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/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index ebc7514d723..01b3ef582a0 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -11,6 +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, ToolItemView, LLMStatusView } from "../../util/running.tsx" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() @@ -20,6 +21,8 @@ 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, tools, llmStatus } = createRunningState(() => props.sessionID, sync.data) + const [expanded, setExpanded] = createStore({ mcp: true, diff: true, @@ -97,6 +100,15 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {context()?.percentage ?? 0}% used {cost()} spent + 0 || llmStatus()}> + + + Running + + {(status) => } + {(item) => } + + 0}> +} + +const STATUS_LABELS: Record = { + sending: "Sending...", + planning: "Planning...", + reasoning: "Reasoning...", + streaming: "Streaming...", + waiting: "Waiting...", +} + +export function createLLMStatus( + sessionID: Accessor, + data: LLMData, + tick: Accessor, + thinkingStartTime: Accessor, +): Accessor { + const sessionStatus = createMemo(() => data.session_status?.[sessionID()] ?? { type: "idle" as const }) + + return createMemo((): RunningItem | null => { + const now = tick() + const status = sessionStatus() + + 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 } + } + + const startTime = thinkingStartTime() + if (!startTime || now - startTime < RUNNING_THRESHOLD_MS) return null + + const label = STATUS_LABELS[status.type] + if (label) return { id: "llm-status", label, 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..48a9fbc0f48 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/running-tools.tsx @@ -0,0 +1,77 @@ +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" +import { extractToolCommand, RUNNING_THRESHOLD_MS, str, type RunningItem, type TaskMetadata } from "./running-utils" + +type ToolsData = { + message: Record + part: Record +} + +type RunningToolPart = ToolPart & { state: ToolStateRunning } + +function hasRunningSubtasks(part: RunningToolPart): boolean { + if (part.tool !== "task") return true + 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 (!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}: ${str(part.state.input.description) || "..."}` + : extractToolCommand(part.tool, part.state.input) +} + +export function createRunningTools( + sessionID: Accessor, + data: ToolsData, + tick: Accessor, +): Accessor { + const [items, setItems] = createStore([]) + const messages = createMemo(() => data.message[sessionID()] ?? []) + + createEffect(() => { + const now = tick() + + const running = messages() + .flatMap((msg) => data.part[msg.id] ?? []) + .filter((part): part is RunningToolPart => isRunningTool(part, now)) + .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, + })) + + setItems(reconcile(newItems, { key: "id" })) + }) + + return () => items +} + +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 new file mode 100644 index 00000000000..ef0eac81e43 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/running-utils.ts @@ -0,0 +1,49 @@ +const MAX_LEN = 40 +export const RUNNING_THRESHOLD_MS = 1000 + +export type RunningItem = { + id: string + label: string + startTime: number + 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 { + 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: ${str(input.description) || "..."}`, +} + +export function extractToolCommand(tool: string, input: Record): string { + // Check for override first + const override = TOOL_OVERRIDES[tool] + if (override) return truncate(override(input), MAX_LEN) + + // Pattern-based fallback for common input fields + 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(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 new file mode 100644 index 00000000000..d15a33fa214 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/running.tsx @@ -0,0 +1,50 @@ +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-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 + message: Record + part: Record +} + +const ACTIVE_STATUSES = new Set(["sending", "planning", "reasoning", "streaming", "waiting", "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 active + createEffect(() => { + if (isActive(sessionStatus())) { + const interval = setInterval(() => setTick(Date.now()), 1000) + onCleanup(() => clearInterval(interval)) + } + }) + + // Track when activity started + createEffect(() => { + if (isActive(sessionStatus())) { + if (thinkingStartTime() === null) setThinkingStartTime(Date.now()) + } else { + setThinkingStartTime(null) + } + }) + + const tools = createRunningTools(sessionID, data, tick) + const llmStatus = createLLMStatus(sessionID, data, tick, thinkingStartTime) + + return { tick, tools, llmStatus } +} diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 71db7f13677..84b957e618a 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, @@ -101,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, @@ -126,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, @@ -277,6 +280,7 @@ export namespace SessionProcessor { break case "text-start": + SessionStatus.set(input.sessionID, { type: "streaming" }) currentText = { id: Identifier.ascending("part"), messageID: input.assistantMessage.id, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9325583acf7..1a20caa90e0 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..4cabf658fd7 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -9,15 +9,27 @@ export namespace SessionStatus { z.object({ type: z.literal("idle"), }), + 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(), message: z.string(), next: z.number(), }), - z.object({ - type: z.literal("busy"), - }), ]) .meta({ ref: "SessionStatus", 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..8c33ae026e0 --- /dev/null +++ b/packages/opencode/test/cli/tui/running.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test" +import { extractToolCommand } from "../../../src/cli/cmd/tui/util/running-utils" + +describe("extractToolCommand", () => { + // Override cases (grep, task have specific formatting) + 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 task with description", () => { + expect(extractToolCommand("task", { description: "Search codebase" })).toBe("agent: Search codebase") + }) + + 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 filePath for write", () => { + expect(extractToolCommand("write", { filePath: "/home/user/project/file.ts" })).toBe("write file.ts") + }) + + test("extracts filePath for edit", () => { + expect(extractToolCommand("edit", { filePath: "/home/user/project/file.ts" })).toBe("edit file.ts") + }) + + test("extracts pattern with tool name prefix", () => { + expect(extractToolCommand("glob", { pattern: "**/*.ts" })).toBe("glob **/*.ts") + }) + + test("extracts url with tool name prefix", () => { + expect(extractToolCommand("webfetch", { url: "https://example.com" })).toBe("webfetch https://example.com") + }) + + test("extracts title field", () => { + 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 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 }) + 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) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 8442889020f..59069a414da 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -518,15 +518,27 @@ export type SessionStatus = | { type: "idle" } + | { + type: "sending" + } + | { + type: "planning" + } + | { + type: "reasoning" + } + | { + type: "streaming" + } + | { + type: "waiting" + } | { type: "retry" attempt: number message: string next: number } - | { - type: "busy" - } export type EventSessionStatus = { type: "session.status" 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"] } ] },