diff --git a/changelogs/v2.2.0.md b/changelogs/v2.2.0.md
new file mode 100644
index 00000000..fca3d715
--- /dev/null
+++ b/changelogs/v2.2.0.md
@@ -0,0 +1,29 @@
+## What's new
+
+### Features
+
+- **Thinking / reasoning stream**: AI chat and the Cairn Agent now stream model reasoning text (Claude `thinking_delta`, OpenAI `delta.reasoning`) as a separate channel alongside content — never merged into note content or tool-call JSON. A collapsible **Thinking** panel renders above the assistant's reply, expanded by default while reasoning unfolds, auto-collapsing the instant the first content token arrives, and re-expandable via chevron at any time. Reasoning text is persisted to the `chat_messages` and `pi_agent_messages` tables so past messages retain their thinking panel across app restarts. For models that don't expose reasoning (e.g. standard GPT-4o), the panel simply never appears — zero overhead.
+ - **Backend**: `runToolLoop` in `electron/ipc/chat.ts` and `runAgentLoop` in `electron/lib/pi-agent-loop.ts` both parse `delta.reasoning` from SSE chunks, accumulate it through the tool-call loop, and emit new `chat:thought` / `pi-agent:thought` IPC channels mirroring the existing token channels. Non-streaming localllm path also reads `message.reasoning` from the completion response.
+ - **Usage**: `completion_tokens_details.reasoning_tokens` is now parsed from the OpenAI usage object and threaded through `onUsage` callbacks on both paths. The ContextRing popover (top-right of chat) now shows an **Output** section with **Answer** / **Thinking** / **Total** token breakdowns when a turn completes with reasoning token data.
+ - **IPC**: New preload listeners `window.electron.chat.onThought` and `window.electron.piAgent.onThought` added; `chat:usage` / `pi-agent:usage` payloads extended with `reasoningTokens`; `chat:done` payload now carries `reasoning` text for persistence.
+ - **Store**: `ChatMessage` and `PiAgentMessage` types gain `reasoning?: string`; `ChatThread.lastUsage` and `TerminalSession.lastUsage` gain `reasoningTokens?: number`. New `appendPiThought` Zustand action mirrors `appendPiToken` for live agent reasoning accumulation.
+ - **DB migration v20**: `ALTER TABLE chat_messages ADD COLUMN reasoning TEXT` and the same for `pi_agent_messages`. Idempotent — checks `PRAGMA table_info` before adding.
+ - **UI components**: New shared `ThinkingPanel.tsx` (`src/components/chat/chat-panel/`) handles both streaming (auto-collapse on first content token, re-expand on new reasoning, honour user override) and persisted (collapsed by default with chevron toggle) states. Rendered in `ToolCallIndicator` (live chat), `ChatMessageBubble` (persisted chat), and `AgentMessageBubble` (both live and persisted agent messages + subagent rows).
+
+- **GitHub-style raw HTML in markdown**: All three markdown pipelines (`NoteMarkdownPreview`, `note-editor` read mode, `MarkdownContent` for chat) now install `rehype-raw` as the first rehype plugin, preserving raw HTML like `
`, ``, ``/``, and ` ` instead of stripping them. Matches GitHub's rendering behaviour for README files pasted into notes or chat.
+
+### Fixes
+
+- **New chat thread not selected**: `SessionPane.handleNewChatThread` created a new thread but didn't call `setActiveChatThreadId` with the new thread's ID, so the chat panel kept showing the old session instead of switching to the freshly-created one. Now immediately switches.
+
+- **Chat thread not switching on project change**: The `ChatPanel` init effect short-circuited whenever `activeChatThreadId` was non-null, so switching projects kept displaying the old project's chat thread. The effect now detects when `activeProjectId` changes and switches to a thread scoped to the new project via `getOrCreateThread`.
+
+- **ESLint clean-install failure**: `eslint.config.mjs` imports from `eslint/config` (`@eslint/core`) which was only available transitively through `eslint@9` — clean installs (e.g. CI, PR review bots) couldn't resolve it. `@eslint/core` is now an explicit devDependency.
+
+### Changes
+
+- **Reasoning is intentionally stripped from compaction**: The chat compaction flow (`compactChatThread` in `src/store/slices/chat.ts`) sends only `{ role, content }` when summarising — reasoning text is never embedded into summary content, per the guidance that reasoning shouldn't leak into user-visible notes as text. Token counts (`reasoningTokens`) remain tracked for metrics/billing.
+
+- **MCP / LLM history excludes reasoning**: `pi_agent_llm_history` (the raw model replay buffer for agent sessions) stores only role + content, so reasoning blocks don't interfere with downstream model turns.
+
+- **`max_tokens` unchanged**: No request-side changes to `max_tokens` / `max_completion_tokens` were made. Models that split reasoning from content (e.g. Gemini-3.5-flash) produce `finish_reason: "length"` if the limit is too low — the existing `max_tokens: 4096` in `runToolLoop` is sufficient for most turns.
diff --git a/electron/db/queries.ts b/electron/db/queries.ts
index 9cb03555..de66cf22 100644
--- a/electron/db/queries.ts
+++ b/electron/db/queries.ts
@@ -534,13 +534,13 @@ export function getChatMessages(db: Database.Database, threadId: string) {
}
export function addChatMessage(db: Database.Database, m: {
- id: string; threadId: string; role: string; content: string; contextRefs?: unknown; toolCalls?: unknown;
+ id: string; threadId: string; role: string; content: string; contextRefs?: unknown; toolCalls?: unknown; reasoning?: string;
}) {
const now = ts();
db.prepare(`
- INSERT INTO chat_messages (id, thread_id, role, content, context_refs, tool_calls, created_at)
- VALUES (?, ?, ?, ?, ?, ?, ?)
- `).run(m.id, m.threadId, m.role, m.content, m.contextRefs ? JSON.stringify(m.contextRefs) : null, m.toolCalls ? JSON.stringify(m.toolCalls) : null, now);
+ INSERT INTO chat_messages (id, thread_id, role, content, context_refs, tool_calls, reasoning, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(m.id, m.threadId, m.role, m.content, m.contextRefs ? JSON.stringify(m.contextRefs) : null, m.toolCalls ? JSON.stringify(m.toolCalls) : null, m.reasoning ?? null, now);
return toChatMessage(db.prepare("SELECT * FROM chat_messages WHERE id = ?").get(m.id));
}
@@ -952,6 +952,7 @@ export interface PiMessageRow {
sessionId: string;
role: "user" | "assistant" | "error" | "system";
content: string;
+ reasoning: string | null;
toolCalls: unknown[] | null;
subagents: unknown[] | null;
timestamp: string;
@@ -965,6 +966,7 @@ function toPiMessage(row: any): PiMessageRow {
sessionId: row.session_id as string,
role: row.role as "user" | "assistant" | "error" | "system",
content: row.content as string,
+ reasoning: (row.reasoning as string | null) ?? null,
toolCalls: row.tool_calls ? JSON.parse(row.tool_calls as string) : null,
subagents: row.subagents ? JSON.parse(row.subagents as string) : null,
timestamp: row.timestamp as string,
@@ -974,17 +976,19 @@ function toPiMessage(row: any): PiMessageRow {
export function upsertPiMessage(
db: Database.Database,
- msg: { id: string; sessionId: string; role: "user" | "assistant" | "error" | "system"; content: string; toolCalls?: unknown[] | null; subagents?: unknown[] | null; timestamp: string; order: number },
+ msg: { id: string; sessionId: string; role: "user" | "assistant" | "error" | "system"; content: string; reasoning?: string | null; toolCalls?: unknown[] | null; subagents?: unknown[] | null; timestamp: string; order: number },
) {
db.prepare(`
- INSERT INTO pi_agent_messages (id, session_id, role, content, tool_calls, subagents, timestamp, "order")
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO pi_agent_messages (id, session_id, role, content, reasoning, tool_calls, subagents, timestamp, "order")
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
content = excluded.content,
+ reasoning = excluded.reasoning,
tool_calls = excluded.tool_calls,
subagents = excluded.subagents
`).run(
msg.id, msg.sessionId, msg.role, msg.content,
+ msg.reasoning ?? null,
msg.toolCalls ? JSON.stringify(msg.toolCalls) : null,
msg.subagents ? JSON.stringify(msg.subagents) : null,
msg.timestamp, msg.order,
@@ -994,7 +998,7 @@ export function upsertPiMessage(
export function savePiMessages(
db: Database.Database,
sessionId: string,
- messages: Array<{ id: string; role: "user" | "assistant" | "error" | "system"; content: string; toolCalls?: unknown[] | null; subagents?: unknown[] | null; timestamp: string }>,
+ messages: Array<{ id: string; role: "user" | "assistant" | "error" | "system"; content: string; reasoning?: string | null; toolCalls?: unknown[] | null; subagents?: unknown[] | null; timestamp: string }>,
) {
const save = db.transaction(() => {
db.prepare("DELETE FROM pi_agent_messages WHERE session_id = ?").run(sessionId);
diff --git a/electron/db/schema.ts b/electron/db/schema.ts
index 0f6b44ce..6dbca6b8 100644
--- a/electron/db/schema.ts
+++ b/electron/db/schema.ts
@@ -480,6 +480,21 @@ const MIGRATIONS: Migration[] = [
db.exec("ALTER TABLE relationship_cache ADD COLUMN target_section_title TEXT");
}
},
+
+ // v20: Persist reasoning / thinking text on chat messages so the
+ // collapsible "Thinking" panel survives app restarts. Mirrors v14's
+ // approach for tool_calls. Also covers pi_agent_messages so terminal
+ // agent sessions retain their thinking across restarts too.
+ (db) => {
+ const chatCols = db.prepare("PRAGMA table_info(chat_messages)").all() as { name: string }[];
+ if (!chatCols.some((c) => c.name === "reasoning")) {
+ db.exec("ALTER TABLE chat_messages ADD COLUMN reasoning TEXT");
+ }
+ const piCols = db.prepare("PRAGMA table_info(pi_agent_messages)").all() as { name: string }[];
+ if (!piCols.some((c) => c.name === "reasoning")) {
+ db.exec("ALTER TABLE pi_agent_messages ADD COLUMN reasoning TEXT");
+ }
+ },
];
export function applySchema(db: Database.Database): void {
diff --git a/electron/ipc/chat.ts b/electron/ipc/chat.ts
index e4f63da6..5643c7cf 100644
--- a/electron/ipc/chat.ts
+++ b/electron/ipc/chat.ts
@@ -83,29 +83,43 @@ async function runToolLoop(
signal?: AbortSignal,
getWin?: () => BrowserWindow | null,
provider?: string,
- onUsage?: (pt: number, ct: number) => void,
+ onUsage?: (pt: number, ct: number, rt?: number) => void,
emitToolCallDone?: (e: { tool: string; cairnRef?: { type: "note" | "task"; id: string; title: string } }) => void,
onToken?: (delta: string) => void,
-): Promise<{ exhausted: true; content: string } | { exhausted: false; content: string }> {
+ onThought?: (delta: string) => void,
+): Promise<{ exhausted: true; content: string; reasoning: string } | { exhausted: false; content: string; reasoning: string }> {
const maxSteps = req.config?.maxSteps ?? 20;
const temperature = req.config?.temperature ?? 0.3;
let accumulatedContent = "";
+ let accumulatedReasoning = "";
for (let round = 0; round < maxSteps; round++) {
- if (signal?.aborted) return { exhausted: true, content: "" };
-
- let assistantMsg: OpenAIMessage;
+ if (signal?.aborted) return { exhausted: true, content: "", reasoning: accumulatedReasoning };
+
+ let assistantMsg: OpenAIMessage & { reasoning?: string };
if (provider === "localllm") {
try {
const { callLocalLLMChat } = await import("../lib/local-llm");
const res = await callLocalLLMChat(messages, TOOLS);
const choice = res.choices?.[0];
- if (!choice) return { exhausted: true, content: "No response from local Llama on-device model." };
+ if (!choice) return { exhausted: true, content: "No response from local Llama on-device model.", reasoning: "" };
if (res.usage && onUsage) {
- onUsage(res.usage.prompt_tokens ?? 0, res.usage.completion_tokens ?? 0);
+ onUsage(
+ res.usage.prompt_tokens ?? 0,
+ res.usage.completion_tokens ?? 0,
+ res.usage.completion_tokens_details?.reasoning_tokens ?? 0,
+ );
}
- assistantMsg = choice.message as OpenAIMessage;
+ const rawMsg = choice.message as OpenAIMessage & { reasoning?: string };
+ if (rawMsg.reasoning) {
+ accumulatedReasoning += rawMsg.reasoning;
+ if (onThought) onThought(rawMsg.reasoning);
+ }
+ // Strip reasoning before assigning — it must not enter the messages
+ // array that gets re-sent to the API on subsequent rounds.
+ const { reasoning: _r, ...msgWithoutReasoning } = rawMsg;
+ assistantMsg = msgWithoutReasoning;
// Self-Healing Parser for On-Device XML-style tool calls and tokenizers
if (assistantMsg.content && assistantMsg.content.includes("<|tool_call>call:")) {
@@ -138,7 +152,7 @@ async function runToolLoop(
if (onToken) onToken(assistantMsg.content);
}
} catch (err) {
- return { exhausted: true, content: `Local LLM Engine error: ${String(err)}` };
+ return { exhausted: true, content: `Local LLM Engine error: ${String(err)}`, reasoning: accumulatedReasoning };
}
} else {
let response: Response;
@@ -161,27 +175,31 @@ async function runToolLoop(
}),
});
} catch (_err) {
- if (signal?.aborted) return { exhausted: true, content: "" };
- return { exhausted: true, content: `Could not reach the AI endpoint at \`${baseUrl}\`. Check your endpoint URL and make sure the server is running.` };
+ if (signal?.aborted) return { exhausted: true, content: "", reasoning: accumulatedReasoning };
+ return { exhausted: true, content: `Could not reach the AI endpoint at \`${baseUrl}\`. Check your endpoint URL and make sure the server is running.`, reasoning: "" };
}
if (!response.ok) {
const errText = await response.text().catch(() => response.statusText);
- return { exhausted: true, content: `AI endpoint error (${response.status}): ${errText.slice(0, 300)}` };
+ return { exhausted: true, content: `AI endpoint error (${response.status}): ${errText.slice(0, 300)}`, reasoning: "" };
}
const reader = response.body?.getReader();
- if (!reader) return { exhausted: true, content: "No response stream" };
+ if (!reader) return { exhausted: true, content: "No response stream", reasoning: "" };
let contentBuffer = "";
- const toolCallBuffers: Map = new Map();
+ const toolCallBuffers: Map = new Map();
for await (const jsonStr of iterSseData(reader, signal ?? undefined)) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chunk = JSON.parse(jsonStr) as any;
if (chunk.usage && onUsage) {
- onUsage(chunk.usage.prompt_tokens ?? 0, chunk.usage.completion_tokens ?? 0);
+ onUsage(
+ chunk.usage.prompt_tokens ?? 0,
+ chunk.usage.completion_tokens ?? 0,
+ chunk.usage.completion_tokens_details?.reasoning_tokens ?? 0,
+ );
}
const delta = chunk.choices?.[0]?.delta;
if (!delta) continue;
@@ -192,6 +210,14 @@ async function runToolLoop(
if (onToken) onToken(delta.content);
}
+ // Reasoning / thinking stream (Claude thinking_delta, OpenAI delta.reasoning).
+ // Models that don't expose reasoning text simply never emit this field —
+ // the panel stays hidden. Reasoning is NOT merged into content/tool JSON.
+ if (delta.reasoning) {
+ accumulatedReasoning += delta.reasoning;
+ if (onThought) onThought(delta.reasoning);
+ }
+
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const idx: number = tc.index ?? 0;
@@ -202,6 +228,8 @@ async function runToolLoop(
if (tc.id) buf.id = tc.id;
if (tc.function?.name) buf.name = tc.function.name;
if (tc.function?.arguments) buf.args += tc.function.arguments;
+ // Gemini 3.x thought signature — opaque blob to round-trip back.
+ if (tc.thought_signature) buf.thought_signature = tc.thought_signature;
}
}
} catch { /* skip malformed SSE JSON line */ }
@@ -216,7 +244,7 @@ async function runToolLoop(
});
}
- if (signal?.aborted) return { exhausted: true, content: "" };
+ if (signal?.aborted) return { exhausted: true, content: "", reasoning: accumulatedReasoning };
const toolCalls = toolCallBuffers.size > 0
? Array.from(toolCallBuffers.entries())
@@ -225,12 +253,17 @@ async function runToolLoop(
id: buf.id,
type: "function" as const,
function: { name: buf.name, arguments: buf.args },
+ ...(buf.thought_signature ? { thought_signature: buf.thought_signature } : {}),
}))
: undefined;
assistantMsg = {
- role: "assistant",
+ role: "assistant" as const,
content: contentBuffer || null,
+ // Note: reasoning is intentionally NOT included here. It is
+ // accumulated separately in `accumulatedReasoning` and returned
+ // to the caller for UI/persistence. Sending it back to the API
+ // would violate both OpenAI and Anthropic message schemas.
tool_calls: toolCalls,
};
}
@@ -238,7 +271,7 @@ async function runToolLoop(
// No tool calls — model is ready to produce its final reply
if (!assistantMsg.tool_calls?.length) {
messages.push(assistantMsg);
- return { exhausted: false, content: accumulatedContent };
+ return { exhausted: false, content: accumulatedContent, reasoning: accumulatedReasoning };
}
messages.push(assistantMsg);
@@ -285,6 +318,7 @@ async function runToolLoop(
return {
exhausted: true,
content: "I reached the maximum number of steps for this request. Any actions taken so far have been saved — check your board and notes. Try breaking the request into smaller steps.",
+ reasoning: accumulatedReasoning,
};
}
@@ -366,17 +400,19 @@ export function registerChatHandler(db: Database.Database, workspacePath: string
let promptTokens = 0;
let completionTokens = 0;
+ let reasoningTokens = 0;
let lastBreakdown: TokenBreakdown | undefined = undefined;
- const addUsage = (pt: number, ct: number) => {
+ const addUsage = (pt: number, ct: number, rt?: number) => {
promptTokens += pt;
completionTokens += ct;
+ if (typeof rt === "number") reasoningTokens += rt;
try {
const rawBreakdown = calculatePromptBreakdown(buildSystemPrompt(req), messages, TOOLS);
lastBreakdown = scaleBreakdown(rawBreakdown, promptTokens);
} catch (err) {
console.error("[chat] failed to calculate breakdown:", err);
}
- send("chat:usage", { promptTokens, completionTokens, breakdown: lastBreakdown });
+ send("chat:usage", { promptTokens, completionTokens, reasoningTokens, breakdown: lastBreakdown });
};
const loopResult = await runToolLoop(
@@ -385,7 +421,10 @@ export function registerChatHandler(db: Database.Database, workspacePath: string
emitToolCallDone,
(delta) => {
send("chat:token", { delta });
- }
+ },
+ (delta) => {
+ send("chat:thought", { delta });
+ },
);
abortControllers.delete(event.sender.id);
@@ -399,10 +438,10 @@ export function registerChatHandler(db: Database.Database, workspacePath: string
}
if (abortCtrl.signal.aborted) {
- send("chat:done", { content: "", contextRefs: [], usage: promptTokens > 0 ? { promptTokens, completionTokens, breakdown: lastBreakdown } : undefined });
+ send("chat:done", { content: "", reasoning: loopResult.reasoning, contextRefs: [], usage: promptTokens > 0 ? { promptTokens, completionTokens, reasoningTokens, breakdown: lastBreakdown } : undefined });
return;
}
- send("chat:done", { content: loopResult.content, contextRefs: [], usage: promptTokens > 0 ? { promptTokens, completionTokens, breakdown: lastBreakdown } : undefined });
+ send("chat:done", { content: loopResult.content, reasoning: loopResult.reasoning, contextRefs: [], usage: promptTokens > 0 ? { promptTokens, completionTokens, reasoningTokens, breakdown: lastBreakdown } : undefined });
});
}
diff --git a/electron/ipc/pi-agent.ts b/electron/ipc/pi-agent.ts
index 88d53a7f..ec692c7c 100644
--- a/electron/ipc/pi-agent.ts
+++ b/electron/ipc/pi-agent.ts
@@ -109,6 +109,7 @@ async function runSession(
llmConfig,
{
onToken: (delta) => send("pi-agent:token", { sessionId, delta }),
+ onThought: (delta) => send("pi-agent:thought", { sessionId, delta }),
onToolsReady: () => send("pi-agent:tools-ready", { sessionId }),
onToolPending: (name, callId) => send("pi-agent:tool", { sessionId, name, label: name, callId, status: "pending" }),
onToolStart: (name, label, callId) => send("pi-agent:tool", { sessionId, name, label, callId, status: "start" }),
@@ -128,7 +129,7 @@ async function runSession(
}
},
onStepStart: () => send("pi-agent:step", { sessionId }),
- onUsage: (promptTokens, completionTokens, breakdown) => send("pi-agent:usage", { sessionId, promptTokens, completionTokens, breakdown }),
+ onUsage: (promptTokens, completionTokens, reasoningTokens, breakdown) => send("pi-agent:usage", { sessionId, promptTokens, completionTokens, reasoningTokens, breakdown }),
onRetry: (attempt, maxRetries, delayMs, error) => send("pi-agent:retry", { sessionId, attempt, maxRetries, delayMs, error }),
transformContext: session.compactionTransformer,
onDone: () => {
diff --git a/electron/lib/llm.ts b/electron/lib/llm.ts
index 3254d95f..26cd8385 100644
--- a/electron/lib/llm.ts
+++ b/electron/lib/llm.ts
@@ -38,6 +38,13 @@ export type OpenAIMessage = {
id: string;
type: "function";
function: { name: string; arguments: string };
+ /**
+ * Gemini 3.x thought signature — opaque blob returned by the model on
+ * tool-call parts when thinking is enabled. Must be round-tripped back
+ * on subsequent requests so the model can resume its reasoning state.
+ * Other providers ignore this field.
+ */
+ thought_signature?: string;
}>;
};
diff --git a/electron/lib/pi-agent-loop.ts b/electron/lib/pi-agent-loop.ts
index 9abf84a6..02864593 100644
--- a/electron/lib/pi-agent-loop.ts
+++ b/electron/lib/pi-agent-loop.ts
@@ -60,7 +60,7 @@ export interface AgentLLMConfig {
// ── Message types ─────────────────────────────────────────────────────────────
export interface AgentUserMessage { role: "user"; content: string }
-export interface AgentAssistantMsg { role: "assistant"; content: string | null; tool_calls?: ToolCallSpec[] }
+export interface AgentAssistantMsg { role: "assistant"; content: string | null; reasoning?: string; tool_calls?: ToolCallSpec[] }
export interface AgentToolResultMsg { role: "tool"; tool_call_id: string; content: string }
export type AgentMessage =
@@ -72,6 +72,13 @@ interface ToolCallSpec {
id: string;
type: "function";
function: { name: string; arguments: string };
+ /**
+ * Gemini 3.x thought signature — opaque blob returned by the model on
+ * tool-call parts when thinking is enabled. Must be round-tripped back
+ * on subsequent requests so the model can resume its reasoning state.
+ * Other providers ignore this field.
+ */
+ thought_signature?: string;
}
// ── Per-session infrastructure context ───────────────────────────────────────
@@ -118,6 +125,8 @@ const CODING_LABELS: Record string> = {
export interface AgentLoopCallbacks {
onToken: (delta: string) => void;
+ /** Streams reasoning/thinking deltas (Claude's thinking_delta, OpenAI's delta.reasoning). Absent for non-reasoning models. */
+ onThought?: (delta: string) => void;
onToolsReady: () => void;
/** Fired during SSE streaming as soon as a tool call name is first seen.
* The chip should appear in "pending" state immediately — before execution. */
@@ -127,7 +136,7 @@ export interface AgentLoopCallbacks {
/** callId links back to the same chip created by onToolPending / updated by onToolStart. */
onToolEnd: (name: string, label: string, ok: boolean, output: string, callId?: string) => void;
onStepStart: () => void;
- onUsage: (promptTokens: number, completionTokens: number, breakdown?: TokenBreakdown) => void;
+ onUsage: (promptTokens: number, completionTokens: number, reasoningTokens: number, breakdown?: TokenBreakdown) => void;
onDone: () => void;
onError: (message: string) => void;
/** Fired when a tool call needs user confirmation before execution. */
@@ -457,7 +466,22 @@ export async function runAgentLoop(
// Build messages array — apply context pruning.
// systemPrompt passes as `system:` in the request body (not as a user message).
- const contextMessages = await pruner([...session.messages]);
+ // Strip reasoning before the pruner so custom context transforms cannot
+ // access thinking text. The post-pruner map is a safety net.
+ const stripped = session.messages.map((m) => {
+ if (m.role === "assistant" && "reasoning" in m) {
+ const { reasoning: _r, ...rest } = m;
+ return rest;
+ }
+ return m;
+ });
+ const contextMessages = (await pruner(stripped)).map((m) => {
+ if (m.role === "assistant" && "reasoning" in m) {
+ const { reasoning: _r, ...rest } = m;
+ return rest;
+ }
+ return m;
+ });
// ── Stream assistant response ─────────────────────────────────────────
const headers: Record = { "Content-Type": "application/json" };
@@ -545,7 +569,8 @@ export async function runAgentLoop(
if (!reader) { callbacks.onError("No response stream"); return; }
let contentBuffer = "";
- const toolCallBuffers: Map = new Map();
+ let reasoningBuffer = "";
+ const toolCallBuffers: Map = new Map();
// callId assigned per tool during streaming — reused at execution time
const streamCallIds: Map = new Map();
let toolsReadyFired = false;
@@ -559,6 +584,7 @@ export async function runAgentLoop(
if (chunk.usage) {
const pt = chunk.usage.prompt_tokens ?? 0;
const ct = chunk.usage.completion_tokens ?? 0;
+ const rt = chunk.usage.completion_tokens_details?.reasoning_tokens ?? 0;
session.lastPromptTokens = pt;
let breakdown: TokenBreakdown | undefined;
try {
@@ -567,7 +593,7 @@ export async function runAgentLoop(
} catch (err) {
console.error("[pi-agent] failed to calculate breakdown:", err);
}
- callbacks.onUsage(pt, ct, breakdown);
+ callbacks.onUsage(pt, ct, rt, breakdown);
}
const delta = chunk.choices?.[0]?.delta;
@@ -578,6 +604,13 @@ export async function runAgentLoop(
callbacks.onToken(delta.content);
}
+ // Reasoning / thinking stream (Claude thinking_delta, OpenAI delta.reasoning).
+ // Not merged into content or tool-call JSON; surfaced as a separate panel.
+ if (delta.reasoning) {
+ reasoningBuffer += delta.reasoning;
+ callbacks.onThought?.(delta.reasoning);
+ }
+
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const idx: number = tc.index ?? 0;
@@ -589,6 +622,8 @@ export async function runAgentLoop(
if (tc.id) buf.id = tc.id;
if (tc.function?.name) buf.name = tc.function.name;
if (tc.function?.arguments) buf.args += tc.function.arguments;
+ // Gemini 3.x thought signature — opaque blob to round-trip back.
+ if (tc.thought_signature) buf.thought_signature = tc.thought_signature;
// Fire pending chip as soon as we see the tool name during streaming
if (isNew && buf.name) {
@@ -616,7 +651,7 @@ export async function runAgentLoop(
// ── No tool calls → turn complete ─────────────────────────────────────
if (toolCallBuffers.size === 0) {
- session.messages.push({ role: "assistant", content: contentBuffer });
+ session.messages.push({ role: "assistant", content: contentBuffer, reasoning: reasoningBuffer || undefined });
callbacks.onDone();
return;
}
@@ -628,11 +663,13 @@ export async function runAgentLoop(
id: buf.id,
type: "function" as const,
function: { name: buf.name, arguments: buf.args },
+ ...(buf.thought_signature ? { thought_signature: buf.thought_signature } : {}),
}));
session.messages.push({
role: "assistant",
content: contentBuffer || null,
+ reasoning: reasoningBuffer || undefined,
tool_calls: toolCalls,
});
diff --git a/electron/lib/tool-schemas.ts b/electron/lib/tool-schemas.ts
index 79075428..6af20431 100644
--- a/electron/lib/tool-schemas.ts
+++ b/electron/lib/tool-schemas.ts
@@ -348,7 +348,7 @@ export const TOOL_SCHEMAS = {
schema: z.object({
workspaceId: sId,
query: sStr.describe("Natural-language query"),
- k: z.number().int().positive().max(100).optional().describe("Max results (default: 5, max: 100)"),
+ k: z.number().int().min(1).max(100).optional().describe("Max results (default: 5, max: 100)"),
}),
},
diff --git a/electron/preload.ts b/electron/preload.ts
index 8d7ad6b8..4c40c658 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -114,9 +114,15 @@ const api = {
ipcRenderer.on("chat:token", handler);
return () => ipcRenderer.off("chat:token", handler);
},
- onDone: (cb: (e: { content: string; contextRefs: unknown[]; error?: string }) => void) => {
+ onThought: (cb: (e: { delta: string }) => void) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- const handler = (_: any, e: { content: string; contextRefs: unknown[]; error?: string }) => cb(e);
+ const handler = (_: any, e: { delta: string }) => cb(e);
+ ipcRenderer.on("chat:thought", handler);
+ return () => ipcRenderer.off("chat:thought", handler);
+ },
+ onDone: (cb: (e: { content: string; reasoning?: string; contextRefs: unknown[]; error?: string; usage?: { promptTokens: number; completionTokens: number; reasoningTokens?: number; breakdown?: unknown } }) => void) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const handler = (_: any, e: { content: string; reasoning?: string; contextRefs: unknown[]; error?: string }) => cb(e);
ipcRenderer.on("chat:done", handler);
return () => ipcRenderer.off("chat:done", handler);
},
@@ -132,9 +138,9 @@ const api = {
ipcRenderer.on("chat:tool-call-done", handler);
return () => ipcRenderer.off("chat:tool-call-done", handler);
},
- onUsage: (cb: (e: { promptTokens: number; completionTokens: number }) => void) => {
+ onUsage: (cb: (e: { promptTokens: number; completionTokens: number; reasoningTokens?: number; breakdown?: unknown }) => void) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- const handler = (_: any, e: { promptTokens: number; completionTokens: number }) => cb(e);
+ const handler = (_: any, e: { promptTokens: number; completionTokens: number; reasoningTokens?: number; breakdown?: unknown }) => cb(e);
ipcRenderer.on("chat:usage", handler);
return () => ipcRenderer.off("chat:usage", handler);
},
@@ -320,6 +326,12 @@ const api = {
ipcRenderer.on("pi-agent:token", handler);
return () => ipcRenderer.off("pi-agent:token", handler);
},
+ onThought: (cb: (e: { sessionId: string; delta: string }) => void) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const handler = (_: any, e: { sessionId: string; delta: string }) => cb(e);
+ ipcRenderer.on("pi-agent:thought", handler);
+ return () => ipcRenderer.off("pi-agent:thought", handler);
+ },
onTool: (cb: (e: { sessionId: string; name: string; label: string; callId?: string; status: "pending" | "start" | "end"; ok?: boolean; output?: string }) => void) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handler = (_: any, e: { sessionId: string; name: string; label: string; callId?: string; status: "pending" | "start" | "end"; ok?: boolean; output?: string }) => cb(e);
@@ -350,9 +362,9 @@ const api = {
ipcRenderer.on("pi-agent:step", handler);
return () => ipcRenderer.off("pi-agent:step", handler);
},
- onUsage: (cb: (e: { sessionId: string; promptTokens: number; completionTokens: number }) => void) => {
+ onUsage: (cb: (e: { sessionId: string; promptTokens: number; completionTokens: number; reasoningTokens?: number; breakdown?: unknown }) => void) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- const handler = (_: any, e: { sessionId: string; promptTokens: number; completionTokens: number }) => cb(e);
+ const handler = (_: any, e: { sessionId: string; promptTokens: number; completionTokens: number; reasoningTokens?: number; breakdown?: unknown }) => cb(e);
ipcRenderer.on("pi-agent:usage", handler);
return () => ipcRenderer.off("pi-agent:usage", handler);
},
diff --git a/electron/shared/db-mappers.ts b/electron/shared/db-mappers.ts
index 7cf65ef1..9efdc571 100644
--- a/electron/shared/db-mappers.ts
+++ b/electron/shared/db-mappers.ts
@@ -161,6 +161,7 @@ export function toChatMessage(row: any) {
threadId: row.thread_id as string,
role: row.role as string,
content: row.content as string,
+ reasoning: (row.reasoning as string | null) ?? undefined,
contextRefs: row.context_refs ? JSON.parse(row.context_refs) : undefined,
toolCalls: row.tool_calls ? JSON.parse(row.tool_calls) : undefined,
createdAt: row.created_at as string,
diff --git a/package-lock.json b/package-lock.json
index f3d70575..8a16586e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -49,6 +49,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
+ "@eslint/core": "^1.2.1",
"@lezer/highlight": "^1.2.3",
"@modelcontextprotocol/sdk": "^1.29.0",
"@playwright/test": "^1.59.1",
@@ -88,6 +89,7 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
+ "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4",
@@ -1859,7 +1861,7 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
- "node_modules/@eslint/core": {
+ "node_modules/@eslint/config-helpers/node_modules/@eslint/core": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
@@ -1872,6 +1874,19 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@eslint/core": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
+ "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
"node_modules/@eslint/eslintrc": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz",
@@ -1988,6 +2003,19 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
@@ -10086,6 +10114,19 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/eslint/node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
"node_modules/eslint/node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -11397,6 +11438,32 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/hast-util-raw": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz",
+ "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "hast-util-from-parse5": "^8.0.0",
+ "hast-util-to-parse5": "^8.0.0",
+ "html-void-elements": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "parse5": "^7.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0",
+ "web-namespaces": "^2.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -11425,6 +11492,26 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/hast-util-to-parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz",
+ "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "devlop": "^1.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "web-namespaces": "^2.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/hast-util-to-text": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
@@ -11560,6 +11647,17 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/html-void-elements": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
+ "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -16199,6 +16297,22 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/rehype-raw": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
+ "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "hast-util-raw": "^9.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/remark-breaks": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz",
diff --git a/package.json b/package.json
index 2c1dce3a..fd9eef69 100644
--- a/package.json
+++ b/package.json
@@ -78,6 +78,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
+ "@eslint/core": "^1.2.1",
"@lezer/highlight": "^1.2.3",
"@modelcontextprotocol/sdk": "^1.29.0",
"@playwright/test": "^1.59.1",
@@ -117,6 +118,7 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
+ "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4",
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 28780844..617d3300 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -102,12 +102,14 @@ export default function Home() {
// Load messages for the latest session
const rows = await window.electron.piAgent.getMessages(latest.id) as Array<{
id: string; role: "user" | "assistant" | "error"; content: string;
+ reasoning: string | null;
toolCalls: unknown[] | null; subagents: unknown[] | null; timestamp: string;
}>;
const piMessages = rows.map((r) => ({
id: r.id,
role: r.role,
content: r.content,
+ reasoning: r.reasoning ?? undefined,
toolCalls: r.toolCalls ?? undefined,
subagents: r.subagents ?? undefined,
timestamp: r.timestamp,
diff --git a/src/components/agent/AgentChatPane.tsx b/src/components/agent/AgentChatPane.tsx
index 07301801..b9693102 100644
--- a/src/components/agent/AgentChatPane.tsx
+++ b/src/components/agent/AgentChatPane.tsx
@@ -22,7 +22,7 @@ import { ContextRing } from "./ContextRing";
import { Tooltip } from "@/components/ui/tooltip";
import { CairnEvents } from "@/lib/events";
import { resolvePromptContext } from "@/lib/context-resolver";
-import type { TerminalSession } from "@/types";
+import type { TerminalSession, TokenBreakdown } from "@/types";
// ── Cairn tool ref extraction ─────────────────────────────────────────────────
@@ -93,6 +93,7 @@ export function AgentChatPane({ session, isActive }: AgentChatPaneProps) {
// Actions — stable Zustand references, never trigger re-renders
const addPiMessage = useCairnStore((s) => s.addPiMessage);
const appendPiToken = useCairnStore((s) => s.appendPiToken);
+ const appendPiThought = useCairnStore((s) => s.appendPiThought);
const finalisePiMessage = useCairnStore((s) => s.finalisePiMessage);
const addPiToolCall = useCairnStore((s) => s.addPiToolCall);
const clearPiMessages = useCairnStore((s) => s.clearPiMessages);
@@ -103,6 +104,7 @@ export function AgentChatPane({ session, isActive }: AgentChatPaneProps) {
const updatePiSubagentToolCall = useCairnStore((s) => s.updatePiSubagentToolCall);
const addPiSubagent = useCairnStore((s) => s.addPiSubagent);
const appendPiSubagentToken = useCairnStore((s) => s.appendPiSubagentToken);
+ const appendPiSubagentThought = useCairnStore((s) => s.appendPiSubagentThought);
const _finalisePiSubagentMessage = useCairnStore((s) => s.finalisePiSubagentMessage);
const addPiSubagentToolCall = useCairnStore((s) => s.addPiSubagentToolCall);
const completePiSubagent = useCairnStore((s) => s.completePiSubagent);
@@ -177,13 +179,18 @@ export function AgentChatPane({ session, isActive }: AgentChatPaneProps) {
appendPiToken(sessionId, e.delta);
});
+ const unsubThought = electron.piAgent.onThought?.((e) => {
+ if (e.sessionId !== sessionId) return;
+ appendPiThought(sessionId, e.delta);
+ });
+
const unsubUsage = electron.piAgent.onUsage((e) => {
if (e.sessionId === sessionId) {
// Parent step — update the parent ring
- updatePiUsage(sessionId, e.promptTokens, e.completionTokens);
+ updatePiUsage(sessionId, e.promptTokens, e.completionTokens, e.reasoningTokens ?? 0, e.breakdown as TokenBreakdown | undefined);
} else if (e.sessionId.startsWith(`${sessionId}:sub:`)) {
// Subagent step — update usage on the subagent inline block, not the parent ring
- updatePiSubagentUsage(sessionId, e.sessionId, e.promptTokens, e.completionTokens);
+ updatePiSubagentUsage(sessionId, e.sessionId, e.promptTokens, e.completionTokens, e.reasoningTokens ?? 0, e.breakdown as TokenBreakdown | undefined);
}
});
@@ -246,6 +253,7 @@ export function AgentChatPane({ session, isActive }: AgentChatPaneProps) {
id: m.id,
role: m.role,
content: m.content,
+ reasoning: m.reasoning ?? null,
toolCalls: m.toolCalls ?? null,
subagents: m.subagents ?? null,
timestamp: m.timestamp,
@@ -272,6 +280,7 @@ export function AgentChatPane({ session, isActive }: AgentChatPaneProps) {
id: m.id,
role: m.role,
content: m.content,
+ reasoning: m.reasoning ?? null,
toolCalls: m.toolCalls ?? null,
subagents: m.subagents ?? null,
timestamp: m.timestamp,
@@ -295,6 +304,11 @@ export function AgentChatPane({ session, isActive }: AgentChatPaneProps) {
appendPiSubagentToken(sessionId, e.sessionId, e.delta);
});
+ const unsubSubThought = electron.piAgent.onThought?.((e) => {
+ if (!e.sessionId.startsWith(`${sessionId}:sub:`)) return;
+ appendPiSubagentThought(sessionId, e.sessionId, e.delta);
+ });
+
// Keyed by callId (not tool name) so parallel calls to the same tool resolve correctly.
const activeSubCallIds = new Set();
@@ -389,6 +403,7 @@ export function AgentChatPane({ session, isActive }: AgentChatPaneProps) {
return () => {
unsubToken();
+ unsubThought?.();
unsubUsage();
unsubToolsReady();
unsubTool();
@@ -397,6 +412,7 @@ export function AgentChatPane({ session, isActive }: AgentChatPaneProps) {
unsubError();
unsubSubagent();
unsubSubToken();
+ unsubSubThought();
unsubSubTool();
unsubSubStep();
unsubPlanNote();
@@ -610,6 +626,8 @@ export function AgentChatPane({ session, isActive }: AgentChatPaneProps) {
promptTokens={session.lastUsage.promptTokens}
contextLimit={agentConfig.contextLimit ?? 128000}
breakdown={session.lastUsage.breakdown}
+ completionTokens={session.lastUsage.completionTokens}
+ reasoningTokens={session.lastUsage.reasoningTokens}
/>
)}
diff --git a/src/components/agent/AgentMessageBubble.tsx b/src/components/agent/AgentMessageBubble.tsx
index 80619254..5b9d50f9 100644
--- a/src/components/agent/AgentMessageBubble.tsx
+++ b/src/components/agent/AgentMessageBubble.tsx
@@ -5,6 +5,7 @@ import { CheckCircle, Loader2, ChevronDown, ChevronRight, GitBranch } from "luci
import { cn } from "@/lib/utils";
import { MarkdownContent } from "@/components/chat/chat-panel/MarkdownContent";
import { MessageAvatar, StreamingCursor } from "@/components/chat/chat-panel/message-ui";
+import { ThinkingPanel } from "@/components/chat/chat-panel/ThinkingPanel";
import { CairnRefChip } from "@/components/shared/cairn-ref-chip";
import { useCairnStore } from "@/store";
import { ContextRing } from "./ContextRing";
@@ -153,6 +154,8 @@ function SubagentBlock({ sub }: { sub: PiSubagentMessage }) {
promptTokens={sub.lastUsage.promptTokens}
contextLimit={contextLimit}
breakdown={sub.lastUsage.breakdown}
+ completionTokens={sub.lastUsage.completionTokens}
+ reasoningTokens={sub.lastUsage.reasoningTokens}
size={12}
stroke={1.5}
/>
@@ -187,11 +190,19 @@ function SubagentBlock({ sub }: { sub: PiSubagentMessage }) {
function SubagentMessageRow({ msg, sessionId }: { msg: PiAgentMessage; sessionId?: string }) {
const hasTools = (msg.toolCalls?.length ?? 0) > 0;
const hasContent = msg.content.length > 0;
+ const hasReasoning = !!msg.reasoning;
return (