import type { ChatTurn, ThreadMessage } from "./types"; /** * Markdown rendering for the detail panel. * * Kept as pure functions so the transcript layout can be tested without * mounting a component. * * The user/assistant distinction is carried by markdown itself: user messages * render as blockquotes, which the renderer draws in a dimmed italic style, * while answers render as normal body text. That mirrors how the list rows * dim the question and brighten the answer, so the two panes agree. */ export const STREAMING_CURSOR = "▍"; /** Prefixes every line so a multi-line message stays one continuous quote block. */ export function quote(text: string): string { return text .split("\n") .map((line) => `> ${line}`.trimEnd()) .join("\n"); } /** A short one-line preview of a message, for a list row. */ export function previewText(text: string, max: number): string { const collapsed = text.replace(/\s+/g, " ").trim(); if (collapsed.length <= max) return collapsed; return `${collapsed.slice(0, Math.max(0, max - 1)).trimEnd()}…`; } function renderMessage(message: ThreadMessage): string { if (message.role === "user") { return `**You**\n\n${quote(message.text)}`; } const body = message.text.trim() || (message.isStreaming ? "_Thinking…_" : "_No response._"); const sections = ["**Lam**", "", message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body]; if (message.error) { sections.push("", `**Error** — ${message.error}`); } if (message.toolCalls.length > 0) { const lines = message.toolCalls.map( (call) => `- \`${call.name}\`${call.isError ? " — failed" : ""}`, ); sections.push("", "**Tools used**", ...lines); } if (message.thinking.trim().length > 0) { sections.push("", "_Reasoning_", "", quote(message.thinking.trim())); } return sections.join("\n"); } /** * Renders the whole conversation. * * The detail panel shows the full thread rather than just the selected message * on purpose. Vicinae's list resets its selection to the first row whenever the * model changes, and streaming changes it on every flush, so the selected row * is not something the extension can rely on staying put while an answer * arrives. Showing the thread means the live answer is always on screen. */ export function buildThreadMarkdown(messages: ThreadMessage[]): string { if (messages.length === 0) return ""; return messages.map(renderMessage).join("\n\n---\n\n"); } /** * Flattens a whole conversation into plain text for the clipboard. * * This has to load the full transcript rather than reuse the index summary — * copying a truncated preview and calling it "the conversation" would be a * quiet lie. */ export function sessionToPlainText(turns: ChatTurn[]): string { if (turns.length === 0) return ""; return turns .map((turn) => { const header = `## ${turn.prompt}`; const body = turn.response.trim() || (turn.error ? "" : "_(no response)_"); const failure = turn.error ? `\n\n**[error]** ${turn.error}` : ""; return `${header}\n\n${body}${failure}`; }) .join("\n\n---\n\n"); }