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 70fcd421cd..c52934c399 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1789,6 +1789,7 @@ function InlineTool(props: { complete: any pending: string spinner?: boolean + subagent?: boolean children: JSX.Element part: ToolPart onClick?: () => void @@ -1829,6 +1830,7 @@ function InlineTool(props: { return ( sync.data.message[ctx.sessionID]?.some((message) => message.role === "user" && message.id === id) ?? false } @@ -1860,6 +1863,7 @@ function InlineTool(props: { } export function InlineToolRow(props: { + id?: string icon: string iconColor?: RGBA color?: RGBA @@ -1871,6 +1875,7 @@ export function InlineToolRow(props: { complete: any pending: string spinner?: boolean + subagent?: boolean children: JSX.Element separateAfter?: (id: string | undefined) => boolean onMouseOver?: () => void @@ -1881,6 +1886,7 @@ export function InlineToolRow(props: { return ( ) { Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])} - {(filepath) => ( - + {(filepath, index) => ( + ↳ Loaded {pathFormatter.format(filepath)} @@ -2190,11 +2199,14 @@ function Task(props: ToolProps) { tools().findLast((x) => (x.state.status === "running" || x.state.status === "completed") && x.state.title), ) - const isRunning = createMemo(() => props.part.state.status === "running") + const status = createMemo(() => sync.data.session_status[props.metadata.sessionId ?? ""]) + const isRunning = createMemo( + () => props.part.state.status === "running" || (props.metadata.background === true && status() !== undefined), + ) const retry = createMemo(() => { - const status = sync.data.session_status[props.metadata.sessionId ?? ""] - if (status?.type !== "retry") return - return status + const value = status() + if (value?.type !== "retry") return + return value }) const duration = createMemo(() => { @@ -2214,20 +2226,15 @@ function Task(props: ToolProps) { if (isRunning() && retrying) { content.push(`↳ ${Locale.truncate(retrying.message, 80)} [retrying attempt #${retrying.attempt}]`) } else if (isRunning() && tools().length > 0) { - // content[0] += ` · ${tools().length} toolcalls` if (current()) { const state = current()!.state const title = state.status === "running" || state.status === "completed" ? state.title : undefined content.push(`↳ ${Locale.titlecase(current()!.tool)} ${title}`) - } else content.push(`↳ ${tools().length} toolcalls`) + } else content.push(`↳ ${formatSubagentToolcalls(tools().length)}`) } - if (props.part.state.status === "completed") { - content.push( - props.metadata.background === true - ? `└ ${tools().length} toolcalls` - : `└ ${tools().length} toolcalls · ${Locale.duration(duration())}`, - ) + if (!isRunning() && props.part.state.status === "completed") { + content.push(`↳ ${formatCompletedSubagentDetail(tools().length, Locale.duration(duration()))}`) } return content.join("\n") @@ -2235,7 +2242,8 @@ function Task(props: ToolProps) { return ( ) { ) } +export function formatSubagentToolcalls(count: number) { + return `${count} toolcall${count === 1 ? "" : "s"}` +} + +export function formatCompletedSubagentDetail(toolcalls: number, duration: string) { + if (toolcalls === 0) return duration + return `${formatSubagentToolcalls(toolcalls)} · ${duration}` +} + function Edit(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() diff --git a/packages/opencode/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap b/packages/opencode/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap index 9e317dae88..348035dcaf 100644 --- a/packages/opencode/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap +++ b/packages/opencode/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap @@ -52,3 +52,21 @@ exports[`TUI inline tool wrapping keeps separation after a padded user message 1 ✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\. Path\\.data|data =" in packages/opencode/src (115 matches)" `; + +exports[`TUI inline tool wrapping separates a contiguous subagent group from inline tools 1`] = ` +" ✱ Grep "Task" (2 matches) + + ⠙ Explore Task — Inspect active task spacing + ✓ General Task — Confirm completed task spacing + ↳ 1 toolcall · 501ms + + → Read src/cli/cmd/tui/routes/session/index.tsx" +`; + +exports[`TUI inline tool wrapping separates a subagent group after an expanded read 1`] = ` +" → Read src/cli/cmd/tui/routes/session/index.tsx + ↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx + + ✓ Explore Task — Inspect active task spacing + ↳ 1 toolcall · 501ms" +`; diff --git a/packages/opencode/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/opencode/test/cli/tui/inline-tool-wrap-snapshot.test.tsx index cab39bdc23..3589d513d3 100644 --- a/packages/opencode/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +++ b/packages/opencode/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -1,7 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test" import { For } from "solid-js" import { testRender, type JSX } from "@opentui/solid" -import { InlineToolRow } from "../../../src/cli/cmd/tui/routes/session/index" +import { + formatCompletedSubagentDetail, + formatSubagentToolcalls, + InlineToolRow, +} from "../../../src/cli/cmd/tui/routes/session/index" let testSetup: Awaited> | undefined @@ -86,6 +90,41 @@ function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" }) ) } +function SubagentGroupFixture() { + return ( + + + Grep "Task" (2 matches) + + + Explore Task — Inspect active task spacing + + + {"General Task — Confirm completed task spacing\n↳ 1 toolcall · 501ms"} + + + Read src/cli/cmd/tui/routes/session/index.tsx + + + ) +} + +function LoadedReadBeforeSubagentFixture() { + return ( + + + Read src/cli/cmd/tui/routes/session/index.tsx + + + ↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx + + + {"Explore Task — Inspect active task spacing\n↳ 1 toolcall · 501ms"} + + + ) +} + async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) { testSetup = await testRender(component, options) await testSetup.renderOnce() @@ -101,6 +140,13 @@ async function renderFrame(component: () => JSX.Element, options: { width: numbe } describe("TUI inline tool wrapping", () => { + test("formats completed subagent toolcall details", () => { + expect(formatCompletedSubagentDetail(0, "501ms")).toBe("501ms") + expect(formatCompletedSubagentDetail(1, "501ms")).toBe("1 toolcall · 501ms") + expect(formatCompletedSubagentDetail(2, "501ms")).toBe("2 toolcalls · 501ms") + expect(formatSubagentToolcalls(0)).toBe("0 toolcalls") + }) + test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => { expect(await renderFrame(() => , { width: 72, height: 12 })).toMatchSnapshot() }) @@ -116,4 +162,12 @@ describe("TUI inline tool wrapping", () => { test("keeps separation after a padded user message", async () => { expect(await renderFrame(() => , { width: 72, height: 14 })).toMatchSnapshot() }) + + test("separates a contiguous subagent group from inline tools", async () => { + expect(await renderFrame(() => , { width: 72, height: 10 })).toMatchSnapshot() + }) + + test("separates a subagent group after an expanded read", async () => { + expect(await renderFrame(() => , { width: 72, height: 8 })).toMatchSnapshot() + }) })