Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand Down Expand Up @@ -97,6 +100,15 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
<text fg={theme.textMuted}>{cost()} spent</text>
</box>
<Show when={tools().length > 0 || llmStatus()}>
<box flexDirection="column">
<text fg={theme.text}>
<b>Running</b>
</text>
<Show when={llmStatus()}>{(status) => <LLMStatusView item={status()} now={tick()} />}</Show>
<For each={tools()}>{(item) => <ToolItemView item={item} now={tick()} />}</For>
</box>
</Show>
<Show when={mcpEntries().length > 0}>
<box>
<box
Expand Down
64 changes: 64 additions & 0 deletions packages/opencode/src/cli/cmd/tui/util/running-llm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { createMemo, Show, type Accessor } from "solid-js"
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<string, SessionStatus>
}

const STATUS_LABELS: Record<string, string> = {
sending: "Sending...",
planning: "Planning...",
reasoning: "Reasoning...",
streaming: "Streaming...",
waiting: "Waiting...",
}

export function createLLMStatus(
sessionID: Accessor<string>,
data: LLMData,
tick: Accessor<number>,
thinkingStartTime: Accessor<number | null>,
): Accessor<RunningItem | null> {
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 (
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={theme.warning}>
</text>
<text fg={theme.text} wrapMode="none">
{props.item.label}
<span style={{ fg: theme.textMuted }}> {elapsed()}</span>
<Show when={props.item.suffix}>
<span style={{ fg: theme.textMuted }}> ({props.item.suffix})</span>
</Show>
</text>
</box>
)
}
77 changes: 77 additions & 0 deletions packages/opencode/src/cli/cmd/tui/util/running-tools.tsx
Original file line number Diff line number Diff line change
@@ -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<string, { id: string }[]>
part: Record<string, Part[]>
}

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<string>,
data: ToolsData,
tick: Accessor<number>,
): Accessor<RunningItem[]> {
const [items, setItems] = createStore<RunningItem[]>([])
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 (
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={theme.warning}>
</text>
<text fg={theme.text} wrapMode="none">
{props.item.label}
<span style={{ fg: theme.textMuted }}> {elapsed()}</span>
</text>
</box>
)
}
49 changes: 49 additions & 0 deletions packages/opencode/src/cli/cmd/tui/util/running-utils.ts
Original file line number Diff line number Diff line change
@@ -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, (input: Record<string, unknown>) => string> = {
grep: (input) => `rg "${input.pattern}"${input.path ? ` ${input.path}` : ""}`,
task: (input) => `agent: ${str(input.description) || "..."}`,
}

export function extractToolCommand(tool: string, input: Record<string, unknown>): 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
}
50 changes: 50 additions & 0 deletions packages/opencode/src/cli/cmd/tui/util/running.tsx
Original file line number Diff line number Diff line change
@@ -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<string, SessionStatus>
message: Record<string, Message[]>
part: Record<string, Part[]>
}

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<string>, data: SyncData) {
const [tick, setTick] = createSignal(Date.now())
const [thinkingStartTime, setThinkingStartTime] = createSignal<number | null>(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 }
}
6 changes: 5 additions & 1 deletion packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
18 changes: 15 additions & 3 deletions packages/opencode/src/session/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading