feat: message-per-row transcript, session resume, drop the composer (#4)

This commit was merged in pull request #4.
This commit is contained in:
2026-10-03 20:59:01 +00:00
parent af0c75a65d
commit 31eb688455
8 changed files with 444 additions and 300 deletions
+10 -7
View File
@@ -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.
+72 -48
View File
@@ -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<ThreadMessage> & Pick<ThreadMessage, "role">): 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",
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.",
);
});
});
+116
View File
@@ -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> = {}): 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");
});
});
+95 -102
View File
@@ -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<SessionSummary[]>([]);
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 = (
<Action title="Stop Generating" icon={Icon.Stop} onAction={chat.cancel} />
);
const sendAction = (
<Action title="Send" icon={Icon.SpeechBubble} onAction={() => send(input)} />
);
const composeAction = (
<Action.Push
title="Compose Longer Message"
icon={Icon.Text}
shortcut={{ key: "enter", modifiers: ["cmd"] }}
target={<ComposeForm initialValue={input} onSubmit={send} />}
/>
);
const stopAction = <Action title="Stop Generating" icon={Icon.Stop} onAction={chat.cancel} />;
const sendAction = <Action title="Send" icon={Icon.SpeechBubble} onAction={() => send(input)} />;
const newChatAction = (
<Action
@@ -105,29 +124,27 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
);
/**
* Every row carries the same primary action. Enter therefore always sends the
* current search text, whichever row happens to be selected, which is what
* makes the whole window feel like a single input box.
* Every row carries the same primary action, so Return always sends whatever
* is in the search bar whichever row happens to be selected. That is what
* makes the window feel like a single input box rather than a menu.
*/
const rowActions = (turn: ChatTurn) => (
const rowActions = (message: ThreadMessage) => (
<ActionPanel>
{chat.isStreaming ? stopAction : sendAction}
{composeAction}
<Action.CopyToClipboard
title="Copy Answer"
content={turn.response}
title={message.role === "user" ? "Copy Question" : "Copy Answer"}
content={message.text}
shortcut={Keyboard.Shortcut.Common.Copy}
/>
<Action.CopyToClipboard title="Copy Question" content={turn.prompt} />
{newChatAction}
</ActionPanel>
);
const dropdownItems = useMemo(() => {
const hasCurrent = sessions.some((summary) => summary.id === chat.session.id);
const withCurrent: SessionSummary[] = hasCurrent
? sessions
: [
if (hasCurrent) return sessions;
return [
{
id: chat.session.id,
title: chat.session.title,
@@ -139,11 +156,11 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
},
...sessions,
];
return withCurrent;
}, [sessions, chat.session]);
const orderedTurns = useMemo(() => [...chat.session.turns].reverse(), [chat.session.turns]);
if (!ready) {
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversation…" />;
}
return (
<List
@@ -153,9 +170,7 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
searchText={input}
onSearchTextChange={setInput}
navigationTitle="Lam"
searchBarPlaceholder={
chat.isStreaming ? "Lam is thinking — press ⏎ to stop" : "Ask Lam anything…"
}
searchBarPlaceholder={chat.isStreaming ? "Lam is thinking — press ⏎ to stop" : "Ask Lam anything…"}
searchBarAccessory={
<List.Dropdown
tooltip="Switch conversation"
@@ -174,11 +189,7 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
<List.Dropdown.Item title="+ New chat" value={NEW_SESSION_VALUE} />
<List.Dropdown.Section title="Recent">
{dropdownItems.map((summary) => (
<List.Dropdown.Item
key={summary.id}
title={truncate(summary.title, 44)}
value={summary.id}
/>
<List.Dropdown.Item key={summary.id} title={truncate(summary.title, 44)} value={summary.id} />
))}
</List.Dropdown.Section>
</List.Dropdown>
@@ -186,49 +197,31 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
actions={
<ActionPanel>
{chat.isStreaming ? stopAction : sendAction}
{composeAction}
{newChatAction}
</ActionPanel>
}
>
{orderedTurns.length === 0 ? (
{thread.length === 0 ? (
<List.EmptyView
icon={Icon.Stars}
title="Ask Lam anything"
description="Type a question and press Return. Use ⌘↩ for a longer, multi-line message."
description="Type a question and press Return."
/>
) : (
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 (
<List.Item
key={turn.id}
id={turn.id}
icon={live ? Icon.ArrowClockwise : Icon.SpeechBubble}
title={truncate(turn.prompt, 72)}
subtitle={
live ? (chat.status ?? "Thinking…") : previewText(turn.response || turn.error || "", 90)
}
accessories={accessories}
detail={
<List.Item.Detail
markdown={detailMarkdown(
turn,
live,
chat.streamingResponse,
chat.streamingThinking,
chat.status,
)}
isLoading={live}
/>
}
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={<List.Item.Detail markdown={threadMarkdown} isLoading={message.isStreaming} />}
actions={rowActions(message)}
/>
);
})
-76
View File
@@ -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<string | undefined>(undefined);
return (
<Form
navigationTitle="Compose"
actions={
<ActionPanel>
<Action.SubmitForm
title="Send"
icon={Icon.SpeechBubble}
shortcut={{ key: "enter", modifiers: ["cmd"] }}
onSubmit={(values) => {
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);
}}
/>
<Action title="Cancel" onAction={pop} />
</ActionPanel>
}
>
<Form.TextArea
id="prompt"
placeholder="Ask Lam anything…"
defaultValue={initialValue}
error={error}
autoFocus
/>
</Form>
);
}
+45 -53
View File
@@ -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");
}
/**
+72
View File
@@ -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";
}
+20
View File
@@ -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;
}