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 (
+ {hasReasoning && ( + + )} {hasTools && (
{msg.toolCalls!.map((tc, i) => ( @@ -199,7 +210,7 @@ function SubagentMessageRow({ msg, sessionId }: { msg: PiAgentMessage; sessionId ))}
)} - {!hasContent && msg.isStreaming && !hasTools && ( + {!hasContent && msg.isStreaming && !hasTools && !hasReasoning && (
Thinking… @@ -269,12 +280,22 @@ export const AgentMessageBubble = React.memo(function AgentMessageBubble({ messa const hasContent = message.content.length > 0; const hasTools = (message.toolCalls?.length ?? 0) > 0; const hasSubagents = (message.subagents?.length ?? 0) > 0; + const hasReasoning = !!message.reasoning; return (
+ {/* Reasoning / thinking panel — collapsible */} + {hasReasoning && ( + + )} + {/* Markdown content with animated cursor when streaming */} {hasContent && (
Thinking… diff --git a/src/components/agent/ContextRing.tsx b/src/components/agent/ContextRing.tsx index 31b5f8a1..f9f47c07 100644 --- a/src/components/agent/ContextRing.tsx +++ b/src/components/agent/ContextRing.tsx @@ -10,6 +10,10 @@ interface ContextRingProps { promptTokens: number; contextLimit: number; breakdown?: TokenBreakdown; + /** Total completion tokens for this turn (answer + reasoning, if any). */ + completionTokens?: number; + /** Subset of completionTokens spent on reasoning/thinking. 0/undefined if the model didn't split. */ + reasoningTokens?: number; /** Ring diameter in px. Default 16. */ size?: number; /** Stroke width in px. Default 2. */ @@ -20,6 +24,8 @@ export function ContextRing({ promptTokens, contextLimit, breakdown, + completionTokens, + reasoningTokens, size = 16, stroke = 2, }: ContextRingProps) { @@ -46,6 +52,9 @@ export function ContextRing({ const toolOutputs = breakdown?.toolOutputs ?? 0; const conversation = breakdown?.conversation ?? Math.max(0, promptTokens - system - toolOutputs); + const thinkingTokens = reasoningTokens ?? 0; + const answerTokens = Math.max(0, (completionTokens ?? 0) - thinkingTokens); + const categories = [ { label: "System prompt", count: system, color: "var(--muted-fg)" }, { label: "Tool definitions", count: tools, color: "var(--accent)" }, @@ -162,6 +171,30 @@ export function ContextRing({ ); })}
+ + {/* Output breakdown — only shown after the first turn completes */} + {typeof completionTokens === "number" && completionTokens > 0 && ( +
+
Output
+
+ Answer + {formatTokenCount(answerTokens)} +
+ {thinkingTokens > 0 && ( +
+ +
+ Thinking + + {formatTokenCount(thinkingTokens)} +
+ )} +
+ Total + {formatTokenCount(completionTokens)} +
+
+ )} diff --git a/src/components/agent/SessionPane.tsx b/src/components/agent/SessionPane.tsx index d7070080..8c04772d 100644 --- a/src/components/agent/SessionPane.tsx +++ b/src/components/agent/SessionPane.tsx @@ -47,6 +47,7 @@ export function SessionPane({ isRightPanel = false, chatPrefill = null, onPrefil activeView, chatOpen, projects, + setActiveChatThreadId, } = useCairnStore(useShallow((s) => ({ terminalSessions: s.terminalSessions, activeSessionId: s.activeSessionId, @@ -59,6 +60,7 @@ export function SessionPane({ isRightPanel = false, chatPrefill = null, onPrefil activeView: s.activeView, chatOpen: s.chatOpen, projects: s.projects, + setActiveChatThreadId: s.setActiveChatThreadId, }))); const hasCodeDirectory = !!projects.find((p) => p.id === activeProjectId)?.codeDirectory; @@ -85,7 +87,8 @@ export function SessionPane({ isRightPanel = false, chatPrefill = null, onPrefil function handleNewChatThread() { if (!activeWorkspaceId) return; setNewMenuOpen(false); - createNewThread(activeWorkspaceId, activeProjectId ?? undefined); + const thread = createNewThread(activeWorkspaceId, activeProjectId ?? undefined); + setActiveChatThreadId(thread.id); setActiveSession("chat"); } diff --git a/src/components/chat/chat-panel/ChatMessageBubble.tsx b/src/components/chat/chat-panel/ChatMessageBubble.tsx index 5a34fce7..21f08566 100644 --- a/src/components/chat/chat-panel/ChatMessageBubble.tsx +++ b/src/components/chat/chat-panel/ChatMessageBubble.tsx @@ -5,6 +5,7 @@ import { FileText, Kanban, FolderOpen, Search, Copy, Check, RotateCcw, CheckCirc import { cn, formatRelative } from "@/lib/utils"; import { MarkdownContent } from "./MarkdownContent"; import { ActionsList } from "./ActionsList"; +import { ThinkingPanel } from "./ThinkingPanel"; import { MessageAvatar } from "./message-ui"; import { CairnRefChip } from "@/components/shared/cairn-ref-chip"; import type { ChatMessage, LinkedContextReference, ChatToolCallRecord } from "@/types"; @@ -68,6 +69,9 @@ export const ChatMessageBubble = React.memo(function ChatMessageBubble({ message ))}
)} + {!isUser && message.reasoning && ( + + )}
diff --git a/src/components/chat/chat-panel/MarkdownContent.tsx b/src/components/chat/chat-panel/MarkdownContent.tsx index 3900e414..6e3194fb 100644 --- a/src/components/chat/chat-panel/MarkdownContent.tsx +++ b/src/components/chat/chat-panel/MarkdownContent.tsx @@ -4,6 +4,7 @@ import React, { useMemo } from "react"; import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; import remarkGfm from "remark-gfm"; import remarkBreaks from "remark-breaks"; +import rehypeRaw from "rehype-raw"; import { MermaidDiagram } from "@/components/notes/MermaidDiagram"; import { CodeBlock } from "@/components/notes/CodeBlock"; import { useCairnStore } from "@/store"; @@ -97,6 +98,7 @@ export function MarkdownContent({ content, isUser }: { content: string; isUser?: return ( url.startsWith("cairn://") ? url : defaultUrlTransform(url)} components={{ p: ({ children }) =>

{children}

, diff --git a/src/components/chat/chat-panel/ThinkingPanel.tsx b/src/components/chat/chat-panel/ThinkingPanel.tsx new file mode 100644 index 00000000..724bfaa7 --- /dev/null +++ b/src/components/chat/chat-panel/ThinkingPanel.tsx @@ -0,0 +1,105 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import { ChevronDown, Brain } from "lucide-react"; +import { MarkdownContent } from "./MarkdownContent"; + +interface ThinkingPanelProps { + /** Reasoning text. When non-empty the panel renders; when empty it's null. */ + text: string; + /** + * If true, the panel starts expanded and auto-collapses as soon as + * `companionContent` begins streaming in. If false, the panel renders + * collapsed by default (used for persisted messages from history). + */ + streaming?: boolean; + /** Live content text — used to detect "answer has started" for auto-collapse. */ + companionContent?: string; +} + +/** + * Collapsible "Thinking" panel for model reasoning text. + * + * Behaviour: + * - Streaming: expanded by default so the user sees the reasoning flow live. + * Auto-collapses the instant the first content token arrives. + * User can re-expand at any time. + * - Final/persisted: rendered collapsed by default with a chevron toggle. + * + * The panel renders nothing when `text` is empty (models that don't expose + * reasoning never produce a panel). + */ +export const ThinkingPanel = React.memo(function ThinkingPanel({ + text, + streaming = false, + companionContent, +}: ThinkingPanelProps) { + const [open, setOpen] = useState(streaming); + const [userOverride, setUserOverride] = useState(false); + const hadTextRef = useRef(Boolean(text)); + + // Auto-collapse when companion content starts streaming. + useEffect(() => { + if (streaming && companionContent && !userOverride && open) { + setOpen(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [streaming, companionContent, userOverride]); + + // Re-expand only on the empty → non-empty transition (new reasoning stream), + // but only if the answer hasn't started streaming yet. + useEffect(() => { + const hadText = hadTextRef.current; + hadTextRef.current = Boolean(text); + if (streaming && text && !hadText && !userOverride && !companionContent) { + setOpen(true); + } + }, [text, streaming, userOverride, companionContent]); + + // Reset user override when text is cleared (new turn). + useEffect(() => { + if (!text) setUserOverride(false); + }, [text]); + + if (!text) return null; + + const handleToggle = () => { + setUserOverride(true); + setOpen((prev) => !prev); + }; + + const preview = streaming && open ? null : ( + + {text.slice(0, 80).trim()}{text.length > 80 ? "…" : ""} + + ); + + return ( +
+ + {open && ( +
+ + {streaming && open && ( + + )} +
+ )} +
+ ); +}); diff --git a/src/components/chat/chat-panel/ToolCallIndicator.tsx b/src/components/chat/chat-panel/ToolCallIndicator.tsx index 73567a06..edd8b8ab 100644 --- a/src/components/chat/chat-panel/ToolCallIndicator.tsx +++ b/src/components/chat/chat-panel/ToolCallIndicator.tsx @@ -3,14 +3,18 @@ import React from "react"; import { Loader2, CheckCircle, Bot } from "lucide-react"; import { MarkdownContent } from "./MarkdownContent"; +import { ThinkingPanel } from "./ThinkingPanel"; import type { ChatToolCall } from "@/hooks/useChatStream"; interface ToolCallIndicatorProps { toolCalls: ChatToolCall[]; streamingContent: string; + streamingThought?: string; } -export const ToolCallIndicator = React.memo(function ToolCallIndicator({ toolCalls, streamingContent }: ToolCallIndicatorProps) { +export const ToolCallIndicator = React.memo(function ToolCallIndicator({ toolCalls, streamingContent, streamingThought }: ToolCallIndicatorProps) { + const hasThought = !!streamingThought; + const hasContent = !!streamingContent; return (
@@ -27,19 +31,28 @@ export const ToolCallIndicator = React.memo(function ToolCallIndicator({ toolCal {tc.label}
))} - {streamingContent ? ( + {hasThought && ( +
+ +
+ )} + {hasContent ? (
- ) : ( + ) : !hasThought ? (
{toolCalls.length === 0 ? "Thinking…" : "Working…"}
- )} + ) : null}
); diff --git a/src/components/chat/chat-panel/index.tsx b/src/components/chat/chat-panel/index.tsx index 5352702e..246d9b68 100644 --- a/src/components/chat/chat-panel/index.tsx +++ b/src/components/chat/chat-panel/index.tsx @@ -118,7 +118,7 @@ export function ChatPanel({ prefill, onPrefillConsumed }: ChatPanelProps = {}) { const messagesEndRef = useRef(null); const inputRef = useRef(null); - const { isLoading, toolCalls, streamingContent, pendingQuestions, sendStream, stopStream } = useChatStream(threadId); + const { isLoading, toolCalls, streamingContent, streamingThought, pendingQuestions, sendStream, stopStream } = useChatStream(threadId); const project = useMemo(() => projects.find((p) => p.id === activeProjectId), [projects, activeProjectId]); const workspace = useMemo(() => workspaces.find((w) => w.id === activeWorkspaceId), [workspaces, activeWorkspaceId]); @@ -276,15 +276,38 @@ export function ChatPanel({ prefill, onPrefillConsumed }: ChatPanelProps = {}) { - // Initialise / switch thread. - // Reads getOrCreateThread directly from the store snapshot (stable, no ref - // needed) so the effect only re-runs when the workspace/project identity - // changes. Never switches while a stream is in-flight. + // Initialise / switch thread when the project changes. + // When the user switches projects, the active chat thread should follow — + // if the current thread belongs to a different project, find or create one + // scoped to the new project. Never switches while a stream is in-flight. + const prevProjectIdRef = useRef(activeProjectId); + const prevWorkspaceIdRef = useRef(activeWorkspaceId); useEffect(() => { if (!activeWorkspaceId) return; if (isLoadingRef.current) return; - // Only initialise if no thread is active yet (tab bar may have already set one) - if (activeChatThreadId) return; + + const prevProject = prevProjectIdRef.current; + const prevWorkspace = prevWorkspaceIdRef.current; + prevProjectIdRef.current = activeProjectId; + prevWorkspaceIdRef.current = activeWorkspaceId; + + // Workspace changed — invalidate the active thread regardless of project match + if (prevWorkspace !== activeWorkspaceId) { + // Fall through to getOrCreateThread below + } else if (prevProject === activeProjectId) { + // Neither changed — only initialise if no thread is active yet + if (activeChatThreadId) return; + } else { + // Project changed — check if the current thread still matches + if (activeChatThreadId) { + const currentThread = useCairnStore.getState().chatThreads.find( + (t) => t.id === activeChatThreadId, + ); + // Keep the thread if it matches the new project or is workspace-scoped + if (currentThread && currentThread.projectId === activeProjectId) return; + } + } + const t = useCairnStore.getState().getOrCreateThread(activeWorkspaceId, activeProjectId ?? undefined); setActiveChatThreadId(t.id); }, [activeWorkspaceId, activeProjectId, activeChatThreadId, setActiveChatThreadId]); @@ -406,6 +429,8 @@ export function ChatPanel({ prefill, onPrefillConsumed }: ChatPanelProps = {}) { promptTokens={activeThread.lastUsage.promptTokens} contextLimit={aiConfig.contextLimit ?? 128000} breakdown={activeThread.lastUsage.breakdown} + completionTokens={activeThread.lastUsage.completionTokens} + reasoningTokens={activeThread.lastUsage.reasoningTokens} /> )} @@ -446,7 +471,7 @@ export function ChatPanel({ prefill, onPrefillConsumed }: ChatPanelProps = {}) { disabled={isLoading && !pendingQuestions} /> )} - {isLoading && } + {isLoading && }
diff --git a/src/components/notes/NoteMarkdownPreview.tsx b/src/components/notes/NoteMarkdownPreview.tsx index 90a8bf7d..aa8f967f 100644 --- a/src/components/notes/NoteMarkdownPreview.tsx +++ b/src/components/notes/NoteMarkdownPreview.tsx @@ -15,6 +15,7 @@ import remarkGfm from "remark-gfm"; import remarkBreaks from "remark-breaks"; import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; +import rehypeRaw from "rehype-raw"; import "katex/dist/katex.min.css"; import { MermaidDiagram } from "./MermaidDiagram"; import { CodeBlock } from "./CodeBlock"; @@ -44,7 +45,7 @@ export function NoteMarkdownPreview({ content, className }: NoteMarkdownPreviewP
& ExtraProps) { diff --git a/src/components/notes/note-editor.tsx b/src/components/notes/note-editor.tsx index e0ab558e..1c64fa93 100644 --- a/src/components/notes/note-editor.tsx +++ b/src/components/notes/note-editor.tsx @@ -6,6 +6,7 @@ import remarkGfm from "remark-gfm"; import remarkBreaks from "remark-breaks"; import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; +import rehypeRaw from "rehype-raw"; import "katex/dist/katex.min.css"; import { Pin, PinOff, Calendar, Eye, Pencil, Wand2, Loader2, CheckCircle2, FileDown, ChevronLeft, Sparkles, Sun, Moon, ChevronDown as Chevron } from "lucide-react";import { WikilinkPicker } from "./WikilinkPicker"; import { getActiveWikilink } from "@/lib/wikilink-parser"; @@ -1003,7 +1004,7 @@ export function NoteEditor({ note, onBack }: NoteEditorProps) {
diff --git a/src/hooks/useChatStream.ts b/src/hooks/useChatStream.ts index 14c2eb01..50880c09 100644 --- a/src/hooks/useChatStream.ts +++ b/src/hooks/useChatStream.ts @@ -55,6 +55,8 @@ export interface UseChatStreamResult { isLoading: boolean; toolCalls: ChatToolCall[]; streamingContent: string; + /** Reasoning / thinking text streamed live from the model. Cleared on done. */ + streamingThought: string; /** Non-null when the agent has called ask_questions — cleared on submit or done. */ pendingQuestions: PendingQuestion[] | null; sendStream: (req: ChatStreamRequest) => void; @@ -69,6 +71,7 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { const [isLoading, setIsLoading] = useState(false); const [toolCalls, setToolCalls] = useState([]); const [streamingContent, setStreamingContent] = useState(""); + const [streamingThought, setStreamingThought] = useState(""); const [pendingQuestions, setPendingQuestions] = useState(null); const threadIdRef = useRef(null); @@ -121,7 +124,11 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { setStreamingContent((prev) => prev + e.delta); }); - const unsubDone = (electron.chat.onDone as (cb: (e: { content: string; contextRefs: unknown[]; error?: string; usage?: { promptTokens: number; completionTokens: number; breakdown?: TokenBreakdown } }) => void) => () => void)((e) => { + const unsubThought = electron.chat.onThought?.((e) => { + setStreamingThought((prev) => prev + e.delta); + }); + + const unsubDone = (electron.chat.onDone as (cb: (e: { content: string; reasoning?: string; contextRefs: unknown[]; error?: string; usage?: { promptTokens: number; completionTokens: number; reasoningTokens?: number; breakdown?: TokenBreakdown } }) => void) => () => void)((e) => { const tid = threadIdRef.current; // Mark any still-running tool as done before persisting. const finalToolCalls = toolCallsRef.current.map((tc) => @@ -131,12 +138,13 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { const capturedActions = pendingActionsRef.current; pendingActionsRef.current = []; // eslint-disable-next-line @typescript-eslint/no-explicit-any - addMessage(tid, "assistant", e.content, e.contextRefs as any, finalToolCalls, capturedActions); + addMessage(tid, "assistant", e.content, e.contextRefs as any, finalToolCalls, capturedActions, e.reasoning); if (e.usage) { setThreadUsage(tid, e.usage); } } setStreamingContent(""); + setStreamingThought(""); setIsLoading(false); setToolCalls([]); toolCallsRef.current = []; @@ -145,7 +153,7 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { // the user submits their answers. It is cleared in sendStream() instead. }); - const unsubUsage = (electron.chat.onUsage as (cb: (e: { promptTokens: number; completionTokens: number; breakdown?: TokenBreakdown }) => void) => () => void)((e) => { + const unsubUsage = (electron.chat.onUsage as (cb: (e: { promptTokens: number; completionTokens: number; reasoningTokens?: number; breakdown?: TokenBreakdown }) => void) => () => void)((e) => { const tid = threadIdRef.current; if (tid) { setThreadUsage(tid, e); @@ -156,6 +164,7 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { unsubTool(); unsubToolDone?.(); unsubToken(); + unsubThought?.(); unsubDone(); unsubUsage(); }; @@ -172,6 +181,7 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { if (threadId) { setThreadUsage(threadId, undefined); } + setStreamingThought(""); window.electron?.chat.stream(req); } @@ -182,6 +192,7 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { toolCallsRef.current = []; pendingActionsRef.current = []; setStreamingContent(""); + setStreamingThought(""); // Keep pendingQuestions — user may still want to answer after stopping } @@ -189,5 +200,5 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { setPendingQuestions(null); } - return { isLoading, toolCalls, streamingContent, pendingQuestions, sendStream, stopStream, clearQuestions }; + return { isLoading, toolCalls, streamingContent, streamingThought, pendingQuestions, sendStream, stopStream, clearQuestions }; } diff --git a/src/store/slices/chat.ts b/src/store/slices/chat.ts index 1711782a..45e69000 100644 --- a/src/store/slices/chat.ts +++ b/src/store/slices/chat.ts @@ -4,7 +4,7 @@ import type { StateCreator } from "zustand"; import type { CairnStore } from "../index"; -import type { ChatThread, ChatMessage, ChatToolCallRecord, PendingAction, ID } from "@/types"; +import type { ChatThread, ChatMessage, ChatToolCallRecord, PendingAction, ID, TokenBreakdown } from "@/types"; import { id, now } from "@/lib/utils"; import { ipc, ipcAwait, ipcAwaitResult } from "../ipc"; @@ -23,7 +23,8 @@ export interface ChatSlice { content: string, contextRefs?: ChatMessage["contextRefs"], toolCalls?: ChatToolCallRecord[], - actions?: ChatMessage["actions"] + actions?: ChatMessage["actions"], + reasoning?: string, ) => ChatMessage; confirmAction: (action: PendingAction) => void; deleteThread: (threadId: ID) => void; @@ -31,7 +32,12 @@ export interface ChatSlice { createNewThread: (workspaceId: ID, projectId?: ID) => ChatThread; compactChatThread: (threadId: ID) => Promise; clearThreadMessages: (threadId: ID) => Promise; - setThreadUsage: (threadId: ID, usage: { promptTokens: number; completionTokens: number } | undefined) => void; + setThreadUsage: (threadId: ID, usage: { + promptTokens: number; + completionTokens: number; + reasoningTokens?: number; + breakdown?: TokenBreakdown; + } | undefined) => void; } // ── Slice creator ───────────────────────────────────────────────────────────── @@ -70,12 +76,13 @@ export const createChatSlice: StateCreator = ( return thread; }, - addMessage(threadId, role, content, contextRefs, toolCalls, actions) { + addMessage(threadId, role, content, contextRefs, toolCalls, actions, reasoning) { const msg: ChatMessage = { id: id(), threadId, role, content, + reasoning: reasoning || undefined, contextRefs, toolCalls: toolCalls && toolCalls.length > 0 ? toolCalls : undefined, actions: actions && actions.length > 0 ? actions : undefined, diff --git a/src/store/slices/terminal-sessions.ts b/src/store/slices/terminal-sessions.ts index 6520e5f8..ebfc94b1 100644 --- a/src/store/slices/terminal-sessions.ts +++ b/src/store/slices/terminal-sessions.ts @@ -39,6 +39,8 @@ export interface TerminalSessionsSlice { addPiMessage: (sessionId: string, msg: PiAgentMessage) => void; /** Append a token delta to the last streaming assistant message */ appendPiToken: (sessionId: string, delta: string) => void; + /** Append a reasoning/thinking delta to the last streaming assistant message */ + appendPiThought: (sessionId: string, delta: string) => void; /** Finalise the streaming assistant message (isStreaming → false) */ finalisePiMessage: (sessionId: string) => void; /** Ensure a streaming assistant message exists — creates one if the last message is not streaming */ @@ -52,11 +54,13 @@ export interface TerminalSessionsSlice { /** Clear message history for a pi session */ clearPiMessages: (sessionId: string) => void; /** Update token usage for a session after a step completes */ - updatePiUsage: (sessionId: string, promptTokens: number, completionTokens: number, breakdown?: TokenBreakdown) => void; + updatePiUsage: (sessionId: string, promptTokens: number, completionTokens: number, reasoningTokens: number, breakdown?: TokenBreakdown) => void; /** Register a new subagent on the last streaming assistant message */ addPiSubagent: (sessionId: string, childSessionId: string) => void; /** Append a token to a subagent's last streaming message */ appendPiSubagentToken: (sessionId: string, childSessionId: string, delta: string) => void; + /** Append a reasoning/thought delta to a subagent's last streaming message */ + appendPiSubagentThought: (sessionId: string, childSessionId: string, delta: string) => void; /** Finalise the last streaming message in a subagent */ finalisePiSubagentMessage: (sessionId: string, childSessionId: string) => void; /** Add a tool call to a subagent's last streaming message */ @@ -66,7 +70,7 @@ export interface TerminalSessionsSlice { /** Mark a subagent as done and store its result */ completePiSubagent: (sessionId: string, childSessionId: string, result: string) => void; /** Update token usage on an inline subagent block */ - updatePiSubagentUsage: (sessionId: string, childSessionId: string, promptTokens: number, completionTokens: number, breakdown?: TokenBreakdown) => void; + updatePiSubagentUsage: (sessionId: string, childSessionId: string, promptTokens: number, completionTokens: number, reasoningTokens: number, breakdown?: TokenBreakdown) => void; /** Start a new step in a subagent (finalise current message) */ stepPiSubagent: (sessionId: string, childSessionId: string) => void; /** Set the mode for a pi session and optionally record the plan note ID */ @@ -177,6 +181,39 @@ export const createTerminalSessionsSlice: StateCreator ({ + terminalSessions: s.terminalSessions.map((t) => { + if (t.sessionId !== sessionId) return t; + const msgs = t.piMessages ?? []; + const last = msgs[msgs.length - 1]; + if (last?.isStreaming) { + return { + ...t, + piMessages: [ + ...msgs.slice(0, -1), + { ...last, reasoning: (last.reasoning ?? "") + delta }, + ], + }; + } + return { + ...t, + piMessages: [ + ...msgs, + { + id: `stream-${Date.now()}`, + role: "assistant" as const, + content: "", + reasoning: delta, + isStreaming: true, + timestamp: new Date().toISOString(), + }, + ], + }; + }), + })); + }, + finalisePiMessage(sessionId) { set((s) => ({ terminalSessions: s.terminalSessions.map((t) => { @@ -186,7 +223,8 @@ export const createTerminalSessionsSlice: StateCreator ({ terminalSessions: s.terminalSessions.map((t) => t.sessionId === sessionId - ? { ...t, lastUsage: { promptTokens, completionTokens, breakdown } } + ? { ...t, lastUsage: { promptTokens, completionTokens, reasoningTokens, breakdown } } : t ), })); @@ -377,6 +415,40 @@ export const createTerminalSessionsSlice: StateCreator ({ + terminalSessions: s.terminalSessions.map((t) => { + if (t.sessionId !== sessionId) return t; + return { + ...t, + piMessages: (t.piMessages ?? []).map((msg) => { + const subIdx = (msg.subagents ?? []).findIndex((sa) => sa.childSessionId === childSessionId); + if (subIdx === -1) return msg; + const sub = msg.subagents![subIdx]; + const subMsgs = sub.messages; + const lastSub = subMsgs[subMsgs.length - 1]; + let newSubMsgs: PiAgentMessage[]; + if (lastSub?.isStreaming) { + newSubMsgs = [...subMsgs.slice(0, -1), { ...lastSub, reasoning: (lastSub.reasoning ?? "") + delta }]; + } else { + newSubMsgs = [...subMsgs, { + id: `sub-stream-${Date.now()}`, + role: "assistant" as const, + content: "", + reasoning: delta, + isStreaming: true, + timestamp: new Date().toISOString(), + }]; + } + const newSubagents = [...msg.subagents!]; + newSubagents[subIdx] = { ...sub, messages: newSubMsgs }; + return { ...msg, subagents: newSubagents }; + }), + }; + }), + })); + }, + finalisePiSubagentMessage(sessionId, childSessionId) { set((s) => ({ terminalSessions: s.terminalSessions.map((t) => { @@ -489,7 +561,7 @@ export const createTerminalSessionsSlice: StateCreator ({ terminalSessions: s.terminalSessions.map((t) => { if (t.sessionId !== sessionId) return t; @@ -499,7 +571,7 @@ export const createTerminalSessionsSlice: StateCreator sa.childSessionId === childSessionId); if (subIdx === -1) return msg; const newSubagents = [...msg.subagents!]; - newSubagents[subIdx] = { ...newSubagents[subIdx], lastUsage: { promptTokens, completionTokens, breakdown } }; + newSubagents[subIdx] = { ...newSubagents[subIdx], lastUsage: { promptTokens, completionTokens, reasoningTokens, breakdown } }; return { ...msg, subagents: newSubagents }; }), }; diff --git a/src/types/index.ts b/src/types/index.ts index 4cdcd24f..b51c7ad6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -137,11 +137,30 @@ export interface ChatThread { title?: string; createdAt: string; updatedAt: string; - lastUsage?: { promptTokens: number; completionTokens: number; breakdown?: TokenBreakdown }; + lastUsage?: { + promptTokens: number; + completionTokens: number; + /** Subset of completion_tokens produced by the model's reasoning/thinking step. 0 if the model didn't split. */ + reasoningTokens?: number; + breakdown?: TokenBreakdown; + }; } export type ChatRole = "user" | "assistant" | "system"; +/** + * Streaming token-usage shape mirrored from the OpenAI chat/completions spec. + * `reasoningTokens` is the subset of `completionTokens` spent on chain-of-thought + * reasoning; absent when the model doesn't split reasoning from content. + */ +export interface CompletionUsage { + promptTokens: number; + completionTokens: number; + totalTokens?: number; + reasoningTokens?: number; + breakdown?: TokenBreakdown; +} + export interface LinkedContextReference { type: "note" | "task" | "project" | "search_result"; id: ID; @@ -166,6 +185,13 @@ export interface ChatMessage { threadId: ID; role: ChatRole; content: string; + /** + * Reasoning / thinking text emitted by the model during generation (Claude's + * thinking_delta, OpenAI-style delta.reasoning). Persisted so past messages + * retain an expandable Thinking panel in the bubble; intentionally stripped + * from compaction summaries. Empty for models that don't expose reasoning. + */ + reasoning?: string; /** Entities cited in or used to produce this message */ contextRefs?: LinkedContextReference[]; /** Tool calls made during this assistant turn — persisted so they remain visible after streaming ends */ @@ -395,13 +421,15 @@ export interface PiSubagentMessage { /** Final result returned to the parent */ result?: string; /** Latest token usage from the subagent's LLM steps */ - lastUsage?: { promptTokens: number; completionTokens: number; breakdown?: TokenBreakdown }; + lastUsage?: { promptTokens: number; completionTokens: number; reasoningTokens?: number; breakdown?: TokenBreakdown }; } export interface PiAgentMessage { id: string; role: "user" | "assistant" | "error" | "system"; content: string; + /** Same semantics as {@link ChatMessage.reasoning}. */ + reasoning?: string; /** Tool calls that occurred before or during this assistant message */ toolCalls?: { callId: string; @@ -432,7 +460,7 @@ export interface TerminalSession { sessionType: "pty" | "pi"; piMessages?: PiAgentMessage[]; initialPrompt?: string; - lastUsage?: { promptTokens: number; completionTokens: number; breakdown?: TokenBreakdown }; + lastUsage?: { promptTokens: number; completionTokens: number; reasoningTokens?: number; breakdown?: TokenBreakdown }; mode?: "plan" | "execute"; planNoteId?: string; }