diff --git a/README.md b/README.md index 366adb9..ab64eae 100644 --- a/README.md +++ b/README.md @@ -54,13 +54,12 @@ To change the model later, either edit the **Model** preference directly, or run | Keys | Action | | --- | --- | | `↩` | Send the message in the search bar | -| `⌘ ↩` | Compose a longer, multi-line message | | `Tab` | Jump to the conversation switcher in the search bar | | `⌘ N` | New chat | -| `⌘ ⇧ C` | Copy the highlighted answer | +| `⌘ ⇧ C` | Copy the highlighted message | | `⌘ ⌫` | Delete a conversation (in **Chat Sessions**) | -Inside the composer: `↩` inserts a new line, `⌘ ↩` sends. +Prompts are single line. An earlier `⌘ ↩` multi-line composer was removed — see [Architecture](#architecture) for why a `Form.TextArea` cannot be laid out cleanly in a launcher. ### Suggested global shortcuts @@ -132,20 +131,24 @@ src/ sessions.ts # LocalStorage persistence history.ts # turns -> API messages location.ts # time/place, derived locally - markdown.ts # turn -> detail-panel markdown + thread.ts # turns -> flat message list + markdown.ts # messages -> detail-panel markdown preferences.ts # preferences, normalised hooks/useChat.ts # the chat state machine components/ChatView.tsx # the chat surface - components/ComposeForm.tsx ``` A few decisions worth knowing about before you change things: -**The search bar is the composer.** It is the only always-focused text input in a `List`, which is what makes "hotkey, then just type" work. It is also single-line and there is no API to change that, which is the entire reason `ComposeForm` exists. `filtering` is off, because this list is a transcript — the builtin fuzzy filter would hide turns as you type a question. +**The search bar is the composer.** It is the only always-focused text input in a `List`, which is what makes "hotkey, then just type" work. It is single-line and there is no API to change that. A multi-line `Form.TextArea` composer was built and then removed: Vicinae always renders it inside `FormField`, which reserves two ninths of the width for a label gutter and two ninths as an empty right spacer, so it shows up as a small box floating in a large empty form. `filtering` is off, because this list is a transcript — the builtin fuzzy filter would hide messages as you type a question. **Every transcript row shares one primary action**, so `↩` always sends whatever is in the search bar regardless of which row is selected. That is what makes the window feel like a single input box rather than a menu. -**Turns render newest first**, so the answer you just asked for is nearest the search bar instead of below the fold. +**Rows are messages, not turns.** Storage keeps a question and its answer together because that is what gets replayed to the model. `thread.ts` flattens that into one message per row so each side can carry its own icon and weight. + +**The user's line is dimmed by putting it in `subtitle`.** List rows are a fixed single line, and the renderer draws `title` in the foreground colour and `subtitle` in a muted one. That is the only lever the extension API offers for varying text weight, so questions go in the subtitle and answers in the title. + +**Messages render oldest first, and the detail panel shows the whole thread.** Vicinae resets list selection to the first row on every model change, and streaming changes the model on every flush, so the selected row cannot be relied on to stay put while an answer arrives. Showing the thread keeps the live answer on screen regardless. **Streamed deltas are buffered in refs and flushed to state on a timer** (`FLUSH_INTERVAL_MS`). Re-rendering the transcript per token makes the launcher visibly janky. diff --git a/src/__tests__/markdown.test.ts b/src/__tests__/markdown.test.ts index 5d8478a..11f2ffb 100644 --- a/src/__tests__/markdown.test.ts +++ b/src/__tests__/markdown.test.ts @@ -1,84 +1,103 @@ import { describe, expect, it } from "vitest"; import { STREAMING_CURSOR, - buildTurnMarkdown, - pendingMarkdown, + buildThreadMarkdown, previewText, + quote, sessionToPlainText, } from "../lib/markdown"; -import type { ChatTurn } from "../lib/types"; +import type { ChatTurn, ThreadMessage } from "../lib/types"; -describe("buildTurnMarkdown", () => { - it("renders the question as a quote block above the answer", () => { - const markdown = buildTurnMarkdown({ prompt: "What is 2+2?", response: "4" }); +function message(overrides: Partial & Pick): ThreadMessage { + return { + id: "m1", + text: "", + preview: "", + createdAt: 0, + isStreaming: false, + thinking: "", + toolCalls: [], + ...overrides, + }; +} - expect(markdown).toContain("> What is 2+2?"); - expect(markdown.indexOf("> What is 2+2?")).toBeLessThan(markdown.indexOf("4")); +describe("quote", () => { + it("keeps a multi-line message inside one quote block", () => { + expect(quote("line one\nline two")).toBe("> line one\n> line two"); + }); +}); + +describe("buildThreadMarkdown", () => { + it("returns nothing for an empty conversation", () => { + expect(buildThreadMarkdown([])).toBe(""); }); - it("keeps a multi-line question inside the quote block", () => { - const markdown = buildTurnMarkdown({ prompt: "line one\nline two", response: "ok" }); + it("renders a question as a quote and an answer as body text", () => { + const markdown = buildThreadMarkdown([ + message({ role: "user", text: "What is 2+2?" }), + message({ role: "assistant", text: "4" }), + ]); - expect(markdown).toContain("> line one\n> line two"); + expect(markdown).toContain("**You**"); + expect(markdown).toContain("> What is 2+2?"); + expect(markdown).toContain("**Lam**"); + expect(markdown).toContain("4"); + }); + + it("keeps the conversation in order", () => { + const markdown = buildThreadMarkdown([ + message({ id: "a", role: "user", text: "first" }), + message({ id: "b", role: "assistant", text: "second" }), + message({ id: "c", role: "user", text: "third" }), + ]); + + expect(markdown.indexOf("first")).toBeLessThan(markdown.indexOf("second")); + expect(markdown.indexOf("second")).toBeLessThan(markdown.indexOf("third")); }); it("marks a streaming answer with a cursor", () => { - const streaming = buildTurnMarkdown({ prompt: "q", response: "partial", isStreaming: true }); - const finished = buildTurnMarkdown({ prompt: "q", response: "partial", isStreaming: false }); + const streaming = buildThreadMarkdown([ + message({ role: "assistant", text: "partial", isStreaming: true }), + ]); + const finished = buildThreadMarkdown([message({ role: "assistant", text: "partial" })]); expect(streaming).toContain(STREAMING_CURSOR); expect(finished).not.toContain(STREAMING_CURSOR); }); - it("shows a placeholder while an answer is still empty", () => { - expect(buildTurnMarkdown({ prompt: "q", response: "", isStreaming: true })).toContain("Thinking"); - expect(buildTurnMarkdown({ prompt: "q", response: "", isStreaming: false })).toContain("No response"); + it("shows a placeholder while an answer has no text yet", () => { + expect(buildThreadMarkdown([message({ role: "assistant", isStreaming: true })])).toContain("Thinking"); + expect(buildThreadMarkdown([message({ role: "assistant" })])).toContain("No response"); }); it("surfaces an error instead of hiding it", () => { - const markdown = buildTurnMarkdown({ prompt: "q", response: "", error: "Ollama rejected the API key." }); + const markdown = buildThreadMarkdown([ + message({ role: "assistant", error: "Ollama rejected the API key." }), + ]); expect(markdown).toContain("Ollama rejected the API key."); }); it("lists tools that were used, flagging failures", () => { - const markdown = buildTurnMarkdown({ - prompt: "q", - response: "a", - toolCalls: [ - { name: "web_search", arguments: {}, result: "ok", isError: false }, - { name: "web_fetch", arguments: {}, result: "boom", isError: true }, - ], - }); + const markdown = buildThreadMarkdown([ + message({ + role: "assistant", + text: "a", + toolCalls: [ + { name: "web_search", arguments: {}, result: "ok", isError: false }, + { name: "web_fetch", arguments: {}, result: "boom", isError: true }, + ], + }), + ]); expect(markdown).toContain("`web_search`"); expect(markdown).toContain("`web_fetch` — failed"); }); - it("omits the reasoning section when there is none", () => { - expect(buildTurnMarkdown({ prompt: "q", response: "a" })).not.toContain("Reasoning"); - expect(buildTurnMarkdown({ prompt: "q", response: "a", thinking: "hmm" })).toContain("Reasoning"); - }); - - it("includes the model and timestamp in the footer", () => { - const markdown = buildTurnMarkdown({ - prompt: "q", - response: "a", - model: "gpt-oss:120b", - createdAt: 1_700_000_000_000, - }); - expect(markdown).toContain("`gpt-oss:120b`"); - }); -}); - -describe("pendingMarkdown", () => { - it("shows the question with an activity line", () => { - const markdown = pendingMarkdown("q", "Searching the web"); - expect(markdown).toContain("> q"); - expect(markdown).toContain("_Searching the web…_"); - }); - - it("falls back to a generic line when there is no status", () => { - expect(pendingMarkdown("q")).toContain("_Thinking…_"); + it("only shows a reasoning section when there is reasoning", () => { + expect(buildThreadMarkdown([message({ role: "assistant", text: "a" })])).not.toContain("Reasoning"); + expect(buildThreadMarkdown([message({ role: "assistant", text: "a", thinking: "hmm" })])).toContain( + "Reasoning", + ); }); }); @@ -90,6 +109,10 @@ describe("previewText", () => { it("clips with an ellipsis", () => { expect(previewText("a".repeat(50), 5)).toHaveLength(5); }); + + it("leaves short text alone", () => { + expect(previewText("hi", 40)).toBe("hi"); + }); }); describe("sessionToPlainText", () => { @@ -111,7 +134,8 @@ describe("sessionToPlainText", () => { }); it("includes the failure when a turn errored", () => { - const text = sessionToPlainText([turn("q", "", "Ollama rejected the API key.")]); - expect(text).toContain("Ollama rejected the API key."); + expect(sessionToPlainText([turn("q", "", "Ollama rejected the API key.")])).toContain( + "Ollama rejected the API key.", + ); }); }); diff --git a/src/__tests__/thread.test.ts b/src/__tests__/thread.test.ts new file mode 100644 index 0000000..d4255c8 --- /dev/null +++ b/src/__tests__/thread.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { MESSAGE_PREVIEW_LENGTH, buildThread, type LiveState } from "../lib/thread"; +import type { ChatTurn } from "../lib/types"; + +const idle: LiveState = { isStreaming: false, response: "", thinking: "" }; + +function turn(id: string, prompt: string, response: string, overrides: Partial = {}): ChatTurn { + return { + id, + prompt, + response, + thinking: "", + model: "m", + createdAt: 100, + toolCalls: [], + ...overrides, + }; +} + +describe("buildThread", () => { + it("returns nothing for an empty conversation", () => { + expect(buildThread([], idle)).toEqual([]); + }); + + it("flattens a turn into a user message followed by an assistant message", () => { + const thread = buildThread([turn("t1", "hello", "hi there")], idle); + + expect(thread.map((message) => message.role)).toEqual(["user", "assistant"]); + expect(thread[0].text).toBe("hello"); + expect(thread[1].text).toBe("hi there"); + }); + + it("keeps messages in chronological order across turns", () => { + const thread = buildThread( + [turn("t1", "first question", "first answer"), turn("t2", "second question", "second answer")], + idle, + ); + + expect(thread.map((message) => message.text)).toEqual([ + "first question", + "first answer", + "second question", + "second answer", + ]); + }); + + it("gives every message a unique id", () => { + const thread = buildThread([turn("t1", "a", "b"), turn("t2", "c", "d")], idle); + const ids = thread.map((message) => message.id); + + expect(new Set(ids).size).toBe(ids.length); + }); + + it("builds one-line previews", () => { + const thread = buildThread([turn("t1", "hello\n\nworld", "a\nlong\n\nanswer")], idle); + + expect(thread[0].preview).toBe("hello world"); + expect(thread[1].preview).toBe("a long answer"); + }); + + it("clips a preview longer than the row can show", () => { + const long = "x".repeat(MESSAGE_PREVIEW_LENGTH + 50); + const thread = buildThread([turn("t1", long, long)], idle); + + expect(thread[0].preview).toHaveLength(MESSAGE_PREVIEW_LENGTH); + }); + + it("streams the newest turn only", () => { + const live: LiveState = { isStreaming: true, response: "streaming now", thinking: "hmm" }; + const thread = buildThread([turn("t1", "old", "old answer"), turn("t2", "new", "")], live); + + // The older turn stays put... + expect(thread[1].isStreaming).toBe(false); + expect(thread[1].text).toBe("old answer"); + + // ...and the newest assistant message reads from the live buffer. + expect(thread[3].isStreaming).toBe(true); + expect(thread[3].text).toBe("streaming now"); + expect(thread[3].thinking).toBe("hmm"); + + // The user's line is never "streaming". + expect(thread[2].isStreaming).toBe(false); + }); + + it("falls back to the activity line while an answer has no text yet", () => { + const live: LiveState = { isStreaming: true, response: "", thinking: "", status: "Searching the web" }; + const thread = buildThread([turn("t1", "q", "")], live); + + expect(thread[1].preview).toBe("Searching the web"); + }); + + it("falls back to a generic label while thinking", () => { + const live: LiveState = { isStreaming: true, response: "", thinking: "" }; + const thread = buildThread([turn("t1", "q", "")], live); + + expect(thread[1].preview).toBe("Thinking…"); + }); + + it("previews a stored error when a finished turn has no answer", () => { + const thread = buildThread([turn("t1", "q", "", { error: "Request failed" })], idle); + + expect(thread[1].preview).toBe("Request failed"); + expect(thread[1].error).toBe("Request failed"); + }); + + it("carries tool calls onto the assistant message", () => { + const withTools = turn("t1", "q", "a", { + toolCalls: [{ name: "web_search", arguments: {}, result: "ok", isError: false }], + }); + const thread = buildThread([withTools], idle); + + expect(thread[0].toolCalls).toEqual([]); + expect(thread[1].toolCalls).toHaveLength(1); + expect(thread[1].toolCalls[0].name).toBe("web_search"); + }); +}); diff --git a/src/lib/components/ChatView.tsx b/src/lib/components/ChatView.tsx index 085d286..ac7e65c 100644 --- a/src/lib/components/ChatView.tsx +++ b/src/lib/components/ChatView.tsx @@ -1,63 +1,58 @@ import { Action, ActionPanel, Icon, Keyboard, List } from "@vicinae/api"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useChat } from "../hooks/useChat"; -import { buildTurnMarkdown, pendingMarkdown, previewText } from "../markdown"; +import { buildThreadMarkdown } from "../markdown"; import { getLamPreferences } from "../preferences"; import { listSessions, loadSession } from "../sessions"; -import type { ChatTurn, Session, SessionSummary } from "../types"; -import { formatRelativeTime, truncate } from "../utils"; -import { ComposeForm } from "./ComposeForm"; +import { buildThread } from "../thread"; +import type { Session, SessionSummary, ThreadMessage } from "../types"; +import { truncate } from "../utils"; /** * The chat surface. * - * Layout notes, because they are consequences of how Vicinae renders lists - * rather than free choices: + * Layout notes, because most of this is a consequence of how Vicinae renders + * lists rather than a free choice: * - * - The search bar doubles as the composer. It is the only always-focused text - * input in a List, which is what makes "hit a hotkey and just start typing" - * work. It is single-line, hence the separate ⌘↩ composer. - * - `filtering` is off. This list is a transcript, not a set of search results, - * so the builtin fuzzy filter would hide turns as the user types a question. - * - Turns render newest first. The answer the user just asked for should be the - * thing nearest the search bar, not something they have to scroll to. - * - Tab reaches the session dropdown in the search bar accessory, which is how - * "Tab to move through previous conversations" is wired. + * - The search bar is the composer. It is the only always-focused text input in + * a List, which is what makes "hit a hotkey, then just start typing" work. + * It is single-line; there is no multi-line composer by design. + * - `filtering` is off. This list is a transcript, so the builtin fuzzy filter + * would hide messages as the user types their next question. + * - Rows are messages, not turns. Storage keeps a question and answer together + * because that is what gets replayed to the model; the transcript flattens + * them so each side gets its own icon and weight. + * - Rows are a fixed single line (38px in `ListItemDelegate.qml`). `title` + * renders in the foreground colour and `subtitle` in a muted one, so the + * user's line goes in the subtitle to make it recede and the answer goes in + * the title to stand out. This is the only way to vary text weight from the + * extension API. + * - Messages are oldest first and the detail panel shows the whole thread. The + * thread is shown rather than just the selected message because Vicinae + * resets list selection to the first row on every model change, and streaming + * changes the model on every flush — so the selected row is not something + * that can be relied on to stay put while an answer arrives. + * - Tab reaches the session dropdown in the search bar accessory. */ /** Sentinel value for the dropdown row that starts a fresh conversation. */ const NEW_SESSION_VALUE = "__new__"; export interface ChatViewProps { - /** Reopens an existing conversation instead of starting a new one. */ + /** Reopens an existing conversation instead of resuming the latest one. */ initialSession?: Session; /** Text handed over by a deeplink or fallback command. */ initialPrompt?: string; } -function detailMarkdown(turn: ChatTurn, live: boolean, streamed: string, streamedThinking: string, status?: string): string { - if (live && !streamed && !turn.response) { - return pendingMarkdown(turn.prompt, status); - } - - return buildTurnMarkdown({ - prompt: turn.prompt, - response: live ? streamed : turn.response, - thinking: live ? streamedThinking : turn.thinking, - toolCalls: turn.toolCalls, - error: live ? undefined : turn.error, - model: turn.model, - createdAt: turn.createdAt, - isStreaming: live, - }); -} - export function ChatView({ initialSession, initialPrompt }: ChatViewProps) { const preferences = useMemo(() => getLamPreferences(), []); const chat = useChat({ preferences, initialSession }); + const { replaceSession } = chat; const [input, setInput] = useState(initialPrompt ?? ""); const [sessions, setSessions] = useState([]); + const [ready, setReady] = useState(Boolean(initialSession)); const refreshSessions = useCallback(() => { void listSessions() @@ -70,6 +65,44 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) { refreshSessions(); }, [refreshSessions, chat.session.updatedAt]); + /** + * Reopening the command continues the most recent conversation rather than + * starting a fresh chat every time. Starting over is explicit, via ⌘N or the + * "New chat" row in the dropdown. + */ + useEffect(() => { + if (initialSession) return; + + let cancelled = false; + void (async () => { + try { + const [latest] = await listSessions(); + if (!latest) return; + const session = await loadSession(latest.id); + if (!cancelled && session) replaceSession(session); + } finally { + if (!cancelled) setReady(true); + } + })(); + + return () => { + cancelled = true; + }; + }, [initialSession, replaceSession]); + + const thread = useMemo( + () => + buildThread(chat.session.turns, { + isStreaming: chat.isStreaming, + response: chat.streamingResponse, + thinking: chat.streamingThinking, + status: chat.status, + }), + [chat.session.turns, chat.isStreaming, chat.streamingResponse, chat.streamingThinking, chat.status], + ); + + const threadMarkdown = useMemo(() => buildThreadMarkdown(thread), [thread]); + const send = useCallback( (text: string) => { chat.ask(text); @@ -78,22 +111,8 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) { [chat.ask], ); - const stopAction = ( - - ); - - const sendAction = ( - send(input)} /> - ); - - const composeAction = ( - } - /> - ); + const stopAction = ; + const sendAction = send(input)} />; const newChatAction = ( ( + const rowActions = (message: ThreadMessage) => ( {chat.isStreaming ? stopAction : sendAction} - {composeAction} - {newChatAction} ); const dropdownItems = useMemo(() => { const hasCurrent = sessions.some((summary) => summary.id === chat.session.id); - const withCurrent: SessionSummary[] = hasCurrent - ? sessions - : [ - { - id: chat.session.id, - title: chat.session.title, - model: chat.session.model, - createdAt: chat.session.createdAt, - updatedAt: chat.session.updatedAt, - turnCount: chat.session.turns.length, - preview: "", - }, - ...sessions, - ]; + if (hasCurrent) return sessions; - return withCurrent; + return [ + { + id: chat.session.id, + title: chat.session.title, + model: chat.session.model, + createdAt: chat.session.createdAt, + updatedAt: chat.session.updatedAt, + turnCount: chat.session.turns.length, + preview: "", + }, + ...sessions, + ]; }, [sessions, chat.session]); - const orderedTurns = useMemo(() => [...chat.session.turns].reverse(), [chat.session.turns]); + if (!ready) { + return ; + } return ( {dropdownItems.map((summary) => ( - + ))} @@ -186,49 +197,31 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) { actions={ {chat.isStreaming ? stopAction : sendAction} - {composeAction} {newChatAction} } > - {orderedTurns.length === 0 ? ( + {thread.length === 0 ? ( ) : ( - orderedTurns.map((turn, index) => { - const live = index === 0 && chat.isStreaming; - - const accessories: List.Item.Accessory[] = [{ text: formatRelativeTime(turn.createdAt) }]; - if (turn.toolCalls.length > 0) { - accessories.unshift({ tag: `${turn.toolCalls.length} tools` }); - } + thread.map((message) => { + const isUser = message.role === "user"; return ( - } - actions={rowActions(turn)} + key={message.id} + id={message.id} + icon={isUser ? Icon.Person : Icon.Stars} + // The renderer draws `title` bright and `subtitle` muted, so the + // question lives in the subtitle and the answer in the title. + title={isUser ? "" : message.preview} + subtitle={isUser ? message.preview : undefined} + detail={} + actions={rowActions(message)} /> ); }) diff --git a/src/lib/components/ComposeForm.tsx b/src/lib/components/ComposeForm.tsx deleted file mode 100644 index a149d1f..0000000 --- a/src/lib/components/ComposeForm.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { Action, ActionPanel, Form, Icon, useNavigation } from "@vicinae/api"; -import { useState } from "react"; - -/** - * The multi-line composer. - * - * This view exists because the launcher's search bar is single-line and there - * is no API to change that. Pressing ⌘↩ in the chat view pushes this view, where - * a real multi-line text area gives the behaviour people expect from a chat box: - * Return inserts a newline, ⌘↩ sends. - * - * It is deliberately stripped down to a single bare editor: - * - * - no field label, so nothing competes with the text itself - * - no description block, because the action bar already renders "Send ⌘↩" - * next to the button, which documents the shortcut without a paragraph - * - the placeholder is one short line rather than an instructions sentence - * - * ## The width limit - * - * A `Form.TextArea` cannot be made truly full-width from the extension API. - * Vicinae always renders it inside its `FormField` component - * (`ExtensionFormView.qml` → `textareaFieldComp`), and `FormField.qml` always - * lays out as three columns with proportional widths 2 / 5 / 2 — a label - * gutter, the editor, and an empty right spacer. That leaves roughly five - * ninths of the width for the editor, and the spacer stays even when there is - * no label. None of it is reachable from the extension side. - * - * So the only lever here is removing clutter, not reclaiming width. If Vicinae - * ever exposes a label-less or full-width field variant, this is the file to - * simplify further. - */ -export interface ComposeFormProps { - initialValue: string; - onSubmit: (text: string) => void; -} - -export function ComposeForm({ initialValue, onSubmit }: ComposeFormProps) { - const { pop } = useNavigation(); - const [error, setError] = useState(undefined); - - return ( -
- { - const text = String(values.prompt ?? "").trim(); - if (!text) { - setError("Write something first."); - return; - } - // Pop before handing off so the chat view is already visible when - // streaming starts, rather than showing a stale composer. - pop(); - onSubmit(text); - }} - /> - - - } - > - - - ); -} diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 64287cc..efa62cb 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -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"); } /** diff --git a/src/lib/thread.ts b/src/lib/thread.ts new file mode 100644 index 0000000..6cf7158 --- /dev/null +++ b/src/lib/thread.ts @@ -0,0 +1,72 @@ +import { previewText } from "./markdown"; +import type { ChatTurn, ThreadMessage } from "./types"; + +/** + * Flattens stored turns into the flat message list the transcript renders. + * + * Storage keeps a question and its answer together as a turn, because that is + * what gets replayed to the model. The UI needs the opposite shape: a flat + * sequence so the user's line and Lam's line can each carry their own icon and + * weight. This is the one place that translation happens. + */ + +/** Rows are a single line, so previews are clipped well before the renderer elides them. */ +export const MESSAGE_PREVIEW_LENGTH = 140; + +export interface LiveState { + isStreaming: boolean; + /** Partial answer for the turn currently in flight. */ + response: string; + thinking: string; + /** Activity line, e.g. "Searching the web". */ + status?: string; +} + +export function buildThread(turns: ChatTurn[], live: LiveState): ThreadMessage[] { + const messages: ThreadMessage[] = []; + const lastIndex = turns.length - 1; + + turns.forEach((turn, index) => { + messages.push({ + id: `${turn.id}:user`, + role: "user", + text: turn.prompt, + preview: previewText(turn.prompt, MESSAGE_PREVIEW_LENGTH), + createdAt: turn.createdAt, + isStreaming: false, + thinking: "", + toolCalls: [], + }); + + // Only the newest turn can be mid-flight. + const isLive = index === lastIndex && live.isStreaming; + const text = isLive ? live.response : turn.response; + + messages.push({ + id: `${turn.id}:assistant`, + role: "assistant", + text, + preview: assistantPreview(text, isLive, live.status, turn.error), + createdAt: turn.createdAt, + isStreaming: isLive, + thinking: isLive ? live.thinking : turn.thinking, + toolCalls: turn.toolCalls, + error: isLive ? undefined : turn.error, + }); + }); + + return messages; +} + +/** + * The row label for an answer. + * + * Before the first token lands there is nothing to preview, so the activity + * line stands in — otherwise the row would sit blank while the model works. + */ +function assistantPreview(text: string, isLive: boolean, status: string | undefined, error?: string): string { + if (text.trim().length > 0) return previewText(text, MESSAGE_PREVIEW_LENGTH); + if (isLive) return status ?? "Thinking…"; + if (error) return error; + return "No response"; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index f4635cb..31f57f6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -85,3 +85,23 @@ export interface ModelInfo { name: string; parameterSize?: string; } + +/** + * One rendered line of the conversation. + * + * A turn is stored as a question/answer pair, but the transcript is displayed as + * a flat sequence of messages so each side can carry its own icon and weight. + */ +export interface ThreadMessage { + id: string; + role: "user" | "assistant"; + /** Full text, used for the detail panel and for copying. */ + text: string; + /** Single-line text for the list row, because rows are one line tall. */ + preview: string; + createdAt: number; + isStreaming: boolean; + thinking: string; + toolCalls: ToolCallRecord[]; + error?: string; +}