summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax <[email protected]>2026-03-01 12:46:10 -0500
committerGitHub <[email protected]>2026-03-01 17:46:10 +0000
commit90270c615d47c3c6b47c3ef0f551e9e57664f2c4 (patch)
treed46f5edb4fa290db8f42116343b826319ca10c03
parent6b7e6bde4d88b97ca99525f1bc40c33e48ba97b4 (diff)
downloadopencode-90270c615d47c3c6b47c3ef0f551e9e57664f2c4.tar.gz
opencode-90270c615d47c3c6b47c3ef0f551e9e57664f2c4.zip
feat(tui): improve task tool display with subagent keybind hints and spinner animations (#15607)
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/index.tsx126
1 files changed, 75 insertions, 51 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 314018367..68f6796cd 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -7,6 +7,7 @@ import {
For,
Match,
on,
+ onMount,
Show,
Switch,
useContext,
@@ -1323,6 +1324,8 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
return props.message.time.completed - user.time.created
})
+ const keybind = useKeybind()
+
return (
<>
<For each={props.parts}>
@@ -1340,6 +1343,14 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
)
}}
</For>
+ <Show when={props.parts.some((x) => x.type === "tool" && x.tool === "task")}>
+ <box paddingTop={1} paddingLeft={3}>
+ <text fg={theme.text}>
+ {keybind.print("session_child_first")}
+ <span style={{ fg: theme.textMuted }}> view subagents</span>
+ </text>
+ </box>
+ </Show>
<Show when={props.message.error && props.message.error.name !== "MessageAbortedError"}>
<box
border={["left"]}
@@ -1609,6 +1620,7 @@ function InlineTool(props: {
iconColor?: RGBA
complete: any
pending: string
+ spinner?: boolean
children: JSX.Element
part: ToolPart
}) {
@@ -1665,11 +1677,18 @@ function InlineTool(props: {
}
}}
>
- <text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
- <Show fallback={<>~ {props.pending}</>} when={props.complete}>
- <span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
- </Show>
- </text>
+ <Switch>
+ <Match when={props.spinner}>
+ <Spinner color={fg()} children={props.children} />
+ </Match>
+ <Match when={true}>
+ <text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
+ <Show fallback={<>~ {props.pending}</>} when={props.complete}>
+ <span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
+ </Show>
+ </text>
+ </Match>
+ </Switch>
<Show when={error() && !denied()}>
<text fg={theme.error}>{error()}</text>
</Show>
@@ -1836,6 +1855,7 @@ function Glob(props: ToolProps<typeof GlobTool>) {
function Read(props: ToolProps<typeof ReadTool>) {
const { theme } = useTheme()
+ const isRunning = createMemo(() => props.part.state.status === "running")
const loaded = createMemo(() => {
if (props.part.state.status !== "completed") return []
if (props.part.state.time.compacted) return []
@@ -1845,7 +1865,13 @@ function Read(props: ToolProps<typeof ReadTool>) {
})
return (
<>
- <InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
+ <InlineTool
+ icon="→"
+ pending="Reading file..."
+ complete={props.input.filePath}
+ spinner={isRunning()}
+ part={props.part}
+ >
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
</InlineTool>
<For each={loaded()}>
@@ -1921,62 +1947,60 @@ function Task(props: ToolProps<typeof TaskTool>) {
const local = useLocal()
const sync = useSync()
+ onMount(() => {
+ if (props.metadata.sessionId && !sync.data.message[props.metadata.sessionId]?.length)
+ sync.session.sync(props.metadata.sessionId)
+ })
+
+ const messages = createMemo(() => sync.data.message[props.metadata.sessionId ?? ""] ?? [])
+
const tools = createMemo(() => {
- const sessionID = props.metadata.sessionId
- const msgs = sync.data.message[sessionID ?? ""] ?? []
- return msgs.flatMap((msg) =>
+ return messages().flatMap((msg) =>
(sync.data.part[msg.id] ?? [])
.filter((part): part is ToolPart => part.type === "tool")
.map((part) => ({ tool: part.tool, state: part.state })),
)
})
- const current = createMemo(() => tools().findLast((x) => x.state.status !== "pending"))
+ const current = createMemo(() => tools().findLast((x) => (x.state as any).title))
const isRunning = createMemo(() => props.part.state.status === "running")
+ const duration = createMemo(() => {
+ const first = messages().find((x) => x.role === "user")?.time.created
+ const assistant = messages().findLast((x) => x.role === "assistant")?.time.completed
+ if (!first || !assistant) return 0
+ return assistant - first
+ })
+
return (
- <Switch>
- <Match when={props.input.description || props.input.subagent_type}>
- <BlockTool
- title={"# " + Locale.titlecase(props.input.subagent_type ?? "unknown") + " Task"}
- onClick={
- props.metadata.sessionId
- ? () => navigate({ type: "session", sessionID: props.metadata.sessionId! })
- : undefined
- }
- part={props.part}
- spinner={isRunning()}
- >
- <box>
- <text style={{ fg: theme.textMuted }}>
- {props.input.description} ({tools().length} toolcalls)
- </text>
- <Show when={current()}>
- {(item) => {
- const title = item().state.status === "completed" ? (item().state as any).title : ""
- return (
- <text style={{ fg: item().state.status === "error" ? theme.error : theme.textMuted }}>
- └ {Locale.titlecase(item().tool)} {title}
- </text>
- )
- }}
- </Show>
- </box>
- <Show when={props.metadata.sessionId}>
- <text fg={theme.text}>
- {keybind.print("session_child_first")}
- <span style={{ fg: theme.textMuted }}> view subagents</span>
- </text>
- </Show>
- </BlockTool>
- </Match>
- <Match when={true}>
- <InlineTool icon="#" pending="Delegating..." complete={props.input.subagent_type} part={props.part}>
- {props.input.subagent_type} Task {props.input.description}
- </InlineTool>
- </Match>
- </Switch>
+ <InlineTool
+ icon="≡"
+ spinner={isRunning()}
+ complete={props.input.description}
+ pending="Delegating..."
+ part={props.part}
+ >
+ {props.input.description}
+ <Show when={isRunning() && tools().length > 0}>
+ {" "}
+ · {tools().length} toolcalls
+ <Show fallback={"\n└ Running..."} when={current()}>
+ {(item) => {
+ const title = createMemo(() => (item().state as any).title)
+ return (
+ <>
+ {"\n"}└ {Locale.titlecase(item().tool)} {title()}
+ </>
+ )
+ }}
+ </Show>
+ </Show>
+ <Show when={duration() && props.part.state.status === "completed"}>
+ {"\n "}
+ {tools().length} toolcalls · {Locale.duration(duration())}
+ </Show>
+ </InlineTool>
)
}