feat: display toggles, smart session resume, fix stale transcript pane (#8)

This commit was merged in pull request #8.
This commit is contained in:
2026-10-03 21:22:04 +00:00
parent ffe2c6b7ed
commit ee96f1ee61
8 changed files with 304 additions and 73 deletions
+39
View File
@@ -139,6 +139,45 @@ describe("buildThreadMarkdown", () => {
expect(markdown).toContain("`web_fetch` — failed");
});
it("omits tools and reasoning when the preferences are off", () => {
const messages = [
message({
role: "assistant",
text: "a",
thinking: "let me think",
toolCalls: [{ name: "web_search", arguments: {}, result: "ok", isError: false }],
}),
];
const hidden = buildThreadMarkdown(messages, { showToolCalls: false, showReasoning: false });
expect(hidden).not.toContain("Tools used");
expect(hidden).not.toContain("web_search");
expect(hidden).not.toContain("Reasoning");
expect(hidden).not.toContain("let me think");
// The answer itself must survive either way.
expect(hidden).toContain("a");
});
it("shows one section without the other", () => {
const messages = [
message({
role: "assistant",
text: "a",
thinking: "let me think",
toolCalls: [{ name: "web_search", arguments: {}, result: "ok", isError: false }],
}),
];
const toolsOnly = buildThreadMarkdown(messages, { showToolCalls: true, showReasoning: false });
expect(toolsOnly).toContain("Tools used");
expect(toolsOnly).not.toContain("Reasoning");
const reasoningOnly = buildThreadMarkdown(messages, { showToolCalls: false, showReasoning: true });
expect(reasoningOnly).not.toContain("Tools used");
expect(reasoningOnly).toContain("Reasoning");
});
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(
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_HOST, DEFAULT_MODEL } from "../lib/constants";
import { describeResumeTimeout, resolvePreferences, resolveResumeTimeout } from "../lib/preferences";
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
describe("resolveResumeTimeout", () => {
it("maps each configured window", () => {
expect(resolveResumeTimeout("15m")).toBe(15 * MINUTE);
expect(resolveResumeTimeout("1h")).toBe(HOUR);
expect(resolveResumeTimeout("6h")).toBe(6 * HOUR);
expect(resolveResumeTimeout("1d")).toBe(DAY);
});
it("returns undefined for 'never', which means always reopen the last chat", () => {
expect(resolveResumeTimeout("never")).toBeUndefined();
});
it("returns undefined for missing or unrecognised values rather than guessing", () => {
expect(resolveResumeTimeout(undefined)).toBeUndefined();
expect(resolveResumeTimeout("")).toBeUndefined();
expect(resolveResumeTimeout("banana")).toBeUndefined();
});
it("tolerates surrounding whitespace", () => {
expect(resolveResumeTimeout(" 15m ")).toBe(15 * MINUTE);
});
});
describe("describeResumeTimeout", () => {
it("names the never case rather than showing a number", () => {
expect(describeResumeTimeout(undefined)).toContain("Never");
});
it("describes minutes, hours, and days", () => {
expect(describeResumeTimeout(15 * MINUTE)).toBe("After 15 minutes");
expect(describeResumeTimeout(HOUR)).toBe("After 1 hour");
expect(describeResumeTimeout(6 * HOUR)).toBe("After 6 hours");
expect(describeResumeTimeout(DAY)).toBe("After 1 day");
});
});
describe("resolvePreferences", () => {
it("fills in every optional value", () => {
const resolved = resolvePreferences({});
expect(resolved.model).toBe(DEFAULT_MODEL);
expect(resolved.host).toBe(DEFAULT_HOST);
expect(resolved.webEnabled).toBe(true);
expect(resolved.showToolCalls).toBe(true);
expect(resolved.showReasoning).toBe(true);
expect(resolved.think).toBeUndefined();
expect(resolved.resumeTimeoutMs).toBe(HOUR);
});
it("honours the display toggles being switched off", () => {
const resolved = resolvePreferences({ showToolCalls: false, showReasoning: false });
expect(resolved.showToolCalls).toBe(false);
expect(resolved.showReasoning).toBe(false);
});
it("treats an unknown model or host as unset", () => {
const resolved = resolvePreferences({ model: " ", ollamaHost: "" });
expect(resolved.model).toBe(DEFAULT_MODEL);
expect(resolved.host).toBe(DEFAULT_HOST);
});
it("keeps a local Ollama host", () => {
expect(resolvePreferences({ ollamaHost: "http://localhost:11434" }).host).toBe(
"http://localhost:11434",
);
});
it("treats reasoning 'off' as disabled", () => {
expect(resolvePreferences({ thinking: "off" }).think).toBeUndefined();
expect(resolvePreferences({ thinking: "high" }).think).toBe("high");
});
it("carries the resume window through, including never", () => {
expect(resolvePreferences({ resumeTimeout: "15m" }).resumeTimeoutMs).toBe(15 * MINUTE);
expect(resolvePreferences({ resumeTimeout: "never" }).resumeTimeoutMs).toBeUndefined();
});
});
+56 -65
View File
@@ -1,11 +1,10 @@
import { Action, ActionPanel, Icon, List } from "@vicinae/api";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useChat } from "../hooks/useChat";
import { buildThreadMarkdown } from "../markdown";
import { buildThreadMarkdown, type TranscriptOptions } from "../markdown";
import { getLamPreferences } from "../preferences";
import { deleteSession, listSessions, loadSession, summarizeSession } from "../sessions";
import { buildThread, type LiveState } from "../thread";
import type { ChatTurn, Session, SessionSummary } from "../types";
import { buildThread } from "../thread";import type { Session, SessionSummary } from "../types";
import { recencyGroup, truncate } from "../utils";
/**
@@ -14,17 +13,25 @@ import { recencyGroup, truncate } from "../utils";
* Modelled on Raycast's AI chat, as far as Vicinae allows:
*
* - The sidebar lists conversations by title only, grouped into recency
* sections. Raycast does the same, and it buys the recency cue that
* per-row timestamps were trying to convey without putting a timestamp on
* every single row.
* - The transcript keeps user and assistant visually distinct. Raycast uses
* right-aligned bubbles; markdown offers no alignment control, so a dimmed
* blockquote for the question and body text for the answer is the available
* equivalent.
* sections, which buys the recency cue a timestamp on every row was trying to
* convey while leaving the title room to be readable.
* - User and assistant stay visually distinct. Raycast uses right-aligned
* bubbles; markdown has no alignment control, so a dimmed blockquote for the
* question and body text for the answer is the available equivalent.
* - Raycast pins its composer to the bottom. Vicinae's search bar is fixed at
* the top of the window, so that part cannot be matched.
*
* Most of the rest is a consequence of how Vicinae renders lists:
* ## Why every row carries the same detail
*
* The right pane always shows the *open* conversation, not the highlighted one.
* That is by design rather than laziness: Vicinae exposes no way to set the
* selected row, so when ⌘N starts a new chat, the selection can stay on the row
* the user was on and the pane would keep rendering the previous conversation.
* Rendering the active thread on every row makes the pane follow the chat the
* user is actually in, whatever the selection happens to be. Switching
* conversations is explicit: Return on an empty search bar, or the action panel.
*
* ## Other constraints
*
* - 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.
@@ -33,15 +40,13 @@ import { recencyGroup, truncate } from "../utils";
* - `Detail` accepts no typed input (`onSearchTextChange` exists only on `List`,
* `Grid`, `Form` and `Dropdown`), so the left column cannot be dropped: it is
* the text box.
*
* Ordering matters. Vicinae resets list selection to the first row whenever the
* model changes (`GenericListView.selectFirstOnReset`), and streaming changes
* the model on every flush. Conversations are therefore ordered by most
* recently updated, which keeps the one being chatted in at row zero where the
* selection lands.
* - Conversations are ordered most-recently-updated first. Vicinae resets list
* selection to the first row on every model change
* (`GenericListView.selectFirstOnReset`), and streaming changes the model on
* every flush, so ordering by recency keeps the chat in progress near the top.
*/
const IDLE: LiveState = { isStreaming: false, response: "", thinking: "" };
const EMPTY_TRANSCRIPT = "**Ask Lam anything**\n\nType a question in the search bar and press Return.";
export interface ChatViewProps {
/** Opens a specific conversation instead of resuming the latest one. */
@@ -50,18 +55,11 @@ export interface ChatViewProps {
initialPrompt?: string;
}
interface StoredConversation {
summary: SessionSummary;
turns: ChatTurn[];
}
interface SessionRow {
id: string;
title: string;
updatedAt: number;
isActive: boolean;
/** Precomputed so a render does not rebuild every stored transcript. */
markdown: string;
}
export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
@@ -70,36 +68,27 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
const { replaceSession } = chat;
const [input, setInput] = useState(initialPrompt ?? "");
const [stored, setStored] = useState<StoredConversation[] | undefined>(undefined);
const [summaries, setSummaries] = useState<SessionSummary[] | undefined>(undefined);
const [resumed, setResumed] = useState(Boolean(initialSession));
const activeId = chat.session.id;
/**
* Loads every conversation in full so each row can carry its own transcript
* as a detail. Only the index is stored separately, so this reads the bodies
* once on mount and after each turn rather than on every render.
*/
const reload = useCallback(() => {
void (async () => {
const summaries = await listSessions();
const conversations = await Promise.all(
summaries.map(async (summary) => {
const session = await loadSession(summary.id);
return { summary, turns: session?.turns ?? [] };
}),
);
setStored(conversations);
})();
void listSessions()
.then(setSummaries)
.catch(() => setSummaries([]));
}, []);
// Refreshed after each turn so the sidebar picks up a newly created chat.
useEffect(() => {
reload();
}, [reload, chat.session.updatedAt]);
/**
* Reopening the command continues the most recent conversation rather than
* starting a fresh chat every time. Starting over is explicit: ⌘N.
* Reopening the command continues the most recent conversation, but only if it
* was used recently. Past the configured idle window it starts a fresh chat
* instead — the previous conversation is never removed, it just drops down the
* sidebar. Setting the preference to "Never" keeps the last chat open forever.
*/
useEffect(() => {
if (initialSession) return;
@@ -109,6 +98,11 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
try {
const [latest] = await listSessions();
if (!latest) return;
const idleMs = Date.now() - latest.updatedAt;
const { resumeTimeoutMs } = preferences;
if (resumeTimeoutMs !== undefined && idleMs > resumeTimeoutMs) return;
const session = await loadSession(latest.id);
if (!cancelled && session) replaceSession(session);
} finally {
@@ -119,7 +113,7 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
return () => {
cancelled = true;
};
}, [initialSession, replaceSession]);
}, [initialSession, preferences, replaceSession]);
const thread = useMemo(
() =>
@@ -132,34 +126,36 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
[chat.session.turns, chat.isStreaming, chat.streamingResponse, chat.streamingThinking, chat.status],
);
const transcriptOptions = useMemo<TranscriptOptions>(
() => ({ showToolCalls: preferences.showToolCalls, showReasoning: preferences.showReasoning }),
[preferences.showToolCalls, preferences.showReasoning],
);
const threadMarkdown = useMemo(
() =>
thread.length === 0
? "**Ask Lam anything**\n\nType a question in the search bar and press Return."
: buildThreadMarkdown(thread),
[thread],
thread.length === 0 ? EMPTY_TRANSCRIPT : buildThreadMarkdown(thread, transcriptOptions),
[thread, transcriptOptions],
);
const rows: SessionRow[] = useMemo(() => {
const base = stored ?? [];
const base = summaries ?? [];
const mapped: SessionRow[] = base.map((conversation) => ({
id: conversation.summary.id,
title: conversation.summary.title,
updatedAt: conversation.summary.updatedAt,
isActive: conversation.summary.id === activeId,
markdown: buildThreadMarkdown(buildThread(conversation.turns, IDLE)),
const mapped: SessionRow[] = base.map((summary) => ({
id: summary.id,
title: summary.title,
updatedAt: summary.updatedAt,
isActive: summary.id === activeId,
}));
// A conversation that has not finished a turn yet is not in the index.
if (base.some((conversation) => conversation.summary.id === activeId)) return mapped;
if (base.some((summary) => summary.id === activeId)) return mapped;
const active = summarizeSession(chat.session);
return [
{ id: active.id, title: active.title, updatedAt: active.updatedAt, isActive: true, markdown: "" },
{ id: active.id, title: active.title, updatedAt: active.updatedAt, isActive: true },
...mapped,
];
}, [stored, activeId, chat.session]);
}, [summaries, activeId, chat.session]);
/**
* Groups the sidebar into recency sections. Rows are already sorted by
@@ -245,7 +241,7 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
);
};
if (!resumed || stored === undefined) {
if (!resumed || summaries === undefined) {
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversations…" />;
}
@@ -282,12 +278,7 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
// Title only. Raycast's sidebar shows nothing else, and stacking a
// subtitle plus a timestamp on a 38px row elided the title down to
// a few characters. The check icon is what marks the open chat.
detail={
<List.Item.Detail
markdown={row.isActive ? threadMarkdown : row.markdown}
isLoading={row.isActive && chat.isStreaming}
/>
}
detail={<List.Item.Detail markdown={threadMarkdown} isLoading={chat.isStreaming} />}
actions={rowActions(row)}
/>
))}
+16 -5
View File
@@ -28,6 +28,14 @@ export const STREAMING_CURSOR = "▍";
export const USER_LABEL = "You";
export const ASSISTANT_LABEL = "Lam";
/** 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
@@ -36,7 +44,7 @@ export function quote(text: string): string {
.join("\n");
}
function renderMessage(message: ThreadMessage): string {
function renderMessage(message: ThreadMessage, options: TranscriptOptions): string {
if (message.role === "user") {
return `**${USER_LABEL}**\n\n${quote(message.text)}`;
}
@@ -51,14 +59,14 @@ function renderMessage(message: ThreadMessage): string {
sections.push("", `**Error** — ${message.error}`);
}
if (message.toolCalls.length > 0) {
if (options.showToolCalls && 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) {
if (options.showReasoning && message.thinking.trim().length > 0) {
sections.push("", "_Reasoning_", "", quote(message.thinking.trim()));
}
@@ -79,7 +87,10 @@ function renderMessage(message: ThreadMessage): string {
* separates one exchange from the next rather than dividing every single
* message.
*/
export function buildThreadMarkdown(messages: ThreadMessage[]): string {
export function buildThreadMarkdown(
messages: ThreadMessage[],
options: TranscriptOptions = SHOW_EVERYTHING,
): string {
if (messages.length === 0) return "";
const blocks: string[] = [];
@@ -98,7 +109,7 @@ export function buildThreadMarkdown(messages: ThreadMessage[]): string {
flush();
currentTurn = turnId;
}
buffer.push(renderMessage(message));
buffer.push(renderMessage(message, options));
}
flush();
+45
View File
@@ -17,6 +17,9 @@ export interface LamPreferences {
systemPrompt?: string;
webSearchEnabled?: boolean;
thinking?: string;
showToolCalls?: boolean;
showReasoning?: boolean;
resumeTimeout?: string;
}
/** Preferences with every optional value filled in, so callers never branch on undefined. */
@@ -28,6 +31,43 @@ export interface ResolvedPreferences {
webEnabled: boolean;
/** Undefined when reasoning is off, which is what the API client expects. */
think?: string;
/** Whether the transcript lists the tools Lam called. */
showToolCalls: boolean;
/** Whether the transcript includes the model's thinking trace. */
showReasoning: boolean;
/** Idle time after which opening the command starts a fresh chat. Undefined means never. */
resumeTimeoutMs?: number;
}
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/** Keep in sync with the `resumeTimeout` dropdown in `package.json`. */
const RESUME_TIMEOUTS: Record<string, number> = {
"15m": 15 * MINUTE,
"1h": HOUR,
"6h": 6 * HOUR,
"1d": DAY,
};
/** `undefined` for "never" and for anything unrecognised, which both mean "always reopen the last chat". */
export function resolveResumeTimeout(value: string | undefined): number | undefined {
return RESUME_TIMEOUTS[(value ?? "").trim()];
}
/** Human-readable form of {@link ResolvedPreferences.resumeTimeoutMs}, for the setup screen. */
export function describeResumeTimeout(milliseconds: number | undefined): string {
if (milliseconds === undefined) return "Never — always reopen the last chat";
const minutes = Math.round(milliseconds / MINUTE);
if (minutes < 60) return `After ${minutes} minutes`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `After ${hours} hour${hours === 1 ? "" : "s"}`;
const days = Math.round(hours / 24);
return `After ${days} day${days === 1 ? "" : "s"}`;
}
export function resolvePreferences(raw: LamPreferences): ResolvedPreferences {
@@ -41,6 +81,11 @@ export function resolvePreferences(raw: LamPreferences): ResolvedPreferences {
// Defaults to on: the preference is a checkbox the user has to actively untick.
webEnabled: raw.webSearchEnabled ?? true,
think: thinking && thinking !== "off" ? thinking : undefined,
showToolCalls: raw.showToolCalls ?? true,
showReasoning: raw.showReasoning ?? true,
// Falls back to the manifest's declared default, so a missing preference
// behaves the same whether Vicinae substitutes it or not.
resumeTimeoutMs: resolveResumeTimeout(raw.resumeTimeout ?? "1h"),
};
}
+19 -1
View File
@@ -1,6 +1,6 @@
import { Action, ActionPanel, Icon, List, openExtensionPreferences } from "@vicinae/api";
import { useCallback, useEffect, useMemo, useState } from "react";
import { getLamPreferences, needsSetup } from "./lib/preferences";
import { describeResumeTimeout, getLamPreferences, needsSetup } from "./lib/preferences";
import { listModels } from "./lib/ollama";
import type { ModelInfo } from "./lib/types";
import { errorMessage } from "./lib/utils";
@@ -131,6 +131,24 @@ export default function SetupCommand() {
subtitle={preferences.think ?? "Off"}
actions={settingsActions}
/>
<List.Item
icon={Icon.BulletPoints}
title="Show Tool Calls"
subtitle={preferences.showToolCalls ? "Shown" : "Hidden"}
actions={settingsActions}
/>
<List.Item
icon={Icon.SpeechBubble}
title="Show Reasoning"
subtitle={preferences.showReasoning ? "Shown" : "Hidden"}
actions={settingsActions}
/>
<List.Item
icon={Icon.ArrowClockwise}
title="Start New Chat After"
subtitle={describeResumeTimeout(preferences.resumeTimeoutMs)}
actions={settingsActions}
/>
</List.Section>
{error ? (