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"]
}
]
},