feat: display toggles, smart session resume, fix stale transcript pane (#8)
This commit was merged in pull request #8.
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user