fix: name each tool once, and stream the reasoning live (#18)

This commit was merged in pull request #18.
This commit is contained in:
2026-10-06 14:18:50 +00:00
parent ff99778e5d
commit 550a2bbefe
7 changed files with 426 additions and 58 deletions
+8 -4
View File
@@ -131,8 +131,8 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'
| **System Prompt** | Lam's default | Replaces the built-in prompt entirely. The default asks for plain prose: no em dashes, no generated-text tells, no restating the question. |
| **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. |
| **Show Tool Calls** | on | Lists which tools Lam called, each named once, with a count of any that failed. Off hides the names; a failed or repeating call is still noted in one line. |
| **Show Reasoning** | on | Includes the model's thinking trace under each answer. Turn off to keep replies short. Either way the trace streams live during a turn and disappears when the turn ends. |
| **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. |
| **Delete Old Chats After** | Disabled | Permanently deletes a conversation once you have not opened it for this long, along with any screenshots it captured. Nothing is ever deleted while this is off. See [Auto-deletion](#auto-deletion). |
| **Tool Call Limit** | 8 rounds | How many rounds of tool calls Lam may make before it has to answer with what it has. `Unlimited` removes the cap. A call that repeats itself with identical arguments ends the turn either way. |
@@ -225,7 +225,9 @@ The cap is a preference rather than a constant, because how many rounds a questi
Arguments are compared with their keys sorted, because models are not consistent about key order and a call that differs only by key order is still the same call.
**What Lam is doing is visible while it is doing it, even with tool calls hidden.** `Show Tool Calls` governs the finished transcript, where a list of names adds little. The live activity line is separate and always shown, because during a turn the difference between working and circling is the entire question: `_Reading a page…_` for a first call, and `_Reading a page · the same call again (2)…_` once it has come round. Hiding that would leave the user watching a spinner they cannot interpret, which is the situation the repeat guard exists to fix. A call that failed, or that was refused for repeating, is also listed in the transcript even with tool calls hidden: those are the two cases where hiding the line leaves an answer the user cannot account for.
**What Lam is doing is visible while it is doing it, even with tool calls hidden.** `Show Tool Calls` governs the finished transcript, where a list of names adds little. The live activity line is separate and always shown, because during a turn the difference between working and circling is the entire question: `_Reading a page…_` for a first call, and `_Reading a page · the same call again (2)…_` once it has come round. Hiding that would leave the user watching a spinner they cannot interpret, which is the situation the repeat guard exists to fix.
When tool calls are off, nothing is listed. A call that failed, or that was cut short for repeating, still gets one quiet sentence (`_2 tool calls failed._`, `_a call kept repeating, so the turn was cut short._`) rather than the names: those are the two cases where hiding the line leaves an answer the user cannot account for, and naming the tools was never the part that mattered.
**That line is rendered in exactly one place, on the live message.** It is not a view-level note above the transcript, and it is not a substitute for missing text either: it is shown whether or not tokens have arrived, because a tool that starts mid-answer would otherwise be invisible for the rest of the turn. The first version had both, a note in the view and the placeholder in the transcript, and a tool running before any text arrived printed "Reading a page" twice, one line above the other. The cursor rides on whichever line is written last, so it always trails the text actually arriving.
@@ -308,7 +310,9 @@ A few decisions worth knowing about before you change things:
**The transcript reads newest message first.** The search bar sits at the top of the window, so the most recent message, an answer once one has been written, is the first line under it rather than below the fold. Within a single exchange that means the answer appears above the question it responds to. `buildThread` produces that order, and `buildThreadMarkdown` regroups messages back into exchanges so the horizontal rule separates one exchange from the next instead of dividing every message.
**The transcript is text plus the attached images, and nothing in it is bold except an error.** The answer is what gets read, so it is plain body text carrying no label; the question is a blockquote, which the renderer draws dimmed, italic, and with a left rule. A `**You**` / `**Lam**` header on each exchange gave both speakers identical weight and made the answer sit behind chrome. Tool calls collapse to one italic line (_Tools: `web_search`_) rather than a bold header plus a list, so they stay below the answer too. Raycast solves the same problem with alignment, but markdown offers no alignment control.
**The transcript is text plus the attached images, and nothing in it is bold except an error.** The answer is what gets read, so it is plain body text carrying no label; the question is a blockquote, which the renderer draws dimmed, italic, and with a left rule. A `**You**` / `**Lam**` header on each exchange gave both speakers identical weight and made the answer sit behind chrome. Tool calls collapse to one italic line naming each tool once, with what went wrong counted rather than repeated, so they stay below the answer too. Raycast solves the same problem with alignment, but markdown offers no alignment control.
**The reasoning is live while a turn is in flight, and a preference only governs where it settles.** A model that is thinking rather than writing has nothing else on screen, so the trace streams into the transcript whatever `Show Reasoning` says; hiding it would leave a spinner and no way to tell thinking from hanging. When the turn ends the trace either stays as an appendix under the answer (on) or disappears (off), which is what the preference actually controls. `useChat` keeps two views of the stream for the same reason: the full trace is what gets stored on the turn, and only the segment since the last tool call is shown live, cleared each time a tool starts. Otherwise a research turn that thinks before each of a dozen calls stacks every round's reasoning on screen at once, and the tool it just asked for runs underneath a wall of text that is no longer about anything.
**An attached screenshot is rendered, not described.** This is a correction: the transcript used to say `_Screenshot attached_` and leave it at that, on the belief that Vicinae could not resolve a local image. It can. `imageProviderUrl` in `markdown-model.cpp` routes `file://` (and a scheme-less string) through `ImageURL::local`, and `MdImage.qml` draws it block-level and centred with the alt text as an italic caption underneath.
+174 -12
View File
@@ -233,7 +233,7 @@ describe("buildThreadMarkdown", () => {
expect(markdown).toContain("[https://example.com](https://example.com)");
});
it("lists tools on one quiet line, flagging failures", () => {
it("lists tools on one quiet line, each named once, flagging failures", () => {
const markdown = buildThreadMarkdown([
message({
role: "assistant",
@@ -245,11 +245,50 @@ describe("buildThreadMarkdown", () => {
}),
]);
expect(markdown).toContain("_Tools: `web_search`, `web_fetch` (failed)_");
expect(markdown).toContain("_Tools: `web_search`, `web_fetch` (1 failed)_");
// Italic, not a bold header, so it stays below the answer.
expect(markdown).not.toContain("**Tools");
});
it("names a tool once however many times it was called", () => {
// The reported case: a research turn listed the same two tools twenty times
// over three lines, which is a wall of text that says nothing a name does
// not. Names in order of first use, no repetition.
const markdown = buildThreadMarkdown([
message({
role: "assistant",
text: "a",
toolCalls: [
{ name: "web_search", arguments: {}, result: "ok", isError: false },
{ name: "web_fetch", arguments: {}, result: "ok", isError: false },
{ name: "web_search", arguments: {}, result: "ok", isError: false },
{ name: "web_search", arguments: {}, result: "ok", isError: false },
],
}),
]);
expect(markdown).toContain("_Tools: `web_search`, `web_fetch`_");
expect(markdown.match(/web_search/g)).toHaveLength(1);
});
it("counts every failed call, not just the failed tools", () => {
const markdown = buildThreadMarkdown([
message({
role: "assistant",
text: "a",
toolCalls: [
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
{ name: "web_search", arguments: {}, result: "ok", isError: false },
],
}),
]);
expect(markdown).toContain("_Tools: `web_fetch`, `web_search` (3 failed)_");
expect(markdown.match(/web_fetch/g)).toHaveLength(1);
});
it("omits tools and reasoning when the preferences are off", () => {
const messages = [
message({
@@ -307,13 +346,30 @@ describe("buildThreadMarkdown", () => {
}),
]);
expect(markdown).toContain("`web_search` (repeated, stopped)");
expect(markdown).not.toContain("(failed)");
expect(markdown).toContain("_Tools: `web_search` (stopped: the same call kept repeating)_");
expect(markdown).not.toContain("failed");
});
it("shows a failed call even when tool calls are hidden", () => {
// Hiding the line would leave the user with an answer they cannot account
// for, which is worse than a quiet italic line they did not ask for.
it("says a call failed and a call repeated, when both happened", () => {
const markdown = buildThreadMarkdown([
message({
role: "assistant",
text: "a",
toolCalls: [
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
{ name: "web_search", arguments: {}, result: "Refused", isError: true, repeated: true },
],
}),
]);
expect(markdown).toContain(
"_Tools: `web_fetch`, `web_search` (1 failed, stopped: the same call kept repeating)_",
);
});
it("hides a failed call's name, but still says that a call failed", () => {
// Off means the list is off, not that a failure goes unmentioned: the answer
// alone would not account for a tool that did not work.
const markdown = buildThreadMarkdown(
[
message({
@@ -325,12 +381,32 @@ describe("buildThreadMarkdown", () => {
{ showToolCalls: false, showReasoning: false },
);
expect(markdown).toContain("`web_fetch` (failed)");
expect(markdown).toContain("_1 tool call failed._");
expect(markdown).not.toContain("web_fetch");
expect(markdown).not.toContain("_Tools:");
});
it("shows a refused repeat even when tool calls are hidden", () => {
// This is the one the user most needs to see: a model going in circles is
// exactly the thing a hidden tool list would conceal.
it("counts the failures rather than naming every failed call when hidden", () => {
const markdown = buildThreadMarkdown(
[
message({
role: "assistant",
text: "a",
toolCalls: [
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
{ name: "web_search", arguments: {}, result: "boom", isError: true },
],
}),
],
{ showToolCalls: false, showReasoning: false },
);
expect(markdown).toContain("_2 tool calls failed._");
});
it("says a turn was cut short by a repeat, without naming the call", () => {
// Still the one the user most needs to know about: a model going in circles
// is exactly the thing a hidden tool list would conceal.
const markdown = buildThreadMarkdown(
[
message({
@@ -344,7 +420,26 @@ describe("buildThreadMarkdown", () => {
{ showToolCalls: false, showReasoning: false },
);
expect(markdown).toContain("`web_search` (repeated, stopped)");
expect(markdown).toContain("_a call kept repeating, so the turn was cut short._");
expect(markdown).not.toContain("web_search");
});
it("says both when a call failed and another kept repeating, with tools hidden", () => {
const markdown = buildThreadMarkdown(
[
message({
role: "assistant",
text: "a",
toolCalls: [
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
{ name: "web_search", arguments: {}, result: "Refused", isError: true, repeated: true },
],
}),
],
{ showToolCalls: false, showReasoning: false },
);
expect(markdown).toContain("_1 tool call failed and a call kept repeating, so the turn was cut short._");
});
it("still hides the ordinary calls when tool calls are off", () => {
@@ -368,6 +463,73 @@ describe("buildThreadMarkdown", () => {
"Reasoning",
);
});
it("streams the reasoning live even when Show Reasoning is off", () => {
// While a turn is in flight the reasoning is the only sign of progress, so
// it is shown whatever the preference says. It is the live view only.
const markdown = buildThreadMarkdown(
[message({ role: "assistant", text: "", isStreaming: true, thinking: "weighing the options" })],
{ showToolCalls: false, showReasoning: false },
);
expect(markdown).toContain("weighing the options");
expect(markdown).toContain("Reasoning");
// The cursor trails the reasoning, because nothing else is arriving.
expect(markdown).toContain(`> weighing the options ${STREAMING_CURSOR}`);
// And the "Thinking…" stand-in is dropped, since the reasoning says it.
expect(markdown).not.toContain("Thinking…");
});
it("drops the reasoning when the turn ends and Show Reasoning is off", () => {
const markdown = buildThreadMarkdown(
[message({ role: "assistant", text: "the answer", thinking: "weighing the options" })],
{ showToolCalls: false, showReasoning: false },
);
expect(markdown).toContain("the answer");
expect(markdown).not.toContain("weighing the options");
expect(markdown).not.toContain("Reasoning");
});
it("keeps the reasoning under the answer when the turn ends and Show Reasoning is on", () => {
const markdown = buildThreadMarkdown([
message({ role: "assistant", text: "the answer", thinking: "weighing the options" }),
]);
expect(markdown).toContain("the answer");
expect(markdown).toContain("weighing the options");
expect(markdown.indexOf("the answer")).toBeLessThan(markdown.indexOf("weighing the options"));
});
it("starts the live reasoning without a leading blank line", () => {
// Nothing else is on screen while the model thinks, so the reasoning is the
// first block, and a stray blank line above it would read as a gap.
const markdown = buildThreadMarkdown(
[message({ role: "assistant", text: "", isStreaming: true, thinking: "weighing the options" })],
{ showToolCalls: false, showReasoning: false },
);
expect(markdown.startsWith("_Reasoning_")).toBe(true);
});
it("rides the answer, not the reasoning, once an answer has arrived", () => {
// The cursor rides the answer once there is one, even while reasoning from
// the round before it is still on screen.
const markdown = buildThreadMarkdown(
[
message({
role: "assistant",
text: "partial answer",
isStreaming: true,
thinking: "weighing the options",
}),
],
{ showToolCalls: false, showReasoning: false },
);
expect(markdown).toContain(`partial answer ${STREAMING_CURSOR}`);
expect(markdown).not.toContain(`weighing the options ${STREAMING_CURSOR}`);
});
});
describe("sessionToPlainText", () => {
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { EMPTY_TRACE, afterToolCall, withFinalTrace, withThinking } from "../lib/thinking";
describe("ThinkingTrace", () => {
it("starts empty", () => {
expect(EMPTY_TRACE).toEqual({ full: "", current: "" });
});
it("appends a delta to both the full trace and the current segment", () => {
const trace = withThinking(withThinking(EMPTY_TRACE, "let me "), "think");
expect(trace).toEqual({ full: "let me think", current: "let me think" });
});
it("keeps the full trace when a tool starts, and clears the current segment", () => {
// The reported case: the model thinks, calls a tool, and thinks again. The
// live view shows only the new thinking; the stored turn keeps both rounds.
const first = withThinking(EMPTY_TRACE, "I should search");
const cleared = afterToolCall(first);
expect(cleared.full).toBe("I should search");
expect(cleared.current).toBe("");
const second = withThinking(cleared, "now I know");
expect(second.full).toBe("I should searchnow I know");
expect(second.current).toBe("now I know");
});
it("does not mutate the trace it was given", () => {
const trace = withThinking(EMPTY_TRACE, "a");
afterToolCall(trace);
withThinking(trace, "b");
expect(trace).toEqual({ full: "a", current: "a" });
});
it("clears each round separately across several tool calls", () => {
let trace = EMPTY_TRACE;
for (const round of ["one", "two", "three"]) {
trace = withThinking(trace, round);
trace = afterToolCall(trace);
}
expect(trace.full).toBe("onetwothree");
expect(trace.current).toBe("");
});
it("adopts the authoritative trace the turn ended with", () => {
const trace = withFinalTrace("the whole thing");
expect(trace.full).toBe("the whole thing");
expect(trace.current).toBe("");
});
});
+24 -8
View File
@@ -7,6 +7,7 @@ import { isSelfHosted } from "../ollama";
import { OllamaError } from "../ollama";
import type { ResolvedPreferences } from "../preferences";
import { createSession, createTurn, saveSession, withTitleFromFirstTurn } from "../sessions";
import { EMPTY_TRACE, afterToolCall, withFinalTrace, withThinking, type ThinkingTrace } from "../thinking";
import { toolsFor } from "../tools";
import type { PendingAttachment, Session, VisionSupport } from "../types";
import { errorMessage, isAbortError } from "../utils";
@@ -61,6 +62,13 @@ export interface UseChat {
session: Session;
isStreaming: boolean;
streamingResponse: string;
/**
* The reasoning to show right now: only the part since the last tool call.
*
* Deliberately not the whole trace. The full one is carried on the turn for
* storage; this is the live view, and it is cleared each time a tool starts so
* a long research turn does not stack every round's thinking on screen.
*/
streamingThinking: string;
/** Short activity line such as "Searching the web", shown while a turn is in flight. */
status?: string;
@@ -102,7 +110,7 @@ export function useChat(options: UseChatOptions): UseChat {
const abortRef = useRef<AbortController | null>(null);
const liveRef = useRef(false);
const responseRef = useRef("");
const thinkingRef = useRef("");
const thinkingRef = useRef<ThinkingTrace>(EMPTY_TRACE);
const statusRef = useRef<string | undefined>(undefined);
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -131,14 +139,14 @@ export function useChat(options: UseChatOptions): UseChat {
flushTimerRef.current = null;
if (!liveRef.current) return;
setStreamingResponse(responseRef.current);
setStreamingThinking(thinkingRef.current);
setStreamingThinking(thinkingRef.current.current);
}, FLUSH_INTERVAL_MS);
}, []);
const flushNow = useCallback(() => {
clearFlushTimer();
setStreamingResponse(responseRef.current);
setStreamingThinking(thinkingRef.current);
setStreamingThinking(thinkingRef.current.current);
}, [clearFlushTimer]);
const setStatusValue = useCallback((value: string | undefined) => {
@@ -185,7 +193,7 @@ export function useChat(options: UseChatOptions): UseChat {
commit(started);
responseRef.current = "";
thinkingRef.current = "";
thinkingRef.current = EMPTY_TRACE;
setStreamingResponse("");
setStreamingThinking("");
setError(undefined);
@@ -242,11 +250,18 @@ export function useChat(options: UseChatOptions): UseChat {
scheduleFlush();
},
onThinkingDelta: (delta) => {
thinkingRef.current += delta;
thinkingRef.current = withThinking(thinkingRef.current, delta);
scheduleFlush();
},
onStatus: (value) => setStatusValue(value),
onToolStart: (_name, label, count) => setActivity({ label, count }),
onToolStart: (_name, label, count) => {
// The reasoning that produced this call is spent, so the live
// segment is cleared and the tool line takes its place. The full
// trace is untouched, because the stored turn keeps all of it.
thinkingRef.current = afterToolCall(thinkingRef.current);
setActivity({ label, count });
flushNow();
},
onToolCall: (record) => {
toolCalls = [...toolCalls, record];
},
@@ -254,7 +269,7 @@ export function useChat(options: UseChatOptions): UseChat {
});
responseRef.current = result.content;
thinkingRef.current = result.thinking;
thinkingRef.current = withFinalTrace(result.thinking);
toolCalls = result.toolCalls;
} catch (thrown) {
if (isAbortError(thrown)) {
@@ -268,7 +283,7 @@ export function useChat(options: UseChatOptions): UseChat {
const finished = {
...turn,
response: responseRef.current,
thinking: thinkingRef.current,
thinking: thinkingRef.current.full,
toolCalls,
error: failure,
};
@@ -306,6 +321,7 @@ export function useChat(options: UseChatOptions): UseChat {
setError(undefined);
setStreamingResponse("");
setStreamingThinking("");
thinkingRef.current = EMPTY_TRACE;
commit(next);
},
[commit, setStatusValue],
+102 -31
View File
@@ -1,7 +1,7 @@
import { imageSource } from "./attachments";
import { repairCodeBlocks } from "./code";
import { describeActivity } from "./thread";
import type { Attachment, ChatTurn, ThreadMessage } from "./types";
import type { Attachment, ChatTurn, ThreadMessage, ToolCallRecord } from "./types";
/**
* Markdown rendering for the detail panel.
@@ -58,19 +58,62 @@ export function quote(text: string): string {
.join("\n");
}
/** One italic line, well below the weight of the answer it belongs to. */
function renderTools(message: ThreadMessage): string {
const names = message.toolCalls
.map((call) => {
const name = `\`${call.name}\``;
// A refused repeat did not fail, so it is not called a failure: it is the
// loop noticing it was going in circles, which is worth saying plainly.
if (call.repeated) return `${name} (repeated, stopped)`;
return call.isError ? `${name} (failed)` : name;
})
.join(", ");
/** How many calls failed for a reason other than being refused as a repeat. */
function countFailures(calls: ToolCallRecord[]): number {
// A refused repeat is an `isError` too, but it did not fail: it is the loop
// guard noticing a rut, which is a different thing and is said differently.
return calls.filter((call) => call.isError && !call.repeated).length;
}
return `_Tools: ${names}_`;
/** Whether the turn was cut short because a call kept repeating itself. */
function wasStopped(calls: ToolCallRecord[]): boolean {
return calls.some((call) => call.repeated);
}
/**
* One italic line naming the tools a turn used, each mentioned once.
*
* Listing every call in order said nothing a name does not, and on a research
* turn it said it twenty times: `_Tools: web_search, web_search, web_search,
* web_fetch (failed), web_search…_` ran to three lines of the same two words.
* Names in order of first use, then a count of what went wrong, which is the
* only part of that repetition that carries information.
*/
function renderTools(message: ThreadMessage): string {
const names: string[] = [];
for (const call of message.toolCalls) {
if (!names.includes(call.name)) names.push(call.name);
}
const listed = names.map((name) => `\`${name}\``).join(", ");
const failed = countFailures(message.toolCalls);
const notes: string[] = [];
if (failed > 0) notes.push(`${failed} failed`);
if (wasStopped(message.toolCalls)) notes.push("stopped: the same call kept repeating");
const suffix = notes.length > 0 ? ` (${notes.join(", ")})` : "";
return `_Tools: ${listed}${suffix}_`;
}
/**
* The one line shown when tool calls are hidden but something went wrong.
*
* Hiding the list is the user's choice, and a list of names is what they turned
* off. A turn where a call failed or was refused as a repeat is different: the
* answer alone would not account for it, so it is said in a sentence rather than
* dropped, and the names stay hidden.
*/
function renderToolFailureNote(message: ThreadMessage): string | undefined {
const failed = countFailures(message.toolCalls);
const stopped = wasStopped(message.toolCalls);
if (failed === 0 && !stopped) return undefined;
const parts: string[] = [];
if (failed > 0) parts.push(`${failed} tool call${failed === 1 ? "" : "s"} failed`);
if (stopped) parts.push("a call kept repeating, so the turn was cut short");
return `_${parts.join(" and ")}._`;
}
function renderMessage(message: ThreadMessage, options: TranscriptOptions): string {
@@ -82,6 +125,19 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
return images ? `${asked}\n\n${images}` : asked;
}
const thinking = message.thinking.trim();
/**
* Live reasoning is shown while a turn is in flight whether or not the
* preference is on: it is the only sign of progress when a model is thinking
* rather than writing, and waiting on a spinner is worse than reading the
* thought. Once the turn ends it stays only if the user asked for it, so
* `Show Reasoning` still governs the finished transcript.
*/
const liveThinking = message.isStreaming && thinking.length > 0;
/** The stored trace, kept as an appendix under the answer it produced. */
const storedThinking = !message.isStreaming && options.showReasoning && thinking.length > 0;
const showThinking = liveThinking || storedThinking;
// While a tool is running there is no answer text yet, so the activity line
// stands in and the user can see what Lam is actually doing. It is shown
// whether or not tokens have arrived, because a tool that starts after the
@@ -103,38 +159,53 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
// printing the placeholder as well put the same sentence on screen
// twice: once as a note above the transcript and once as the stand-in.
undefined
: liveThinking
? // The reasoning below is already saying the model is working, so
// "Thinking…" on top of it would only repeat it.
undefined
: message.status
? `_${message.status}…_`
: "_Thinking…_";
// The cursor trails whichever of the two is written last, so it always sits at
// the end of the text arriving rather than on a line of its own.
const written = [activityLine, body].filter((line): line is string => Boolean(line));
const last = written.length - 1;
const sections = written.map((line, index) =>
message.isStreaming && index === last ? `${line} ${STREAMING_CURSOR}` : line,
);
const sections: string[] = [];
const activityIndex = activityLine ? sections.push(activityLine) - 1 : -1;
const bodyIndex = body ? sections.push(body) - 1 : -1;
// The one thing allowed to break the plain-text rule, because it means
// something went wrong and should not be skimmed past.
if (message.error) sections.push("", `**Error:** ${message.error}`);
/**
* Tool calls are a display preference, with one exception: a call that failed,
* or that was refused because it kept repeating, is always listed. Those are
* the two cases where hiding the line would leave the user with an answer they
* cannot account for, and a model stuck in a loop is exactly the thing they
* need to see.
* Tool calls are a display preference. When they are on, every tool is named
* once. When they are off, nothing is listed; a failure or a refused repeat
* still gets one quiet sentence, because those are the cases where hiding the
* line would leave the user with an answer they cannot account for.
*/
const showTools =
options.showToolCalls || message.toolCalls.some((call) => call.isError || call.repeated);
if (showTools && message.toolCalls.length > 0) {
if (options.showToolCalls && message.toolCalls.length > 0) {
sections.push("", renderTools(message));
} else if (!options.showToolCalls) {
const note = renderToolFailureNote(message);
if (note) sections.push("", note);
}
if (options.showReasoning && message.thinking.trim().length > 0) {
sections.push("", "_Reasoning_", "", quote(message.thinking.trim()));
if (showThinking) {
// Only live reasoning can be the first thing in a message, so the blank
// separator is conditional: otherwise the transcript opens on an empty line.
if (sections.length > 0) sections.push("");
sections.push("_Reasoning_", "", quote(thinking));
}
/**
* The cursor marks what is arriving. The answer wins when there is one, then
* the live reasoning, and only then the activity line: reasoning on screen
* means the model is thinking, which is newer than the tool it called to get
* there. It is placed before the quiet sections so it never lands on a tool
* list or an appendix.
*/
if (message.isStreaming) {
const reasoningIndex = showThinking && liveThinking ? sections.length - 1 : -1;
const target = bodyIndex >= 0 ? bodyIndex : reasoningIndex >= 0 ? reasoningIndex : activityIndex;
if (target >= 0) sections[target] = `${sections[target]} ${STREAMING_CURSOR}`;
}
return sections.join("\n");
+53
View File
@@ -0,0 +1,53 @@
/**
* The live reasoning trace for a turn in flight.
*
* Ollama streams reasoning a round at a time: the model thinks, calls a tool,
* thinks again, and so on. Two views of that stream are needed at once, which is
* why this is two strings rather than one.
*
* - `full` is every token the model has thought this turn, across every round.
* It is what gets stored on the turn, so `Show Reasoning` can replay the whole
* trace when the conversation is reopened.
* - `current` is only the reasoning since the last tool call. The live transcript
* shows this one. A research turn that thinks before each of a dozen calls
* would otherwise pile every round's reasoning on screen at once, and the tool
* it just asked for would run underneath a wall of text that is no longer
* about anything.
*
* Kept pure and free of React so the reset is testable without mounting the hook.
*/
export interface ThinkingTrace {
/** Every token this turn, across every round. What gets stored on the turn. */
full: string;
/** Just the reasoning since the last tool call. What the live transcript shows. */
current: string;
}
export const EMPTY_TRACE: ThinkingTrace = { full: "", current: "" };
/** Appends one streamed reasoning delta to both views. */
export function withThinking(trace: ThinkingTrace, delta: string): ThinkingTrace {
return { full: trace.full + delta, current: trace.current + delta };
}
/**
* Clears the current view, leaving the full trace alone.
*
* Called when a tool starts: the reasoning that produced the call has served its
* purpose, and whatever the model thinks next is a new segment rather than a
* continuation of the last one.
*/
export function afterToolCall(trace: ThinkingTrace): ThinkingTrace {
return { full: trace.full, current: "" };
}
/**
* Adopts the authoritative trace the turn ended with.
*
* The agent owns the final value, so this is not a reconstruction: it is the
* one the stream produced, handed back whole.
*/
export function withFinalTrace(full: string): ThinkingTrace {
return { full, current: "" };
}
+8
View File
@@ -12,6 +12,14 @@ export interface LiveState {
isStreaming: boolean;
/** Partial answer for the turn currently in flight. */
response: string;
/**
* Reasoning to show for the turn in flight.
*
* Only the part since the last tool call, not the whole trace: the live
* transcript clears it each time a tool starts, so a research turn does not
* stack every round's thinking on screen. The stored turn keeps the whole
* trace separately.
*/
thinking: string;
/** Activity line, e.g. "Searching the web". Only meaningful mid-turn. */
status?: string;