feat: message-per-row transcript, session resume, drop the composer (#4)
This commit was merged in pull request #4.
This commit is contained in:
+45
-53
@@ -1,80 +1,72 @@
|
||||
import type { ChatTurn } from "./types";
|
||||
import type { ChatTurn, ThreadMessage } from "./types";
|
||||
|
||||
/**
|
||||
* Renders a turn as markdown for the detail panel.
|
||||
* Markdown rendering for the detail panel.
|
||||
*
|
||||
* Kept as a pure function so the transcript layout can be tested without
|
||||
* mounting a component, and so the same renderer can serve both a finished
|
||||
* turn and one that is still streaming.
|
||||
* 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 = "▍";
|
||||
|
||||
export interface TurnMarkdownInput {
|
||||
prompt: string;
|
||||
response: string;
|
||||
thinking?: string;
|
||||
toolCalls?: ChatTurn["toolCalls"];
|
||||
error?: string;
|
||||
model?: string;
|
||||
createdAt?: number;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
/** Prefixes every line so a multi-line prompt renders as one continuous quote block. */
|
||||
function quote(text: string): string {
|
||||
/** 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");
|
||||
}
|
||||
|
||||
export function buildTurnMarkdown(input: TurnMarkdownInput): string {
|
||||
const sections: string[] = [];
|
||||
/** 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()}…`;
|
||||
}
|
||||
|
||||
sections.push(quote(input.prompt));
|
||||
sections.push("");
|
||||
|
||||
const body = input.response.trim() || (input.isStreaming ? "_Thinking…_" : "_No response._");
|
||||
sections.push(input.isStreaming ? `${body} ${STREAMING_CURSOR}` : body);
|
||||
|
||||
if (input.error) {
|
||||
sections.push("", `**Error** — ${input.error}`);
|
||||
function renderMessage(message: ThreadMessage): string {
|
||||
if (message.role === "user") {
|
||||
return `**You**\n\n${quote(message.text)}`;
|
||||
}
|
||||
|
||||
const toolCalls = input.toolCalls ?? [];
|
||||
if (toolCalls.length > 0) {
|
||||
const lines = toolCalls.map((call) => `- \`${call.name}\`${call.isError ? " — failed" : ""}`);
|
||||
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 (input.thinking && input.thinking.trim().length > 0) {
|
||||
sections.push("", "---", "", "**Reasoning**", "", quote(input.thinking.trim()));
|
||||
}
|
||||
|
||||
const footer: string[] = [];
|
||||
if (input.model) footer.push(`\`${input.model}\``);
|
||||
if (input.createdAt) footer.push(new Date(input.createdAt).toLocaleString());
|
||||
|
||||
if (footer.length > 0) {
|
||||
sections.push("", "---", "", footer.join(" · "));
|
||||
if (message.thinking.trim().length > 0) {
|
||||
sections.push("", "_Reasoning_", "", quote(message.thinking.trim()));
|
||||
}
|
||||
|
||||
return sections.join("\n");
|
||||
}
|
||||
|
||||
/** The placeholder shown while the first tokens are still in flight. */
|
||||
export function pendingMarkdown(prompt: string, status?: string): string {
|
||||
const lines = [quote(prompt), ""];
|
||||
lines.push(status ? `_${status}…_` : "_Thinking…_");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** A short one-line preview of an answer, for a collapsed 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()}…`;
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user