import { imageSource } from "./attachments"; import { renderInlineContent, renderModelOutput } from "./render"; import { describeActivity } from "./thread"; import type { Attachment, ChatTurn, ThreadMessage, ToolCallRecord } 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"); } /** How many calls failed for a reason other than being refused as a repeat. */ function countFailures(calls: ToolCallRecord[]): number { // A refused repeat is an `isError` too, but it did not fail: it is the loop // guard noticing a rut, which is a different thing and is said differently. return calls.filter((call) => call.isError && !call.repeated).length; } /** Whether the turn was cut short because a call kept repeating itself. */ function wasStopped(calls: ToolCallRecord[]): boolean { return calls.some((call) => call.repeated); } /** * One italic line naming the tools a turn used, each mentioned once. * * Listing every call in order said nothing a name does not, and on a research * turn it said it twenty times: `_Tools: web_search, web_search, web_search, * web_fetch (failed), web_search…_` ran to three lines of the same two words. * Names in order of first use, then a count of what went wrong, which is the * only part of that repetition that carries information. */ function renderTools(message: ThreadMessage): string { const names: string[] = []; for (const call of message.toolCalls) { if (!names.includes(call.name)) names.push(call.name); } const listed = names.map((name) => `\`${name}\``).join(", "); const failed = countFailures(message.toolCalls); const notes: string[] = []; if (failed > 0) notes.push(`${failed} failed`); if (wasStopped(message.toolCalls)) notes.push("stopped: the same call kept repeating"); const suffix = notes.length > 0 ? ` (${notes.join(", ")})` : ""; return `_Tools: ${listed}${suffix}_`; } /** * The one line shown when tool calls are hidden but something went wrong. * * Hiding the list is the user's choice, and a list of names is what they turned * off. A turn where a call failed or was refused as a repeat is different: the * answer alone would not account for it, so it is said in a sentence rather than * dropped, and the names stay hidden. */ function renderToolFailureNote(message: ThreadMessage): string | undefined { const failed = countFailures(message.toolCalls); const stopped = wasStopped(message.toolCalls); if (failed === 0 && !stopped) return undefined; const parts: string[] = []; if (failed > 0) parts.push(`${failed} tool call${failed === 1 ? "" : "s"} failed`); if (stopped) parts.push("a call kept repeating, so the turn was cut short"); return `_${parts.join(" and ")}._`; } function renderMessage(message: ThreadMessage, options: TranscriptOptions): string { // The question is identified by its styling alone. It goes through the same // rendering pass as an answer, because a person pastes LaTeX into a question // as readily as a model writes it, and a question full of raw HTML should be // put behind the same fence. if (message.role === "user") { const images = renderAttachments(message.attachments); const asked = quote(renderInlineContent(message.text)); return images ? `${asked}\n\n${images}` : asked; } const thinking = message.thinking.trim(); /** * Live reasoning is shown while a turn is in flight whether or not the * preference is on: it is the only sign of progress when a model is thinking * rather than writing, and waiting on a spinner is worse than reading the * thought. Once the turn ends it stays only if the user asked for it, so * `Show Reasoning` still governs the finished transcript. */ const liveThinking = message.isStreaming && thinking.length > 0; /** The stored trace, kept as an appendix under the answer it produced. */ const storedThinking = !message.isStreaming && options.showReasoning && thinking.length > 0; const showThinking = liveThinking || storedThinking; // 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. It is shown // whether or not tokens have arrived, because a tool that starts after the // answer has begun would otherwise be invisible for the rest of the turn. const activityLine = message.isStreaming && message.activity ? `_${describeActivity(message.activity)}…_` : undefined; /** The answer, or whatever stands in for it while there is none. */ const answered = message.text.trim(); const body = answered ? // One pass, in one place: fences get their languages and links recovered, // then TeX becomes something drawable and the HTML around it is filtered to // the subset the renderer acts on. See `render.ts` for the order and why. renderModelOutput(answered).text : !message.isStreaming ? "_No response._" : activityLine ? // The activity line above is already saying what is being waited on, so // printing the placeholder as well put the same sentence on screen // twice: once as a note above the transcript and once as the stand-in. undefined : liveThinking ? // The reasoning below is already saying the model is working, so // "Thinking…" on top of it would only repeat it. undefined : message.status ? `_${message.status}…_` : "_Thinking…_"; const sections: string[] = []; const activityIndex = activityLine ? sections.push(activityLine) - 1 : -1; const bodyIndex = body ? sections.push(body) - 1 : -1; // 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}`); /** * Tool calls are a display preference. When they are on, every tool is named * once. When they are off, nothing is listed; a failure or a refused repeat * still gets one quiet sentence, because those are the cases where hiding the * line would leave the user with an answer they cannot account for. */ if (options.showToolCalls && message.toolCalls.length > 0) { sections.push("", renderTools(message)); } else if (!options.showToolCalls) { const note = renderToolFailureNote(message); if (note) sections.push("", note); } if (showThinking) { // Only live reasoning can be the first thing in a message, so the blank // separator is conditional: otherwise the transcript opens on an empty line. if (sections.length > 0) sections.push(""); sections.push("_Reasoning_", "", quote(thinking)); } /** * The cursor marks what is arriving. The answer wins when there is one, then * the live reasoning, and only then the activity line: reasoning on screen * means the model is thinking, which is newer than the tool it called to get * there. It is placed before the quiet sections so it never lands on a tool * list or an appendix. */ if (message.isStreaming) { const reasoningIndex = showThinking && liveThinking ? sections.length - 1 : -1; const target = bodyIndex >= 0 ? bodyIndex : reasoningIndex >= 0 ? reasoningIndex : activityIndex; if (target >= 0) sections[target] = `${sections[target]} ${STREAMING_CURSOR}`; } 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"); }