import { imageSource } from "./attachments"; import { repairCodeBlocks } from "./code"; import type { Attachment, 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. * * ## Emphasis * * Nothing here is bold except an error. The answer is what is being read, so it * is plain body text and carries no label. The question is a blockquote, which * the renderer draws dimmed, italic, and with a left rule. Putting a `**You**` * and a `**Lam**` header on each exchange gave both speakers the same visual * weight and made the reader scan past chrome to reach the answer. * * Raycast solves the same problem by aligning messages: right-aligned bubbles * for the user. Markdown offers no alignment control, so styling is the only * lever available. */ export const STREAMING_CURSOR = "▍"; /** * Renders attachments as markdown images. * * Vicinae does resolve a local file: `MdImage.qml` draws it block-level and * centred, uses the alt text as an italic caption underneath, and caps the * height at 200px no matter what is asked for. So this is a thumbnail rather * than something readable, which is why the action panel also offers Open * Screenshot for the full image. * * The alt text is short on purpose. Whatever is here is drawn centred under the * image, so a sentence reads as a stray line of prose floating in the middle of * the pane. It is also the only thing left if the file has since been deleted, * which is why it is not empty. */ export function renderAttachments(attachments: Attachment[]): string { return attachments.map((attachment) => `![Screenshot](${imageSource(attachment)})`).join("\n\n"); } /** Which optional sections the transcript includes. Driven by preferences. */ export interface TranscriptOptions { showToolCalls: boolean; showReasoning: boolean; } const SHOW_EVERYTHING: TranscriptOptions = { showToolCalls: true, showReasoning: true }; /** 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"); } /** One italic line, well below the weight of the answer it belongs to. */ function renderTools(message: ThreadMessage): string { const names = message.toolCalls .map((call) => `\`${call.name}\`${call.isError ? " (failed)" : ""}`) .join(", "); return `_Tools: ${names}_`; } function renderMessage(message: ThreadMessage, options: TranscriptOptions): string { // The question is identified by its styling alone. if (message.role === "user") { const images = renderAttachments(message.attachments); const asked = quote(message.text); return images ? `${asked}\n\n${images}` : asked; } // While a tool is running there is no answer text yet, so the activity line // stands in and the user can see what Lam is actually doing. const placeholder = message.status ? `_${message.status}…_` : "_Thinking…_"; const body = message.text.trim() ? // Fences without a language render as a plain dark panel, and a lone // inline command renders as a chip with no Copy button at all. Both are // repaired before the markdown reaches the renderer. repairCodeBlocks(message.text.trim()) : message.isStreaming ? placeholder : "_No response._"; const sections = [message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body]; // The one thing allowed to break the plain-text rule, because it means // something went wrong and should not be skimmed past. if (message.error) sections.push("", `**Error:** ${message.error}`); if (options.showToolCalls && message.toolCalls.length > 0) { sections.push("", renderTools(message)); } if (options.showReasoning && message.thinking.trim().length > 0) { sections.push("", "_Reasoning_", "", quote(message.thinking.trim())); } return sections.join("\n"); } /** * Renders the whole conversation, newest message first. * * The detail panel shows the entire thread rather than just one 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 whole thread means the live answer is always on screen. * * Messages are grouped back into exchanges for layout: an answer and the * question it responds to belong to the same block, so the horizontal rule only * separates one exchange from the next rather than dividing every single * message. That rule earns its place with newest-first ordering, where the * boundary between two answers would otherwise be easy to miss. */ export function buildThreadMarkdown( messages: ThreadMessage[], options: TranscriptOptions = SHOW_EVERYTHING, ): string { if (messages.length === 0) return ""; const blocks: string[] = []; let currentTurn = ""; let buffer: string[] = []; const flush = () => { if (buffer.length > 0) blocks.push(buffer.join("\n\n")); buffer = []; }; for (const message of messages) { // `turnId:role`, so the turn is the id without its role suffix. const turnId = message.id.replace(/:(assistant|user)$/, ""); if (turnId !== currentTurn) { flush(); currentTurn = turnId; } buffer.push(renderMessage(message, options)); } flush(); return blocks.join("\n\n---\n\n"); } /** * Flattens a whole conversation into plain text for the clipboard. * * Unlike the transcript this reads oldest-first: as a standalone document, a * conversation is easier to read in the order it happened. * * This needs labels because the transcript's styling (blockquotes and dimmed * text) is gone by the time it reaches a clipboard. */ export function sessionToPlainText(turns: ChatTurn[]): string { if (turns.length === 0) return ""; return turns .map((turn) => { const question = `**You**\n\n${turn.prompt}`; const attached = turn.attachments.length > 0 ? "\n\n_(screenshot attached)_" : ""; const answer = turn.response.trim() || (turn.error ? "" : "_(no response)_"); const failure = turn.error ? `\n\n**[error]** ${turn.error}` : ""; return `${question}${attached}\n\n**Lam**\n\n${answer}${failure}`; }) .join("\n\n---\n\n"); }