From b5eed8e6a959287c509657f3cd53afbc0d3bdbbb Mon Sep 17 00:00:00 2001 From: becktor Date: Tue, 20 Jan 2026 16:56:48 +0100 Subject: [PATCH 1/7] feat(tui): show running tools and LLM status in sidebar - Add 'Running' section to sidebar showing active tools with elapsed time - Display LLM inference states: Sending, Pondering, Streaming, Retry - Extract extractToolCommand utility to tui/util/running.ts - Reuse existing formatDuration from @/util/format - Add tests for extractToolCommand with 100% coverage --- .../cli/cmd/tui/routes/session/sidebar.tsx | 226 +++++++++++++++++- .../opencode/src/cli/cmd/tui/util/running.ts | 36 +++ .../opencode/test/cli/tui/running.test.ts | 68 ++++++ 3 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/util/running.ts create mode 100644 packages/opencode/test/cli/tui/running.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index ebc7514d723..43e680f5322 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -1,16 +1,36 @@ import { useSync } from "@tui/context/sync" -import { createMemo, For, Show, Switch, Match } from "solid-js" +import { createMemo, createSignal, createEffect, For, Show, Switch, Match, onMount, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "../../context/theme" import { Locale } from "@/util/locale" +import { formatDuration } from "@/util/format" import path from "path" -import type { AssistantMessage } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, ToolPart, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" import { Global } from "@/global" import { Installation } from "@/installation" import { useKeybind } from "../../context/keybind" import { useDirectory } from "../../context/directory" import { useKV } from "../../context/kv" import { TodoItem } from "../../component/todo-item" +import { extractToolCommand } from "../../util/running" + +const RUNNING_THRESHOLD_MS = 2000 + +function RunningToolItem(props: { command: string; startTime: number; now: number }) { + const { theme } = useTheme() + + return ( + + + ● + + + {props.command} + {formatDuration(Math.floor((props.now - props.startTime) / 1000))} + + + ) +} export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() @@ -20,6 +40,124 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const todo = createMemo(() => sync.data.todo[props.sessionID] ?? []) const messages = createMemo(() => sync.data.message[props.sessionID] ?? []) + // Tick signal for live time updates + const [tick, setTick] = createSignal(Date.now()) + onMount(() => { + const interval = setInterval(() => setTick(Date.now()), 1000) + onCleanup(() => clearInterval(interval)) + }) + + // Track session status for LLM inference state + const sessionStatus = createMemo(() => sync.data.session_status?.[props.sessionID] ?? { type: "idle" as const }) + const [thinkingStartTime, setThinkingStartTime] = createSignal(null) + + createEffect(() => { + const status = sessionStatus() + if (status.type === "busy") { + if (thinkingStartTime() === null) { + setThinkingStartTime(Date.now()) + } + } else { + setThinkingStartTime(null) + } + }) + + const runningTools = createMemo(() => { + const now = tick() + const sessionMessages = messages() + const tools: Array<{ + id: string + command: string + startTime: number + }> = [] + + for (const message of sessionMessages) { + const parts = sync.data.part[message.id] ?? [] + for (const part of parts) { + if (part.type === "tool") { + const toolPart = part as ToolPart + if (toolPart.state.status === "running") { + const startTime = toolPart.state.time.start + if (now - startTime >= RUNNING_THRESHOLD_MS) { + tools.push({ + id: toolPart.id, + command: extractToolCommand(toolPart.tool, toolPart.state.input), + startTime, + }) + } + } + } + } + } + + return tools.sort((a, b) => a.startTime - b.startTime) + }) + + const inferenceStatus = createMemo(() => { + const now = tick() + const status = sessionStatus() + + // Handle retry state + if (status.type === "retry") { + const remaining = Math.max(0, Math.ceil((status.next - now) / 1000)) + return { type: "retry" as const, message: status.message, remaining } + } + + // Not busy = no inference happening + if (status.type !== "busy") return null + + // Check threshold + const startTime = thinkingStartTime() + if (!startTime || now - startTime < RUNNING_THRESHOLD_MS) return null + + // If tools are running, don't show inference status (tools will show instead) + if (runningTools().length > 0) return null + + const sessionMessages = messages() + const lastMsg = sessionMessages.at(-1) + + // No messages or last is user message = sending request + if (!lastMsg || lastMsg.role === "user") { + return { type: "sending" as const, startTime } + } + + // Have assistant message - check parts + const parts = sync.data.part[lastMsg.id] ?? [] + + // No parts yet = pondering (waiting for first token) + if (parts.length === 0) { + return { type: "pondering" as const, startTime } + } + + // Check for active text/reasoning streaming (ignore tool parts) + const lastTextOrReasoning = [...parts] + .reverse() + .find((p): p is TextPart | ReasoningPart => p.type === "text" || p.type === "reasoning") + + if (lastTextOrReasoning) { + const hasContent = lastTextOrReasoning.text?.length > 0 + const isComplete = lastTextOrReasoning.time?.end !== undefined + + if (!hasContent) { + // Part exists but no content yet = pondering + return { type: "pondering" as const, startTime } + } + + if (!isComplete) { + // Has content, not complete = actively streaming + const streamStartTime = lastTextOrReasoning.time?.start ?? startTime + return { type: "streaming" as const, startTime: streamStartTime } + } + } + + // Between tool calls or other intermediate state = pondering + if (!lastMsg.time.completed) { + return { type: "pondering" as const, startTime } + } + + return null + }) + const [expanded, setExpanded] = createStore({ mcp: true, diff: true, @@ -97,6 +235,90 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {context()?.percentage ?? 0}% used {cost()} spent + 0 || inferenceStatus()}> + + + Running + + + {(status) => ( + + + + + ● + + + Sending... + + {" "} + {formatDuration( + Math.floor( + (tick() - (status() as { type: "sending"; startTime: number }).startTime) / 1000, + ), + )} + + + + + + + + ● + + + Pondering... + + {" "} + {formatDuration( + Math.floor( + (tick() - (status() as { type: "pondering"; startTime: number }).startTime) / 1000, + ), + )} + + + + + + + + ● + + + Streaming... + + {" "} + {formatDuration( + Math.floor( + (tick() - (status() as { type: "streaming"; startTime: number }).startTime) / 1000, + ), + )} + + + + + + + + ● + + + Retrying in {(status() as { type: "retry"; message: string; remaining: number }).remaining}s + + {" "} + ({(status() as { type: "retry"; message: string; remaining: number }).message}) + + + + + + )} + + + {(item) => } + + + 0}> ): string { + let cmd = "" + + switch (tool) { + case "bash": + cmd = (input.command as string) || "bash" + break + case "grep": + cmd = `rg "${input.pattern}"${input.path ? ` ${input.path}` : ""}` + break + case "glob": + cmd = `glob ${input.pattern}` + break + case "read": + cmd = `read ${(input.filePath as string)?.split("/").pop() || input.filePath}` + break + case "write": + cmd = `write ${(input.filePath as string)?.split("/").pop() || input.filePath}` + break + case "edit": + cmd = `edit ${(input.filePath as string)?.split("/").pop() || input.filePath}` + break + case "task": + cmd = `task: ${(input.description as string) || "..."}` + break + case "webfetch": + cmd = `fetch ${input.url}` + break + default: + cmd = (input.title as string) || tool + } + + return cmd.length > MAX_LEN ? cmd.slice(0, MAX_LEN - 3) + "..." : cmd +} diff --git a/packages/opencode/test/cli/tui/running.test.ts b/packages/opencode/test/cli/tui/running.test.ts new file mode 100644 index 00000000000..0685c3fbdb1 --- /dev/null +++ b/packages/opencode/test/cli/tui/running.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test" +import { extractToolCommand } from "../../../src/cli/cmd/tui/util/running" + +describe("extractToolCommand", () => { + test("extracts bash command", () => { + expect(extractToolCommand("bash", { command: "ls -la" })).toBe("ls -la") + }) + + test("falls back to 'bash' when command missing", () => { + expect(extractToolCommand("bash", {})).toBe("bash") + }) + + test("formats grep with pattern", () => { + expect(extractToolCommand("grep", { pattern: "foo" })).toBe('rg "foo"') + }) + + test("formats grep with pattern and path", () => { + expect(extractToolCommand("grep", { pattern: "foo", path: "src" })).toBe('rg "foo" src') + }) + + test("formats glob with pattern", () => { + expect(extractToolCommand("glob", { pattern: "**/*.ts" })).toBe("glob **/*.ts") + }) + + test("extracts filename for read", () => { + expect(extractToolCommand("read", { filePath: "/home/user/project/file.ts" })).toBe("read file.ts") + }) + + test("extracts filename for write", () => { + expect(extractToolCommand("write", { filePath: "/home/user/project/file.ts" })).toBe("write file.ts") + }) + + test("extracts filename for edit", () => { + expect(extractToolCommand("edit", { filePath: "/home/user/project/file.ts" })).toBe("edit file.ts") + }) + + test("formats task with description", () => { + expect(extractToolCommand("task", { description: "Search codebase" })).toBe("task: Search codebase") + }) + + test("falls back to '...' when task description missing", () => { + expect(extractToolCommand("task", {})).toBe("task: ...") + }) + + test("formats webfetch with url", () => { + expect(extractToolCommand("webfetch", { url: "https://example.com" })).toBe("fetch https://example.com") + }) + + test("uses title for unknown tool", () => { + expect(extractToolCommand("custom", { title: "Custom action" })).toBe("Custom action") + }) + + test("falls back to tool name when title missing", () => { + expect(extractToolCommand("custom", {})).toBe("custom") + }) + + test("truncates long commands", () => { + const long = "a".repeat(50) + const result = extractToolCommand("bash", { command: long }) + expect(result.length).toBe(40) + expect(result.endsWith("...")).toBe(true) + }) + + test("does not truncate commands at limit", () => { + const exact = "a".repeat(40) + expect(extractToolCommand("bash", { command: exact })).toBe(exact) + }) +}) From 164da0a758cb4a50a51affa40b1a97c6f8fffc71 Mon Sep 17 00:00:00 2001 From: becktor Date: Tue, 20 Jan 2026 18:45:33 +0100 Subject: [PATCH 2/7] feat(tui): show running tools and LLM status in sidebar Adds a Running section to the sidebar showing LLM status (Pondering, Streaming, etc.) and active tools/agents with elapsed time. Closes #9655 Related: #8322, #7093, #5872 --- .../cli/cmd/tui/routes/session/sidebar.tsx | 105 +----------- .../src/cli/cmd/tui/util/running-utils.ts | 44 ++++++ .../cmd/tui/util/{running.ts => running.tsx} | 149 +++++++++--------- .../opencode/test/cli/tui/running.test.ts | 2 +- 4 files changed, 126 insertions(+), 174 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/util/running-utils.ts rename packages/opencode/src/cli/cmd/tui/util/{running.ts => running.tsx} (59%) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index ffce5e788fd..bb3bd79e3f1 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -2,29 +2,16 @@ import { useSync } from "@tui/context/sync" import { createMemo, For, Show, Switch, Match } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "../../context/theme" -import { formatDuration } from "@/util/format" +import { Locale } from "@/util/locale" +import path from "path" import type { AssistantMessage } from "@opencode-ai/sdk/v2" +import { Global } from "@/global" import { Installation } from "@/installation" +import { useKeybind } from "../../context/keybind" import { useDirectory } from "../../context/directory" import { useKV } from "../../context/kv" import { TodoItem } from "../../component/todo-item" -import { createRunningState } from "../../util/running" - -function RunningToolItem(props: { command: string; startTime: number; now: number }) { - const { theme } = useTheme() - - return ( - - - ● - - - {props.command} - {formatDuration(Math.floor((props.now - props.startTime) / 1000))} - - - ) -} +import { createRunningState, RunningItemView } from "../../util/running.tsx" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() @@ -34,7 +21,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const todo = createMemo(() => sync.data.todo[props.sessionID] ?? []) const messages = createMemo(() => sync.data.message[props.sessionID] ?? []) - const { tick, runningTools, inferenceStatus } = createRunningState(() => props.sessionID, sync.data) + const { tick, runningItems } = createRunningState(() => props.sessionID, sync.data) const [expanded, setExpanded] = createStore({ mcp: true, @@ -113,88 +100,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {context()?.percentage ?? 0}% used {cost()} spent - 0 || inferenceStatus()}> + 0}> Running - - {(status) => ( - - - - - ● - - - Sending... - - {" "} - {formatDuration( - Math.floor( - (tick() - (status() as { type: "sending"; startTime: number }).startTime) / 1000, - ), - )} - - - - - - - - ● - - - Pondering... - - {" "} - {formatDuration( - Math.floor( - (tick() - (status() as { type: "pondering"; startTime: number }).startTime) / 1000, - ), - )} - - - - - - - - ● - - - Streaming... - - {" "} - {formatDuration( - Math.floor( - (tick() - (status() as { type: "streaming"; startTime: number }).startTime) / 1000, - ), - )} - - - - - - - - ● - - - Retrying in {(status() as { type: "retry"; message: string; remaining: number }).remaining}s - - {" "} - ({(status() as { type: "retry"; message: string; remaining: number }).message}) - - - - - - )} - - - {(item) => } - + {(item) => } 0}> diff --git a/packages/opencode/src/cli/cmd/tui/util/running-utils.ts b/packages/opencode/src/cli/cmd/tui/util/running-utils.ts new file mode 100644 index 00000000000..f503e877b28 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/running-utils.ts @@ -0,0 +1,44 @@ +const MAX_LEN = 40 +export const RUNNING_THRESHOLD_MS = 2000 + +export type RunningItem = { + id: string + label: string + startTime: number + suffix?: string +} + +export function extractToolCommand(tool: string, input: Record): string { + let cmd = "" + + switch (tool) { + case "bash": + cmd = (input.command as string) || "bash" + break + case "grep": + cmd = `rg "${input.pattern}"${input.path ? ` ${input.path}` : ""}` + break + case "glob": + cmd = `glob ${input.pattern}` + break + case "read": + cmd = `read ${(input.filePath as string)?.split("/").pop() || input.filePath}` + break + case "write": + cmd = `write ${(input.filePath as string)?.split("/").pop() || input.filePath}` + break + case "edit": + cmd = `edit ${(input.filePath as string)?.split("/").pop() || input.filePath}` + break + case "task": + cmd = `agent: ${(input.description as string) || "..."}` + break + case "webfetch": + cmd = `fetch ${input.url}` + break + default: + cmd = (input.title as string) || tool + } + + return cmd.length > MAX_LEN ? cmd.slice(0, MAX_LEN - 3) + "..." : cmd +} diff --git a/packages/opencode/src/cli/cmd/tui/util/running.ts b/packages/opencode/src/cli/cmd/tui/util/running.tsx similarity index 59% rename from packages/opencode/src/cli/cmd/tui/util/running.ts rename to packages/opencode/src/cli/cmd/tui/util/running.tsx index f6175751a34..41019cd7b6b 100644 --- a/packages/opencode/src/cli/cmd/tui/util/running.ts +++ b/packages/opencode/src/cli/cmd/tui/util/running.tsx @@ -1,58 +1,12 @@ -import { createSignal, createMemo, createEffect, onCleanup, type Accessor } from "solid-js" +import { createSignal, createMemo, createEffect, onCleanup, Show, type Accessor } from "solid-js" import type { Message, Part, SessionStatus, ToolPart, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" -import { formatDuration } from "@/util/format" - -const MAX_LEN = 40 -const RUNNING_THRESHOLD_MS = 2000 - -export function extractToolCommand(tool: string, input: Record): string { - let cmd = "" - - switch (tool) { - case "bash": - cmd = (input.command as string) || "bash" - break - case "grep": - cmd = `rg "${input.pattern}"${input.path ? ` ${input.path}` : ""}` - break - case "glob": - cmd = `glob ${input.pattern}` - break - case "read": - cmd = `read ${(input.filePath as string)?.split("/").pop() || input.filePath}` - break - case "write": - cmd = `write ${(input.filePath as string)?.split("/").pop() || input.filePath}` - break - case "edit": - cmd = `edit ${(input.filePath as string)?.split("/").pop() || input.filePath}` - break - case "task": - cmd = `agent: ${(input.description as string) || "..."}` - break - case "webfetch": - cmd = `fetch ${input.url}` - break - default: - cmd = (input.title as string) || tool - } - - return cmd.length > MAX_LEN ? cmd.slice(0, MAX_LEN - 3) + "..." : cmd -} - -export type RunningTool = { - id: string - tool: string - input: Record - command: string - startTime: number -} +import { formatDuration } from "../../../../util/format" +import { useTheme } from "../context/theme" +import { extractToolCommand, RUNNING_THRESHOLD_MS, type RunningItem } from "./running-utils" -export type InferenceStatus = - | { type: "sending"; startTime: number } - | { type: "pondering"; startTime: number } - | { type: "streaming"; startTime: number } - | { type: "retry"; message: string; remaining: number } +// Re-export for convenience +export type { RunningItem } from "./running-utils" +export { extractToolCommand } from "./running-utils" type SyncData = { session_status: Record @@ -60,6 +14,26 @@ type SyncData = { part: Record } +export function RunningItemView(props: { item: RunningItem; now: number }) { + const { theme } = useTheme() + const elapsed = () => formatDuration(Math.floor((props.now - props.item.startTime) / 1000)) + + return ( + + + ● + + + {props.item.label} + {elapsed()} + + ({props.item.suffix}) + + + + ) +} + export function createRunningState(sessionID: Accessor, data: SyncData) { const [tick, setTick] = createSignal(Date.now()) @@ -91,7 +65,7 @@ export function createRunningState(sessionID: Accessor, data: SyncData) const runningTools = createMemo(() => { const now = tick() - const tools: RunningTool[] = [] + const tools: { id: string; tool: string; input: Record; startTime: number }[] = [] for (const message of messages()) { const parts = data.part[message.id] ?? [] @@ -105,7 +79,6 @@ export function createRunningState(sessionID: Accessor, data: SyncData) id: toolPart.id, tool: toolPart.tool, input: toolPart.state.input, - command: "", // filled in below startTime, }) } @@ -114,29 +87,22 @@ export function createRunningState(sessionID: Accessor, data: SyncData) } } - // Sort by start time and number agents - const sorted = tools.sort((a, b) => a.startTime - b.startTime) - let agentIndex = 0 - for (const tool of sorted) { - if (tool.tool === "task") { - agentIndex++ - const desc = (tool.input.description as string) || "..." - tool.command = `agent${agentIndex}: ${desc}` - } else { - tool.command = extractToolCommand(tool.tool, tool.input) - } - } - return sorted + return tools.sort((a, b) => a.startTime - b.startTime) }) - const inferenceStatus = createMemo((): InferenceStatus | null => { + const inferenceStatus = createMemo((): RunningItem | null => { const now = tick() const status = sessionStatus() // Handle retry state if (status.type === "retry") { const remaining = Math.max(0, Math.ceil((status.next - now) / 1000)) - return { type: "retry", message: status.message, remaining } + return { + id: "inference", + label: `Retrying in ${remaining}s`, + startTime: now, + suffix: status.message, + } } // Not busy = no inference happening @@ -151,7 +117,7 @@ export function createRunningState(sessionID: Accessor, data: SyncData) // No messages or last is user message = sending request if (!lastMsg || lastMsg.role === "user") { - return { type: "sending", startTime } + return { id: "inference", label: "Sending...", startTime } } // Have assistant message - check parts @@ -159,7 +125,7 @@ export function createRunningState(sessionID: Accessor, data: SyncData) // No parts yet = pondering (waiting for first token) if (parts.length === 0) { - return { type: "pondering", startTime } + return { id: "inference", label: "Pondering...", startTime } } // Check for active text/reasoning streaming (ignore tool parts) @@ -172,22 +138,53 @@ export function createRunningState(sessionID: Accessor, data: SyncData) const isComplete = lastTextOrReasoning.time?.end !== undefined if (!hasContent) { - return { type: "pondering", startTime } + return { id: "inference", label: "Pondering...", startTime } } if (!isComplete) { const streamStartTime = lastTextOrReasoning.time?.start ?? startTime - return { type: "streaming", startTime: streamStartTime } + return { id: "inference", label: "Streaming...", startTime: streamStartTime } } } // Between tool calls or other intermediate state = pondering if (!lastMsg.time.completed) { - return { type: "pondering", startTime } + return { id: "inference", label: "Pondering...", startTime } } return null }) - return { tick, runningTools, inferenceStatus } + const runningItems = createMemo((): RunningItem[] => { + const items: RunningItem[] = [] + + // Add inference status first (always on top) + const inference = inferenceStatus() + if (inference) { + items.push(inference) + } + + // Add running tools with agent numbering + const tools = runningTools() + let agentIndex = 0 + for (const tool of tools) { + let label: string + if (tool.tool === "task") { + agentIndex++ + const desc = (tool.input.description as string) || "..." + label = `agent${agentIndex}: ${desc}` + } else { + label = extractToolCommand(tool.tool, tool.input) + } + items.push({ + id: tool.id, + label, + startTime: tool.startTime, + }) + } + + return items + }) + + return { tick, runningItems } } diff --git a/packages/opencode/test/cli/tui/running.test.ts b/packages/opencode/test/cli/tui/running.test.ts index 9fc8c48e32a..1e2d54f7f76 100644 --- a/packages/opencode/test/cli/tui/running.test.ts +++ b/packages/opencode/test/cli/tui/running.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { extractToolCommand } from "../../../src/cli/cmd/tui/util/running" +import { extractToolCommand } from "../../../src/cli/cmd/tui/util/running-utils" describe("extractToolCommand", () => { test("extracts bash command", () => { From 2faac3124c63c7d6e418164099f1d171bbe31f42 Mon Sep 17 00:00:00 2001 From: becktor Date: Tue, 20 Jan 2026 18:52:56 +0100 Subject: [PATCH 3/7] refactor(tui): split running state into tools and LLM modules - Extract running-tools.tsx for tool tracking - Extract running-llm.tsx for LLM status - Make extractToolCommand generic with pattern-based fallback - Add tests for generic tool command extraction --- .../cli/cmd/tui/routes/session/sidebar.tsx | 9 +- .../src/cli/cmd/tui/util/running-llm.tsx | 93 ++++++++++ .../src/cli/cmd/tui/util/running-tools.tsx | 75 ++++++++ .../src/cli/cmd/tui/util/running-utils.ts | 55 +++--- .../opencode/src/cli/cmd/tui/util/running.tsx | 170 ++---------------- .../opencode/test/cli/tui/running.test.ts | 68 ++++--- 6 files changed, 257 insertions(+), 213 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/util/running-llm.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/util/running-tools.tsx diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index bb3bd79e3f1..a328721d31f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -11,7 +11,7 @@ import { useKeybind } from "../../context/keybind" import { useDirectory } from "../../context/directory" import { useKV } from "../../context/kv" import { TodoItem } from "../../component/todo-item" -import { createRunningState, RunningItemView } from "../../util/running.tsx" +import { createRunningState, ToolItemView, LLMStatusView } from "../../util/running.tsx" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() @@ -21,7 +21,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const todo = createMemo(() => sync.data.todo[props.sessionID] ?? []) const messages = createMemo(() => sync.data.message[props.sessionID] ?? []) - const { tick, runningItems } = createRunningState(() => props.sessionID, sync.data) + const { tick, tools, llmStatus } = createRunningState(() => props.sessionID, sync.data) const [expanded, setExpanded] = createStore({ mcp: true, @@ -100,12 +100,13 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {context()?.percentage ?? 0}% used {cost()} spent - 0}> + 0 || llmStatus()}> Running - {(item) => } + {(status) => } + {(item) => } 0}> diff --git a/packages/opencode/src/cli/cmd/tui/util/running-llm.tsx b/packages/opencode/src/cli/cmd/tui/util/running-llm.tsx new file mode 100644 index 00000000000..f7c4a65c300 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/running-llm.tsx @@ -0,0 +1,93 @@ +import { createMemo, Show, type Accessor } from "solid-js" +import type { Message, Part, SessionStatus, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" +import { formatDuration } from "../../../../util/format" +import { useTheme } from "../context/theme" +import { RUNNING_THRESHOLD_MS, type RunningItem } from "./running-utils" + +type LLMData = { + session_status: Record + message: Record + part: Record +} + +export function createLLMStatus( + sessionID: Accessor, + data: LLMData, + tick: Accessor, + thinkingStartTime: Accessor, +): Accessor { + const sessionStatus = createMemo(() => data.session_status?.[sessionID()] ?? { type: "idle" as const }) + const messages = createMemo(() => data.message[sessionID()] ?? []) + + return createMemo((): RunningItem | null => { + const now = tick() + const status = sessionStatus() + + // Handle retry state + if (status.type === "retry") { + const remaining = Math.max(0, Math.ceil((status.next - now) / 1000)) + return { + id: "llm-status", + label: `Retrying in ${remaining}s`, + startTime: now, + suffix: status.message, + } + } + + if (status.type !== "busy") return null + + const startTime = thinkingStartTime() + if (!startTime || now - startTime < RUNNING_THRESHOLD_MS) return null + + const lastMsg = messages().at(-1) + if (!lastMsg || lastMsg.role === "user") { + return { id: "llm-status", label: "Sending...", startTime } + } + + const parts = data.part[lastMsg.id] ?? [] + if (parts.length === 0) { + return { id: "llm-status", label: "Pondering...", startTime } + } + + const lastTextOrReasoning = [...parts] + .reverse() + .find((p): p is TextPart | ReasoningPart => p.type === "text" || p.type === "reasoning") + + if (lastTextOrReasoning) { + const hasContent = lastTextOrReasoning.text?.length > 0 + const isComplete = lastTextOrReasoning.time?.end !== undefined + + if (!hasContent) return { id: "llm-status", label: "Pondering...", startTime } + if (!isComplete) { + const streamStartTime = lastTextOrReasoning.time?.start ?? startTime + return { id: "llm-status", label: "Streaming...", startTime: streamStartTime } + } + } + + if (!lastMsg.time.completed) { + return { id: "llm-status", label: "Pondering...", startTime } + } + + return null + }) +} + +export function LLMStatusView(props: { item: RunningItem; now: number }) { + const { theme } = useTheme() + const elapsed = () => formatDuration(Math.floor((props.now - props.item.startTime) / 1000)) + + return ( + + + ● + + + {props.item.label} + {elapsed()} + + ({props.item.suffix}) + + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/util/running-tools.tsx b/packages/opencode/src/cli/cmd/tui/util/running-tools.tsx new file mode 100644 index 00000000000..3cdb5d4239c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/running-tools.tsx @@ -0,0 +1,75 @@ +import { createMemo, type Accessor } from "solid-js" +import type { Part, ToolPart } from "@opencode-ai/sdk/v2" +import { formatDuration } from "../../../../util/format" +import { useTheme } from "../context/theme" +import { extractToolCommand, RUNNING_THRESHOLD_MS, type RunningItem } from "./running-utils" + +type ToolsData = { + message: Record + part: Record +} + +export function createRunningTools( + sessionID: Accessor, + data: ToolsData, + tick: Accessor, +): Accessor { + const messages = createMemo(() => data.message[sessionID()] ?? []) + + return createMemo(() => { + const now = tick() + const tools: { id: string; tool: string; input: Record; startTime: number }[] = [] + + for (const message of messages()) { + const parts = data.part[message.id] ?? [] + for (const part of parts) { + if (part.type === "tool") { + const toolPart = part as ToolPart + if (toolPart.state.status === "running") { + const startTime = toolPart.state.time.start + if (now - startTime >= RUNNING_THRESHOLD_MS) { + tools.push({ + id: toolPart.id, + tool: toolPart.tool, + input: toolPart.state.input, + startTime, + }) + } + } + } + } + } + + // Sort and number agents + const sorted = tools.sort((a, b) => a.startTime - b.startTime) + let agentIndex = 0 + return sorted.map((tool) => { + let label: string + if (tool.tool === "task") { + agentIndex++ + const desc = (tool.input.description as string) || "..." + label = `agent${agentIndex}: ${desc}` + } else { + label = extractToolCommand(tool.tool, tool.input) + } + return { id: tool.id, label, startTime: tool.startTime } + }) + }) +} + +export function ToolItemView(props: { item: RunningItem; now: number }) { + const { theme } = useTheme() + const elapsed = () => formatDuration(Math.floor((props.now - props.item.startTime) / 1000)) + + return ( + + + ● + + + {props.item.label} + {elapsed()} + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/util/running-utils.ts b/packages/opencode/src/cli/cmd/tui/util/running-utils.ts index f503e877b28..695d31f2417 100644 --- a/packages/opencode/src/cli/cmd/tui/util/running-utils.ts +++ b/packages/opencode/src/cli/cmd/tui/util/running-utils.ts @@ -8,37 +8,32 @@ export type RunningItem = { suffix?: string } +function truncate(str: string, max: number): string { + return str.length > max ? str.slice(0, max - 3) + "..." : str +} + +function basename(filepath: unknown): string { + return (filepath as string)?.split("/").pop() || (filepath as string) || "" +} + +// Overrides for tools that need custom formatting +const TOOL_OVERRIDES: Record) => string> = { + grep: (input) => `rg "${input.pattern}"${input.path ? ` ${input.path}` : ""}`, + task: (input) => `agent: ${(input.description as string) || "..."}`, +} + export function extractToolCommand(tool: string, input: Record): string { - let cmd = "" + // Check for override first + const override = TOOL_OVERRIDES[tool] + if (override) return truncate(override(input), MAX_LEN) - switch (tool) { - case "bash": - cmd = (input.command as string) || "bash" - break - case "grep": - cmd = `rg "${input.pattern}"${input.path ? ` ${input.path}` : ""}` - break - case "glob": - cmd = `glob ${input.pattern}` - break - case "read": - cmd = `read ${(input.filePath as string)?.split("/").pop() || input.filePath}` - break - case "write": - cmd = `write ${(input.filePath as string)?.split("/").pop() || input.filePath}` - break - case "edit": - cmd = `edit ${(input.filePath as string)?.split("/").pop() || input.filePath}` - break - case "task": - cmd = `agent: ${(input.description as string) || "..."}` - break - case "webfetch": - cmd = `fetch ${input.url}` - break - default: - cmd = (input.title as string) || tool - } + // Pattern-based fallback for common input fields + if (input.command) return truncate(input.command as string, MAX_LEN) + if (input.filePath) return truncate(`${tool} ${basename(input.filePath)}`, MAX_LEN) + if (input.pattern) return truncate(`${tool} ${input.pattern}`, MAX_LEN) + if (input.url) return truncate(`${tool} ${input.url}`, MAX_LEN) + if (input.title) return truncate(input.title as string, MAX_LEN) + if (input.description) return truncate(`${tool}: ${input.description}`, MAX_LEN) - return cmd.length > MAX_LEN ? cmd.slice(0, MAX_LEN - 3) + "..." : cmd + return tool } diff --git a/packages/opencode/src/cli/cmd/tui/util/running.tsx b/packages/opencode/src/cli/cmd/tui/util/running.tsx index 41019cd7b6b..bc82647121a 100644 --- a/packages/opencode/src/cli/cmd/tui/util/running.tsx +++ b/packages/opencode/src/cli/cmd/tui/util/running.tsx @@ -1,12 +1,12 @@ -import { createSignal, createMemo, createEffect, onCleanup, Show, type Accessor } from "solid-js" -import type { Message, Part, SessionStatus, ToolPart, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" -import { formatDuration } from "../../../../util/format" -import { useTheme } from "../context/theme" -import { extractToolCommand, RUNNING_THRESHOLD_MS, type RunningItem } from "./running-utils" +import { createSignal, createMemo, createEffect, onCleanup, type Accessor } from "solid-js" +import type { Message, Part, SessionStatus } from "@opencode-ai/sdk/v2" +import { createRunningTools, ToolItemView } from "./running-tools.tsx" +import { createLLMStatus, LLMStatusView } from "./running-llm.tsx" -// Re-export for convenience -export type { RunningItem } from "./running-utils" -export { extractToolCommand } from "./running-utils" +// Re-exports +export { extractToolCommand, type RunningItem } from "./running-utils" +export { ToolItemView } from "./running-tools.tsx" +export { LLMStatusView } from "./running-llm.tsx" type SyncData = { session_status: Record @@ -14,33 +14,11 @@ type SyncData = { part: Record } -export function RunningItemView(props: { item: RunningItem; now: number }) { - const { theme } = useTheme() - const elapsed = () => formatDuration(Math.floor((props.now - props.item.startTime) / 1000)) - - return ( - - - ● - - - {props.item.label} - {elapsed()} - - ({props.item.suffix}) - - - - ) -} - export function createRunningState(sessionID: Accessor, data: SyncData) { const [tick, setTick] = createSignal(Date.now()) + const [thinkingStartTime, setThinkingStartTime] = createSignal(null) const sessionStatus = createMemo(() => data.session_status?.[sessionID()] ?? { type: "idle" as const }) - const messages = createMemo(() => data.message[sessionID()] ?? []) - - const [thinkingStartTime, setThinkingStartTime] = createSignal(null) // Only tick when session is busy or retrying createEffect(() => { @@ -55,136 +33,14 @@ export function createRunningState(sessionID: Accessor, data: SyncData) createEffect(() => { const status = sessionStatus() if (status.type === "busy") { - if (thinkingStartTime() === null) { - setThinkingStartTime(Date.now()) - } + if (thinkingStartTime() === null) setThinkingStartTime(Date.now()) } else { setThinkingStartTime(null) } }) - const runningTools = createMemo(() => { - const now = tick() - const tools: { id: string; tool: string; input: Record; startTime: number }[] = [] - - for (const message of messages()) { - const parts = data.part[message.id] ?? [] - for (const part of parts) { - if (part.type === "tool") { - const toolPart = part as ToolPart - if (toolPart.state.status === "running") { - const startTime = toolPart.state.time.start - if (now - startTime >= RUNNING_THRESHOLD_MS) { - tools.push({ - id: toolPart.id, - tool: toolPart.tool, - input: toolPart.state.input, - startTime, - }) - } - } - } - } - } - - return tools.sort((a, b) => a.startTime - b.startTime) - }) - - const inferenceStatus = createMemo((): RunningItem | null => { - const now = tick() - const status = sessionStatus() - - // Handle retry state - if (status.type === "retry") { - const remaining = Math.max(0, Math.ceil((status.next - now) / 1000)) - return { - id: "inference", - label: `Retrying in ${remaining}s`, - startTime: now, - suffix: status.message, - } - } - - // Not busy = no inference happening - if (status.type !== "busy") return null - - // Check threshold - const startTime = thinkingStartTime() - if (!startTime || now - startTime < RUNNING_THRESHOLD_MS) return null - - const sessionMessages = messages() - const lastMsg = sessionMessages.at(-1) - - // No messages or last is user message = sending request - if (!lastMsg || lastMsg.role === "user") { - return { id: "inference", label: "Sending...", startTime } - } - - // Have assistant message - check parts - const parts = data.part[lastMsg.id] ?? [] - - // No parts yet = pondering (waiting for first token) - if (parts.length === 0) { - return { id: "inference", label: "Pondering...", startTime } - } - - // Check for active text/reasoning streaming (ignore tool parts) - const lastTextOrReasoning = [...parts] - .reverse() - .find((p): p is TextPart | ReasoningPart => p.type === "text" || p.type === "reasoning") - - if (lastTextOrReasoning) { - const hasContent = lastTextOrReasoning.text?.length > 0 - const isComplete = lastTextOrReasoning.time?.end !== undefined - - if (!hasContent) { - return { id: "inference", label: "Pondering...", startTime } - } - - if (!isComplete) { - const streamStartTime = lastTextOrReasoning.time?.start ?? startTime - return { id: "inference", label: "Streaming...", startTime: streamStartTime } - } - } - - // Between tool calls or other intermediate state = pondering - if (!lastMsg.time.completed) { - return { id: "inference", label: "Pondering...", startTime } - } - - return null - }) - - const runningItems = createMemo((): RunningItem[] => { - const items: RunningItem[] = [] - - // Add inference status first (always on top) - const inference = inferenceStatus() - if (inference) { - items.push(inference) - } - - // Add running tools with agent numbering - const tools = runningTools() - let agentIndex = 0 - for (const tool of tools) { - let label: string - if (tool.tool === "task") { - agentIndex++ - const desc = (tool.input.description as string) || "..." - label = `agent${agentIndex}: ${desc}` - } else { - label = extractToolCommand(tool.tool, tool.input) - } - items.push({ - id: tool.id, - label, - startTime: tool.startTime, - }) - } - - return items - }) + const tools = createRunningTools(sessionID, data, tick) + const llmStatus = createLLMStatus(sessionID, data, tick, thinkingStartTime) - return { tick, runningItems } + return { tick, tools, llmStatus } } diff --git a/packages/opencode/test/cli/tui/running.test.ts b/packages/opencode/test/cli/tui/running.test.ts index 1e2d54f7f76..8c33ae026e0 100644 --- a/packages/opencode/test/cli/tui/running.test.ts +++ b/packages/opencode/test/cli/tui/running.test.ts @@ -2,14 +2,7 @@ import { describe, expect, test } from "bun:test" import { extractToolCommand } from "../../../src/cli/cmd/tui/util/running-utils" describe("extractToolCommand", () => { - test("extracts bash command", () => { - expect(extractToolCommand("bash", { command: "ls -la" })).toBe("ls -la") - }) - - test("falls back to 'bash' when command missing", () => { - expect(extractToolCommand("bash", {})).toBe("bash") - }) - + // Override cases (grep, task have specific formatting) test("formats grep with pattern", () => { expect(extractToolCommand("grep", { pattern: "foo" })).toBe('rg "foo"') }) @@ -18,42 +11,73 @@ describe("extractToolCommand", () => { expect(extractToolCommand("grep", { pattern: "foo", path: "src" })).toBe('rg "foo" src') }) - test("formats glob with pattern", () => { - expect(extractToolCommand("glob", { pattern: "**/*.ts" })).toBe("glob **/*.ts") + test("formats task with description", () => { + expect(extractToolCommand("task", { description: "Search codebase" })).toBe("agent: Search codebase") }) - test("extracts filename for read", () => { + test("falls back to '...' when task description missing", () => { + expect(extractToolCommand("task", {})).toBe("agent: ...") + }) + + // Pattern-based fallbacks (generic behavior) + test("extracts command field", () => { + expect(extractToolCommand("bash", { command: "ls -la" })).toBe("ls -la") + }) + + test("extracts filePath with tool name prefix", () => { expect(extractToolCommand("read", { filePath: "/home/user/project/file.ts" })).toBe("read file.ts") }) - test("extracts filename for write", () => { + test("extracts filePath for write", () => { expect(extractToolCommand("write", { filePath: "/home/user/project/file.ts" })).toBe("write file.ts") }) - test("extracts filename for edit", () => { + test("extracts filePath for edit", () => { expect(extractToolCommand("edit", { filePath: "/home/user/project/file.ts" })).toBe("edit file.ts") }) - test("formats task with description", () => { - expect(extractToolCommand("task", { description: "Search codebase" })).toBe("agent: Search codebase") + test("extracts pattern with tool name prefix", () => { + expect(extractToolCommand("glob", { pattern: "**/*.ts" })).toBe("glob **/*.ts") }) - test("falls back to '...' when task description missing", () => { - expect(extractToolCommand("task", {})).toBe("agent: ...") + test("extracts url with tool name prefix", () => { + expect(extractToolCommand("webfetch", { url: "https://example.com" })).toBe("webfetch https://example.com") }) - test("formats webfetch with url", () => { - expect(extractToolCommand("webfetch", { url: "https://example.com" })).toBe("fetch https://example.com") + test("extracts title field", () => { + expect(extractToolCommand("custom", { title: "Custom action" })).toBe("Custom action") }) - test("uses title for unknown tool", () => { - expect(extractToolCommand("custom", { title: "Custom action" })).toBe("Custom action") + test("extracts description with tool name prefix", () => { + expect(extractToolCommand("custom", { description: "Do something" })).toBe("custom: Do something") }) - test("falls back to tool name when title missing", () => { + test("falls back to tool name when no known fields", () => { expect(extractToolCommand("custom", {})).toBe("custom") }) + test("falls back to tool name for bash with no command", () => { + expect(extractToolCommand("bash", {})).toBe("bash") + }) + + // Generic unknown tools + test("handles unknown tool with command", () => { + expect(extractToolCommand("mcp_tool", { command: "do something" })).toBe("do something") + }) + + test("handles unknown tool with filePath", () => { + expect(extractToolCommand("mcp_tool", { filePath: "/path/to/file.txt" })).toBe("mcp_tool file.txt") + }) + + test("handles unknown tool with pattern", () => { + expect(extractToolCommand("mcp_tool", { pattern: "*.ts" })).toBe("mcp_tool *.ts") + }) + + test("handles unknown tool with url", () => { + expect(extractToolCommand("mcp_tool", { url: "https://example.com" })).toBe("mcp_tool https://example.com") + }) + + // Truncation test("truncates long commands", () => { const long = "a".repeat(50) const result = extractToolCommand("bash", { command: long }) From 28383c14b2a778f488ea04bd84ce94ed68c53a1b Mon Sep 17 00:00:00 2001 From: becktor Date: Tue, 20 Jan 2026 19:08:56 +0100 Subject: [PATCH 4/7] refactor(tui): use flatMap for running tools collection Replace nested loops with functional flatMap/filter/map chain for cleaner code --- .../src/cli/cmd/tui/util/running-tools.tsx | 54 ++++++++----------- 1 file changed, 22 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/util/running-tools.tsx b/packages/opencode/src/cli/cmd/tui/util/running-tools.tsx index 3cdb5d4239c..04ff7847f42 100644 --- a/packages/opencode/src/cli/cmd/tui/util/running-tools.tsx +++ b/packages/opencode/src/cli/cmd/tui/util/running-tools.tsx @@ -1,5 +1,5 @@ import { createMemo, type Accessor } from "solid-js" -import type { Part, ToolPart } from "@opencode-ai/sdk/v2" +import type { Part, ToolPart, ToolStateRunning } from "@opencode-ai/sdk/v2" import { formatDuration } from "../../../../util/format" import { useTheme } from "../context/theme" import { extractToolCommand, RUNNING_THRESHOLD_MS, type RunningItem } from "./running-utils" @@ -9,6 +9,12 @@ type ToolsData = { part: Record } +type RunningToolPart = ToolPart & { state: ToolStateRunning } + +function isRunningTool(part: Part, now: number): part is RunningToolPart { + return part.type === "tool" && part.state.status === "running" && now - part.state.time.start >= RUNNING_THRESHOLD_MS +} + export function createRunningTools( sessionID: Accessor, data: ToolsData, @@ -18,40 +24,24 @@ export function createRunningTools( return createMemo(() => { const now = tick() - const tools: { id: string; tool: string; input: Record; startTime: number }[] = [] - for (const message of messages()) { - const parts = data.part[message.id] ?? [] - for (const part of parts) { - if (part.type === "tool") { - const toolPart = part as ToolPart - if (toolPart.state.status === "running") { - const startTime = toolPart.state.time.start - if (now - startTime >= RUNNING_THRESHOLD_MS) { - tools.push({ - id: toolPart.id, - tool: toolPart.tool, - input: toolPart.state.input, - startTime, - }) - } - } - } - } - } + const tools = messages() + .flatMap((msg) => data.part[msg.id] ?? []) + .filter((part): part is RunningToolPart => isRunningTool(part, now)) + .map((part) => ({ + id: part.id, + tool: part.tool, + input: part.state.input, + startTime: part.state.time.start, + })) + .sort((a, b) => a.startTime - b.startTime) - // Sort and number agents - const sorted = tools.sort((a, b) => a.startTime - b.startTime) let agentIndex = 0 - return sorted.map((tool) => { - let label: string - if (tool.tool === "task") { - agentIndex++ - const desc = (tool.input.description as string) || "..." - label = `agent${agentIndex}: ${desc}` - } else { - label = extractToolCommand(tool.tool, tool.input) - } + return tools.map((tool) => { + const label = + tool.tool === "task" + ? `agent${++agentIndex}: ${(tool.input.description as string) || "..."}` + : extractToolCommand(tool.tool, tool.input) return { id: tool.id, label, startTime: tool.startTime } }) }) From e31c5416e707fb6fdca2136dc068e4c3d0e0a41e Mon Sep 17 00:00:00 2001 From: becktor Date: Tue, 20 Jan 2026 19:17:42 +0100 Subject: [PATCH 5/7] feat(session): add explicit LLM status phases Replace inferred LLM status with explicit server-side phases: - sending: waiting for LLM response - reasoning: receiving reasoning tokens - streaming: receiving text output This simplifies running-llm.tsx from inference logic to direct status lookup --- .../cmd/tui/component/dialog-session-list.tsx | 2 +- .../src/cli/cmd/tui/util/running-llm.tsx | 51 ++++--------------- .../opencode/src/cli/cmd/tui/util/running.tsx | 16 +++--- packages/opencode/src/session/processor.ts | 6 ++- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/session/status.ts | 12 +++-- packages/sdk/js/src/v2/gen/types.gen.ts | 12 +++-- 7 files changed, 45 insertions(+), 56 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 85c174c1dcb..3b3cf78f65d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -49,7 +49,7 @@ export function DialogSessionList() { } const isDeleting = toDelete() === x.id const status = sync.data.session_status?.[x.id] - const isWorking = status?.type === "busy" + const isWorking = status?.type && status.type !== "idle" return { title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, bg: isDeleting ? theme.error : undefined, diff --git a/packages/opencode/src/cli/cmd/tui/util/running-llm.tsx b/packages/opencode/src/cli/cmd/tui/util/running-llm.tsx index f7c4a65c300..f6e1392ee9f 100644 --- a/packages/opencode/src/cli/cmd/tui/util/running-llm.tsx +++ b/packages/opencode/src/cli/cmd/tui/util/running-llm.tsx @@ -1,13 +1,17 @@ import { createMemo, Show, type Accessor } from "solid-js" -import type { Message, Part, SessionStatus, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" +import type { SessionStatus } from "@opencode-ai/sdk/v2" import { formatDuration } from "../../../../util/format" import { useTheme } from "../context/theme" import { RUNNING_THRESHOLD_MS, type RunningItem } from "./running-utils" type LLMData = { session_status: Record - message: Record - part: Record +} + +const STATUS_LABELS: Record = { + sending: "Sending...", + reasoning: "Reasoning...", + streaming: "Streaming...", } export function createLLMStatus( @@ -17,56 +21,21 @@ export function createLLMStatus( thinkingStartTime: Accessor, ): Accessor { const sessionStatus = createMemo(() => data.session_status?.[sessionID()] ?? { type: "idle" as const }) - const messages = createMemo(() => data.message[sessionID()] ?? []) return createMemo((): RunningItem | null => { const now = tick() const status = sessionStatus() - // Handle retry state if (status.type === "retry") { const remaining = Math.max(0, Math.ceil((status.next - now) / 1000)) - return { - id: "llm-status", - label: `Retrying in ${remaining}s`, - startTime: now, - suffix: status.message, - } + return { id: "llm-status", label: `Retrying in ${remaining}s`, startTime: now, suffix: status.message } } - if (status.type !== "busy") return null - const startTime = thinkingStartTime() if (!startTime || now - startTime < RUNNING_THRESHOLD_MS) return null - const lastMsg = messages().at(-1) - if (!lastMsg || lastMsg.role === "user") { - return { id: "llm-status", label: "Sending...", startTime } - } - - const parts = data.part[lastMsg.id] ?? [] - if (parts.length === 0) { - return { id: "llm-status", label: "Pondering...", startTime } - } - - const lastTextOrReasoning = [...parts] - .reverse() - .find((p): p is TextPart | ReasoningPart => p.type === "text" || p.type === "reasoning") - - if (lastTextOrReasoning) { - const hasContent = lastTextOrReasoning.text?.length > 0 - const isComplete = lastTextOrReasoning.time?.end !== undefined - - if (!hasContent) return { id: "llm-status", label: "Pondering...", startTime } - if (!isComplete) { - const streamStartTime = lastTextOrReasoning.time?.start ?? startTime - return { id: "llm-status", label: "Streaming...", startTime: streamStartTime } - } - } - - if (!lastMsg.time.completed) { - return { id: "llm-status", label: "Pondering...", startTime } - } + const label = STATUS_LABELS[status.type] + if (label) return { id: "llm-status", label, startTime } return null }) diff --git a/packages/opencode/src/cli/cmd/tui/util/running.tsx b/packages/opencode/src/cli/cmd/tui/util/running.tsx index bc82647121a..e89fb2d35de 100644 --- a/packages/opencode/src/cli/cmd/tui/util/running.tsx +++ b/packages/opencode/src/cli/cmd/tui/util/running.tsx @@ -14,25 +14,29 @@ type SyncData = { part: Record } +const ACTIVE_STATUSES = new Set(["sending", "reasoning", "streaming", "retry"]) + +function isActive(status: SessionStatus): boolean { + return ACTIVE_STATUSES.has(status.type) +} + export function createRunningState(sessionID: Accessor, data: SyncData) { const [tick, setTick] = createSignal(Date.now()) const [thinkingStartTime, setThinkingStartTime] = createSignal(null) const sessionStatus = createMemo(() => data.session_status?.[sessionID()] ?? { type: "idle" as const }) - // Only tick when session is busy or retrying + // Only tick when session is active createEffect(() => { - const status = sessionStatus() - if (status.type === "busy" || status.type === "retry") { + if (isActive(sessionStatus())) { const interval = setInterval(() => setTick(Date.now()), 1000) onCleanup(() => clearInterval(interval)) } }) - // Track when thinking started + // Track when activity started createEffect(() => { - const status = sessionStatus() - if (status.type === "busy") { + if (isActive(sessionStatus())) { if (thinkingStartTime() === null) setThinkingStartTime(Date.now()) } else { setThinkingStartTime(null) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 71db7f13677..1d93e6d3265 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -56,13 +56,14 @@ export namespace SessionProcessor { input.abort.throwIfAborted() switch (value.type) { case "start": - SessionStatus.set(input.sessionID, { type: "busy" }) + SessionStatus.set(input.sessionID, { type: "sending" }) break case "reasoning-start": if (value.id in reasoningMap) { continue } + SessionStatus.set(input.sessionID, { type: "reasoning" }) reasoningMap[value.id] = { id: Identifier.ascending("part"), messageID: input.assistantMessage.id, @@ -97,6 +98,7 @@ export namespace SessionProcessor { if (value.providerMetadata) part.metadata = value.providerMetadata await Session.updatePart(part) delete reasoningMap[value.id] + SessionStatus.set(input.sessionID, { type: "sending" }) } break @@ -277,6 +279,7 @@ export namespace SessionProcessor { break case "text-start": + SessionStatus.set(input.sessionID, { type: "streaming" }) currentText = { id: Identifier.ascending("part"), messageID: input.assistantMessage.id, @@ -321,6 +324,7 @@ export namespace SessionProcessor { } if (value.providerMetadata) currentText.metadata = value.providerMetadata await Session.updatePart(currentText) + SessionStatus.set(input.sessionID, { type: "sending" }) } currentText = undefined break diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9dbca30d8b3..bc533613df0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -269,7 +269,7 @@ export namespace SessionPrompt { let step = 0 const session = await Session.get(sessionID) while (true) { - SessionStatus.set(sessionID, { type: "busy" }) + SessionStatus.set(sessionID, { type: "sending" }) log.info("loop", { step, sessionID }) if (abort.aborted) break let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 1db03b5db0d..0438603b8bd 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -9,15 +9,21 @@ export namespace SessionStatus { z.object({ type: z.literal("idle"), }), + z.object({ + type: z.literal("sending"), + }), + z.object({ + type: z.literal("reasoning"), + }), + z.object({ + type: z.literal("streaming"), + }), z.object({ type: z.literal("retry"), attempt: z.number(), message: z.string(), next: z.number(), }), - z.object({ - type: z.literal("busy"), - }), ]) .meta({ ref: "SessionStatus", diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 8442889020f..a3f6f14b659 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -518,15 +518,21 @@ export type SessionStatus = | { type: "idle" } + | { + type: "sending" + } + | { + type: "reasoning" + } + | { + type: "streaming" + } | { type: "retry" attempt: number message: string next: number } - | { - type: "busy" - } export type EventSessionStatus = { type: "session.status" From ba5e4cc1031b5858f38d9783040283ddc76dbba4 Mon Sep 17 00:00:00 2001 From: becktor Date: Tue, 20 Jan 2026 20:05:56 +0100 Subject: [PATCH 6/7] fix(tui): stabilize running tools display with store reconciliation --- .../cli/cmd/tui/routes/session/sidebar.tsx | 2 +- .../src/cli/cmd/tui/util/running-tools.tsx | 50 ++++++++++++------- .../src/cli/cmd/tui/util/running-utils.ts | 2 +- packages/opencode/src/session/processor.ts | 2 - packages/opencode/src/session/prompt.ts | 4 +- 5 files changed, 36 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index a328721d31f..01b3ef582a0 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -101,7 +101,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {cost()} spent 0 || llmStatus()}> - + Running diff --git a/packages/opencode/src/cli/cmd/tui/util/running-tools.tsx b/packages/opencode/src/cli/cmd/tui/util/running-tools.tsx index 04ff7847f42..6cc8def64de 100644 --- a/packages/opencode/src/cli/cmd/tui/util/running-tools.tsx +++ b/packages/opencode/src/cli/cmd/tui/util/running-tools.tsx @@ -1,4 +1,5 @@ -import { createMemo, type Accessor } from "solid-js" +import { createMemo, createEffect, type Accessor } from "solid-js" +import { createStore, reconcile } from "solid-js/store" import type { Part, ToolPart, ToolStateRunning } from "@opencode-ai/sdk/v2" import { formatDuration } from "../../../../util/format" import { useTheme } from "../context/theme" @@ -11,8 +12,23 @@ type ToolsData = { type RunningToolPart = ToolPart & { state: ToolStateRunning } +function hasStartedWork(part: RunningToolPart): boolean { + if (part.tool !== "task") return true + const metadata = part.state.metadata as { summary?: unknown[] } | undefined + return !!metadata?.summary?.length +} + function isRunningTool(part: Part, now: number): part is RunningToolPart { - return part.type === "tool" && part.state.status === "running" && now - part.state.time.start >= RUNNING_THRESHOLD_MS + if (part.type !== "tool" || part.state.status !== "running") return false + const running = part as RunningToolPart + if (!hasStartedWork(running)) return false + return now - running.state.time.start >= RUNNING_THRESHOLD_MS +} + +function getLabel(part: RunningToolPart, agentIndex: number): string { + return part.tool === "task" + ? `agent${agentIndex}: ${(part.state.input.description as string) || "..."}` + : extractToolCommand(part.tool, part.state.input) } export function createRunningTools( @@ -20,31 +36,27 @@ export function createRunningTools( data: ToolsData, tick: Accessor, ): Accessor { + const [items, setItems] = createStore([]) const messages = createMemo(() => data.message[sessionID()] ?? []) - return createMemo(() => { + createEffect(() => { const now = tick() - const tools = messages() + const running = messages() .flatMap((msg) => data.part[msg.id] ?? []) .filter((part): part is RunningToolPart => isRunningTool(part, now)) - .map((part) => ({ - id: part.id, - tool: part.tool, - input: part.state.input, - startTime: part.state.time.start, - })) - .sort((a, b) => a.startTime - b.startTime) + .sort((a, b) => a.state.time.start - b.state.time.start) + + const newItems = running.map((part, i) => ({ + id: part.id, + label: getLabel(part, i + 1), + startTime: part.state.time.start, + })) - let agentIndex = 0 - return tools.map((tool) => { - const label = - tool.tool === "task" - ? `agent${++agentIndex}: ${(tool.input.description as string) || "..."}` - : extractToolCommand(tool.tool, tool.input) - return { id: tool.id, label, startTime: tool.startTime } - }) + setItems(reconcile(newItems, { key: "id" })) }) + + return () => items } export function ToolItemView(props: { item: RunningItem; now: number }) { diff --git a/packages/opencode/src/cli/cmd/tui/util/running-utils.ts b/packages/opencode/src/cli/cmd/tui/util/running-utils.ts index 695d31f2417..9f0c596108d 100644 --- a/packages/opencode/src/cli/cmd/tui/util/running-utils.ts +++ b/packages/opencode/src/cli/cmd/tui/util/running-utils.ts @@ -1,5 +1,5 @@ const MAX_LEN = 40 -export const RUNNING_THRESHOLD_MS = 2000 +export const RUNNING_THRESHOLD_MS = 1000 export type RunningItem = { id: string diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 1d93e6d3265..90a6c17081f 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -98,7 +98,6 @@ export namespace SessionProcessor { if (value.providerMetadata) part.metadata = value.providerMetadata await Session.updatePart(part) delete reasoningMap[value.id] - SessionStatus.set(input.sessionID, { type: "sending" }) } break @@ -324,7 +323,6 @@ export namespace SessionProcessor { } if (value.providerMetadata) currentText.metadata = value.providerMetadata await Session.updatePart(currentText) - SessionStatus.set(input.sessionID, { type: "sending" }) } currentText = undefined break diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index bc533613df0..61463acdc06 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -269,7 +269,9 @@ export namespace SessionPrompt { let step = 0 const session = await Session.get(sessionID) while (true) { - SessionStatus.set(sessionID, { type: "sending" }) + if (SessionStatus.get(sessionID).type === "idle") { + SessionStatus.set(sessionID, { type: "sending" }) + } log.info("loop", { step, sessionID }) if (abort.aborted) break let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) From 53e26a6c18cd115864b046960b39e3a3c6a525e8 Mon Sep 17 00:00:00 2001 From: becktor Date: Tue, 20 Jan 2026 20:36:19 +0100 Subject: [PATCH 7/7] feat(session): add planning status and improve type safety - Add 'planning' status emitted on tool-input-start event - Add 'waiting' status emitted on tool-call event - Add TaskMetadata and str() helper for type-safe metadata access - Remove all 'as any' casts from running tools code --- .../src/cli/cmd/tui/util/running-llm.tsx | 2 + .../src/cli/cmd/tui/util/running-tools.tsx | 12 ++-- .../src/cli/cmd/tui/util/running-utils.ts | 18 ++++-- .../opencode/src/cli/cmd/tui/util/running.tsx | 2 +- packages/opencode/src/session/processor.ts | 2 + packages/opencode/src/session/prompt.ts | 4 +- packages/opencode/src/session/status.ts | 6 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 6 ++ packages/sdk/openapi.json | 60 +++++++++++++++---- 9 files changed, 88 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/util/running-llm.tsx b/packages/opencode/src/cli/cmd/tui/util/running-llm.tsx index f6e1392ee9f..8c7470ed608 100644 --- a/packages/opencode/src/cli/cmd/tui/util/running-llm.tsx +++ b/packages/opencode/src/cli/cmd/tui/util/running-llm.tsx @@ -10,8 +10,10 @@ type LLMData = { const STATUS_LABELS: Record = { sending: "Sending...", + planning: "Planning...", reasoning: "Reasoning...", streaming: "Streaming...", + waiting: "Waiting...", } export function createLLMStatus( diff --git a/packages/opencode/src/cli/cmd/tui/util/running-tools.tsx b/packages/opencode/src/cli/cmd/tui/util/running-tools.tsx index 6cc8def64de..48a9fbc0f48 100644 --- a/packages/opencode/src/cli/cmd/tui/util/running-tools.tsx +++ b/packages/opencode/src/cli/cmd/tui/util/running-tools.tsx @@ -3,7 +3,7 @@ import { createStore, reconcile } from "solid-js/store" import type { Part, ToolPart, ToolStateRunning } from "@opencode-ai/sdk/v2" import { formatDuration } from "../../../../util/format" import { useTheme } from "../context/theme" -import { extractToolCommand, RUNNING_THRESHOLD_MS, type RunningItem } from "./running-utils" +import { extractToolCommand, RUNNING_THRESHOLD_MS, str, type RunningItem, type TaskMetadata } from "./running-utils" type ToolsData = { message: Record @@ -12,22 +12,22 @@ type ToolsData = { type RunningToolPart = ToolPart & { state: ToolStateRunning } -function hasStartedWork(part: RunningToolPart): boolean { +function hasRunningSubtasks(part: RunningToolPart): boolean { if (part.tool !== "task") return true - const metadata = part.state.metadata as { summary?: unknown[] } | undefined - return !!metadata?.summary?.length + const metadata = part.state.metadata as TaskMetadata | undefined + return metadata?.summary?.some((t) => t.state?.status === "running") ?? false } function isRunningTool(part: Part, now: number): part is RunningToolPart { if (part.type !== "tool" || part.state.status !== "running") return false const running = part as RunningToolPart - if (!hasStartedWork(running)) return false + if (!hasRunningSubtasks(running)) return false return now - running.state.time.start >= RUNNING_THRESHOLD_MS } function getLabel(part: RunningToolPart, agentIndex: number): string { return part.tool === "task" - ? `agent${agentIndex}: ${(part.state.input.description as string) || "..."}` + ? `agent${agentIndex}: ${str(part.state.input.description) || "..."}` : extractToolCommand(part.tool, part.state.input) } diff --git a/packages/opencode/src/cli/cmd/tui/util/running-utils.ts b/packages/opencode/src/cli/cmd/tui/util/running-utils.ts index 9f0c596108d..ef0eac81e43 100644 --- a/packages/opencode/src/cli/cmd/tui/util/running-utils.ts +++ b/packages/opencode/src/cli/cmd/tui/util/running-utils.ts @@ -8,18 +8,28 @@ export type RunningItem = { suffix?: string } +// Task tool metadata types (for subagent tracking) +export type TaskSummaryItem = { state?: { status?: string } } +export type TaskMetadata = { summary?: TaskSummaryItem[] } + +// Helper to safely coerce unknown to string +export function str(value: unknown): string { + return typeof value === "string" ? value : "" +} + function truncate(str: string, max: number): string { return str.length > max ? str.slice(0, max - 3) + "..." : str } function basename(filepath: unknown): string { - return (filepath as string)?.split("/").pop() || (filepath as string) || "" + const path = str(filepath) + return path.split("/").pop() || path } // Overrides for tools that need custom formatting const TOOL_OVERRIDES: Record) => string> = { grep: (input) => `rg "${input.pattern}"${input.path ? ` ${input.path}` : ""}`, - task: (input) => `agent: ${(input.description as string) || "..."}`, + task: (input) => `agent: ${str(input.description) || "..."}`, } export function extractToolCommand(tool: string, input: Record): string { @@ -28,11 +38,11 @@ export function extractToolCommand(tool: string, input: Record) if (override) return truncate(override(input), MAX_LEN) // Pattern-based fallback for common input fields - if (input.command) return truncate(input.command as string, MAX_LEN) + if (input.command) return truncate(str(input.command), MAX_LEN) if (input.filePath) return truncate(`${tool} ${basename(input.filePath)}`, MAX_LEN) if (input.pattern) return truncate(`${tool} ${input.pattern}`, MAX_LEN) if (input.url) return truncate(`${tool} ${input.url}`, MAX_LEN) - if (input.title) return truncate(input.title as string, MAX_LEN) + if (input.title) return truncate(str(input.title), MAX_LEN) if (input.description) return truncate(`${tool}: ${input.description}`, MAX_LEN) return tool diff --git a/packages/opencode/src/cli/cmd/tui/util/running.tsx b/packages/opencode/src/cli/cmd/tui/util/running.tsx index e89fb2d35de..d15a33fa214 100644 --- a/packages/opencode/src/cli/cmd/tui/util/running.tsx +++ b/packages/opencode/src/cli/cmd/tui/util/running.tsx @@ -14,7 +14,7 @@ type SyncData = { part: Record } -const ACTIVE_STATUSES = new Set(["sending", "reasoning", "streaming", "retry"]) +const ACTIVE_STATUSES = new Set(["sending", "planning", "reasoning", "streaming", "waiting", "retry"]) function isActive(status: SessionStatus): boolean { return ACTIVE_STATUSES.has(status.type) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 90a6c17081f..84b957e618a 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -102,6 +102,7 @@ export namespace SessionProcessor { break case "tool-input-start": + SessionStatus.set(input.sessionID, { type: "planning" }) const part = await Session.updatePart({ id: toolcalls[value.id]?.id ?? Identifier.ascending("part"), messageID: input.assistantMessage.id, @@ -127,6 +128,7 @@ export namespace SessionProcessor { case "tool-call": { const match = toolcalls[value.toolCallId] if (match) { + SessionStatus.set(input.sessionID, { type: "waiting" }) const part = await Session.updatePart({ ...match, tool: value.toolName, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 61463acdc06..bc533613df0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -269,9 +269,7 @@ export namespace SessionPrompt { let step = 0 const session = await Session.get(sessionID) while (true) { - if (SessionStatus.get(sessionID).type === "idle") { - SessionStatus.set(sessionID, { type: "sending" }) - } + SessionStatus.set(sessionID, { type: "sending" }) log.info("loop", { step, sessionID }) if (abort.aborted) break let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 0438603b8bd..4cabf658fd7 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -12,12 +12,18 @@ export namespace SessionStatus { z.object({ type: z.literal("sending"), }), + z.object({ + type: z.literal("planning"), + }), z.object({ type: z.literal("reasoning"), }), z.object({ type: z.literal("streaming"), }), + z.object({ + type: z.literal("waiting"), + }), z.object({ type: z.literal("retry"), attempt: z.number(), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a3f6f14b659..59069a414da 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -521,12 +521,18 @@ export type SessionStatus = | { type: "sending" } + | { + type: "planning" + } | { type: "reasoning" } | { type: "streaming" } + | { + type: "waiting" + } | { type: "retry" attempt: number diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 14008f32307..89b555ce982 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7224,6 +7224,56 @@ }, "required": ["type"] }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sending" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "planning" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "reasoning" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "streaming" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "waiting" + } + }, + "required": ["type"] + }, { "type": "object", "properties": { @@ -7242,16 +7292,6 @@ } }, "required": ["type", "attempt", "message", "next"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "busy" - } - }, - "required": ["type"] } ] },