feat: display toggles, smart session resume, fix stale transcript pane

Three things.

1. Two new display preferences. "Show Tool Calls" and "Show Reasoning"
   control whether each answer lists the tools Lam called and the model's
   thinking trace. Both default on, so nothing changes on upgrade.

2. Fix: starting a new chat left the previous conversation in the right
   pane. The pane rendered the highlighted row's transcript, and Vicinae
   exposes no way to set the selected row, so the selection could stay put
   when cmd+N prepended a new chat. The pane now derives from the active
   session rather than the row, so it follows the chat the user is
   actually in whatever the selection happens to be. A side benefit is
   that mount now reads only session summaries, not every transcript.

3. "Start New Chat After" (default 1 hour) starts a fresh chat when the
   command opens and the last conversation is older than the window.
   "Never" restores the old always-reopen behaviour, for people who want
   to stay in their last chat. Old conversations are never deleted; they
   just sort into their recency section.

The setup screen lists all three so the current values are visible without
opening preferences.

Verified: setup renders "Show Tool Calls: Shown", "Show Reasoning: Shown",
"Start New Chat After: After 1 hour" with 17 models reachable. typecheck,
eslint, 127 tests and vici build all pass.
This commit is contained in:
2026-10-04 05:21:35 +08:00
parent ffe2c6b7ed
commit 9a791475d3
8 changed files with 304 additions and 73 deletions
+9 -2
View File
@@ -13,7 +13,7 @@ Hit a global shortcut, type a question, get a streamed answer — without leavin
- **Global hotkey, instant input.** Bind a shortcut to the chat command and the search bar is focused before you finish the keystroke.
- **Streaming answers.** Tokens render as they arrive, in a markdown side panel.
- **Persistent conversations.** Every chat is saved locally and can be reopened later.
- **Persistent conversations.** Every chat is saved locally. The sidebar lists them by recency, and reopening the command picks up where you left off — unless it has been idle longer than you allow.
- **Tools.** Lam decides when to call them — current time, location context, web search, and page fetching.
- **Chat only.** Lam answers questions. It does not touch your files or run commands.
@@ -100,6 +100,9 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'
| **System Prompt** | Lam's default | Replaces the built-in prompt entirely. |
| **Web Tools** | on | Enables `web_search` and `web_fetch`. |
| **Reasoning Effort** | Off | `low`/`medium`/`high` for models that support thinking. |
| **Show Tool Calls** | on | Lists the tools Lam called under each answer. |
| **Show Reasoning** | on | Includes the model's thinking trace. Turn off to keep replies short. |
| **Start New Chat After** | 1 hour | Idle time past which opening the command starts a fresh chat. `Never` always reopens the last one. The old chat is never deleted either way. |
## Tools
@@ -141,7 +144,11 @@ 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 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 the left column is a conversation switcher — the builtin fuzzy filter would hide rows as you type a question.
**The two panes show different things on purpose.** Left is the list of conversations; right is the transcript of one. Both are required rather than a layout preference: `Detail` accepts no typed input (`onSearchTextChange` exists only on `List`, `Grid`, `Form` and `Dropdown`), and list rows are a fixed single line, so full message text cannot render in the list. Input needs a `List`; full text needs a `Detail`.
**The two panes show different things on purpose.** Left is the list of conversations; right is the transcript of the open one. Both are required rather than a layout preference: `Detail` accepts no typed input (`onSearchTextChange` exists only on `List`, `Grid`, `Form` and `Dropdown`), and list rows are a fixed single line, so full message text cannot render in the list. Input needs a `List`; full text needs a `Detail`.
**Every sidebar row carries the same detail, and that is deliberate.** The right pane shows the *open* conversation, not the highlighted one. Vicinae exposes no way to set the selected row, so when ⌘N starts a new chat the selection can stay where it was and the pane would keep rendering the previous conversation — which is exactly the bug this replaced. Deriving the detail from the active session rather than the row makes the pane follow the chat the user is actually in, whatever the selection happens to be. It also means only summaries are read on mount, not every transcript. Switching conversations is explicit: Return on an empty search bar, or the action panel.
**Reopening may deliberately start a fresh chat.** `resumeTimeout` is checked against the newest conversation's `updatedAt` before resuming. Past that window the command opens a new chat instead; the old one is untouched and sorted into its recency section in the sidebar.
**Return adapts to the search bar.** With text in it, `↩` sends. Empty — where sending would be a no-op anyway — `↩` opens the highlighted conversation. Every row carries that conversation's transcript as its detail, so moving the selection previews it.
+33
View File
@@ -80,6 +80,39 @@
{ "title": "Medium", "value": "medium" },
{ "title": "High", "value": "high" }
]
},
{
"name": "showToolCalls",
"title": "Show Tool Calls",
"label": "Show which tools Lam used",
"description": "List the tools Lam called under each answer, for example web_search or get_datetime.",
"type": "checkbox",
"required": false,
"default": true
},
{
"name": "showReasoning",
"title": "Show Reasoning",
"label": "Show the model's reasoning",
"description": "Include the model's thinking trace under each answer. Useful for seeing why a model answered the way it did, but it makes replies much longer.",
"type": "checkbox",
"required": false,
"default": true
},
{
"name": "resumeTimeout",
"title": "Start New Chat After",
"description": "When the command opens, resume the last conversation only if it was used recently. Past this much idle time it starts a fresh chat instead. The old conversation is never deleted — it stays in the sidebar.",
"type": "dropdown",
"required": false,
"default": "1h",
"data": [
{ "title": "Never — always reopen the last chat", "value": "never" },
{ "title": "After 15 minutes", "value": "15m" },
{ "title": "After 1 hour", "value": "1h" },
{ "title": "After 6 hours", "value": "6h" },
{ "title": "After 1 day", "value": "1d" }
]
}
],
"dependencies": {
+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 ? (