Files
lam-agent-vicinae/src/lib/markdown.ts
T
adrianbonpin 05f77fcc7f feat: conversations on the left, the transcript on the right
The transcript was duplicated across both panes, which was the actual
complaint. The left column is now the list of conversations and the right
pane is the transcript of the selected one, so the panes show different
things.

- the open conversation is marked with a check icon and an "Open" tag
- each row carries its own transcript as its detail, so moving the
  selection previews that conversation
- conversations are ordered most-recently-updated first, which keeps the
  one being chatted in at row zero, where Vicinae's selection reset lands
- Return now adapts: with text in the search bar it sends, with an empty
  bar it opens the highlighted conversation (sending an empty prompt was a
  no-op anyway)
- the search bar accessory dropdown is gone. The left column is the
  conversation switcher now, so the dropdown only duplicated it.

Speaker icons in the transcript are not possible, so the labels are plain
text. MdImage renders images block-level, centred, and capped at 200px
tall, and all three candidate sources were tried in a live probe --
icon://person, a raw icon slug, and a file:// asset path -- and each one
fell back to rendering its alt text as a caption. markdown.ts records the
reason so nobody retries it blind.

Verified on screen: one conversation row with an Open tag and a check
icon, the full transcript on the right, and the action bar reading "Open
Conversation" while the search bar is empty. typecheck, eslint, 109 tests
and vici build all pass.
2026-10-04 05:09:15 +08:00

105 lines
3.7 KiB
TypeScript

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 = "▍";
/**
* Speaker labels.
*
* Plain text, not icons. Vicinae's markdown renderer draws images as
* block-level, centred elements capped at 200px tall (`MdImage.qml`), and none
* of the candidate sources resolve inside markdown — `icon://person`, a raw
* icon slug, and a `file://` asset path were all tried and each one fell back
* to rendering the alt text as a caption. An icon therefore cannot sit inline
* beside a message the way a text label can. The dim/bright styling carries
* most of the distinction regardless.
*/
export const USER_LABEL = "You";
export const ASSISTANT_LABEL = "Lam";
/** 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 `**${USER_LABEL}**\n\n${quote(message.text)}`;
}
const body = message.text.trim() || (message.isStreaming ? "_Thinking…_" : "_No response._");
const sections = [`**${ASSISTANT_LABEL}**`, "", 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");
}