Bläddra i källkod

fix(tui): clarify inline subagent rows (#30051)

Kit Langton 3 månader sedan
förälder
incheckning
5f2b4eab28

+ 32 - 15
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -1789,6 +1789,7 @@ function InlineTool(props: {
   complete: any
   complete: any
   pending: string
   pending: string
   spinner?: boolean
   spinner?: boolean
+  subagent?: boolean
   children: JSX.Element
   children: JSX.Element
   part: ToolPart
   part: ToolPart
   onClick?: () => void
   onClick?: () => void
@@ -1829,6 +1830,7 @@ function InlineTool(props: {
 
 
   return (
   return (
     <InlineToolRow
     <InlineToolRow
+      id={`tool-inline-${props.subagent ? "subagent-" : ""}${props.part.id}`}
       icon={props.icon}
       icon={props.icon}
       iconColor={props.iconColor}
       iconColor={props.iconColor}
       color={fg()}
       color={fg()}
@@ -1840,6 +1842,7 @@ function InlineTool(props: {
       complete={props.complete}
       complete={props.complete}
       pending={props.pending}
       pending={props.pending}
       spinner={props.spinner}
       spinner={props.spinner}
+      subagent={props.subagent}
       separateAfter={(id) =>
       separateAfter={(id) =>
         sync.data.message[ctx.sessionID]?.some((message) => message.role === "user" && message.id === id) ?? false
         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: {
 export function InlineToolRow(props: {
+  id?: string
   icon: string
   icon: string
   iconColor?: RGBA
   iconColor?: RGBA
   color?: RGBA
   color?: RGBA
@@ -1871,6 +1875,7 @@ export function InlineToolRow(props: {
   complete: any
   complete: any
   pending: string
   pending: string
   spinner?: boolean
   spinner?: boolean
+  subagent?: boolean
   children: JSX.Element
   children: JSX.Element
   separateAfter?: (id: string | undefined) => boolean
   separateAfter?: (id: string | undefined) => boolean
   onMouseOver?: () => void
   onMouseOver?: () => void
@@ -1881,6 +1886,7 @@ export function InlineToolRow(props: {
 
 
   return (
   return (
     <box
     <box
+      id={props.id}
       marginTop={margin()}
       marginTop={margin()}
       paddingLeft={3}
       paddingLeft={3}
       onMouseOver={props.onMouseOver}
       onMouseOver={props.onMouseOver}
@@ -1895,9 +1901,12 @@ export function InlineToolRow(props: {
         const children = parent.getChildren()
         const children = parent.getChildren()
         const index = children.indexOf(el)
         const index = children.indexOf(el)
         const previous = children[index - 1]
         const previous = children[index - 1]
+        const previousInline = previous?.id.startsWith("tool-inline-") ?? false
+        const previousSubagent = previous?.id.startsWith("tool-inline-subagent-") ?? false
         setMargin(
         setMargin(
           previous?.id.startsWith("text-") ||
           previous?.id.startsWith("text-") ||
             previous?.id.startsWith("tool-block-") ||
             previous?.id.startsWith("tool-block-") ||
+            (previousInline && previousSubagent !== Boolean(props.subagent)) ||
             props.separateAfter?.(previous?.id)
             props.separateAfter?.(previous?.id)
             ? 1
             ? 1
             : 0,
             : 0,
@@ -2123,8 +2132,8 @@ function Read(props: ToolProps<typeof ReadTool>) {
         Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])}
         Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])}
       </InlineTool>
       </InlineTool>
       <For each={loaded()}>
       <For each={loaded()}>
-        {(filepath) => (
-          <box paddingLeft={3}>
+        {(filepath, index) => (
+          <box id={`tool-inline-loaded-${props.part.id}-${index()}`} paddingLeft={3}>
             <text paddingLeft={3} fg={theme.textMuted}>
             <text paddingLeft={3} fg={theme.textMuted}>
               ↳ Loaded {pathFormatter.format(filepath)}
               ↳ Loaded {pathFormatter.format(filepath)}
             </text>
             </text>
@@ -2190,11 +2199,14 @@ function Task(props: ToolProps<typeof TaskTool>) {
     tools().findLast((x) => (x.state.status === "running" || x.state.status === "completed") && x.state.title),
     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 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(() => {
   const duration = createMemo(() => {
@@ -2214,20 +2226,15 @@ function Task(props: ToolProps<typeof TaskTool>) {
     if (isRunning() && retrying) {
     if (isRunning() && retrying) {
       content.push(`↳ ${Locale.truncate(retrying.message, 80)} [retrying attempt #${retrying.attempt}]`)
       content.push(`↳ ${Locale.truncate(retrying.message, 80)} [retrying attempt #${retrying.attempt}]`)
     } else if (isRunning() && tools().length > 0) {
     } else if (isRunning() && tools().length > 0) {
-      // content[0] += ` · ${tools().length} toolcalls`
       if (current()) {
       if (current()) {
         const state = current()!.state
         const state = current()!.state
         const title = state.status === "running" || state.status === "completed" ? state.title : undefined
         const title = state.status === "running" || state.status === "completed" ? state.title : undefined
         content.push(`↳ ${Locale.titlecase(current()!.tool)} ${title}`)
         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")
     return content.join("\n")
@@ -2235,7 +2242,8 @@ function Task(props: ToolProps<typeof TaskTool>) {
 
 
   return (
   return (
     <InlineTool
     <InlineTool
-      icon="│"
+      icon={props.part.state.status === "completed" ? "✓" : "│"}
+      subagent={true}
       color={retry() ? theme.error : undefined}
       color={retry() ? theme.error : undefined}
       spinner={isRunning()}
       spinner={isRunning()}
       complete={props.input.description}
       complete={props.input.description}
@@ -2254,6 +2262,15 @@ function Task(props: ToolProps<typeof TaskTool>) {
   )
   )
 }
 }
 
 
+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<typeof EditTool>) {
 function Edit(props: ToolProps<typeof EditTool>) {
   const ctx = use()
   const ctx = use()
   const { theme, syntax } = useTheme()
   const { theme, syntax } = useTheme()

+ 18 - 0
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\\.
    ✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
      Path\\.data|data =" in packages/opencode/src (115 matches)"
      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"
+`;

+ 55 - 1
packages/opencode/test/cli/tui/inline-tool-wrap-snapshot.test.tsx

@@ -1,7 +1,11 @@
 import { afterEach, describe, expect, test } from "bun:test"
 import { afterEach, describe, expect, test } from "bun:test"
 import { For } from "solid-js"
 import { For } from "solid-js"
 import { testRender, type JSX } from "@opentui/solid"
 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<ReturnType<typeof testRender>> | undefined
 let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
 
 
@@ -86,6 +90,41 @@ function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" })
   )
   )
 }
 }
 
 
+function SubagentGroupFixture() {
+  return (
+    <box flexDirection="column" width={72}>
+      <InlineToolRow id="tool-inline-before" icon="✱" complete={true} pending="">
+        Grep "Task" (2 matches)
+      </InlineToolRow>
+      <InlineToolRow id="tool-inline-subagent-one" icon="⠙" complete={true} pending="" subagent={true}>
+        Explore Task — Inspect active task spacing
+      </InlineToolRow>
+      <InlineToolRow id="tool-inline-subagent-two" icon="✓" complete={true} pending="" subagent={true}>
+        {"General Task — Confirm completed task spacing\n↳ 1 toolcall · 501ms"}
+      </InlineToolRow>
+      <InlineToolRow id="tool-inline-after" icon="→" complete={true} pending="">
+        Read src/cli/cmd/tui/routes/session/index.tsx
+      </InlineToolRow>
+    </box>
+  )
+}
+
+function LoadedReadBeforeSubagentFixture() {
+  return (
+    <box flexDirection="column" width={72}>
+      <InlineToolRow id="tool-inline-read" icon="→" complete={true} pending="">
+        Read src/cli/cmd/tui/routes/session/index.tsx
+      </InlineToolRow>
+      <box id="tool-inline-loaded-read-child" paddingLeft={3}>
+        <text paddingLeft={3}>↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx</text>
+      </box>
+      <InlineToolRow id="tool-inline-subagent-after-read" icon="✓" complete={true} pending="" subagent={true}>
+        {"Explore Task — Inspect active task spacing\n↳ 1 toolcall · 501ms"}
+      </InlineToolRow>
+    </box>
+  )
+}
+
 async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) {
 async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) {
   testSetup = await testRender(component, options)
   testSetup = await testRender(component, options)
   await testSetup.renderOnce()
   await testSetup.renderOnce()
@@ -101,6 +140,13 @@ async function renderFrame(component: () => JSX.Element, options: { width: numbe
 }
 }
 
 
 describe("TUI inline tool wrapping", () => {
 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 () => {
   test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => {
     expect(await renderFrame(() => <Fixture />, { width: 72, height: 12 })).toMatchSnapshot()
     expect(await renderFrame(() => <Fixture />, { width: 72, height: 12 })).toMatchSnapshot()
   })
   })
@@ -116,4 +162,12 @@ describe("TUI inline tool wrapping", () => {
   test("keeps separation after a padded user message", async () => {
   test("keeps separation after a padded user message", async () => {
     expect(await renderFrame(() => <Fixture before="user" />, { width: 72, height: 14 })).toMatchSnapshot()
     expect(await renderFrame(() => <Fixture before="user" />, { width: 72, height: 14 })).toMatchSnapshot()
   })
   })
+
+  test("separates a contiguous subagent group from inline tools", async () => {
+    expect(await renderFrame(() => <SubagentGroupFixture />, { width: 72, height: 10 })).toMatchSnapshot()
+  })
+
+  test("separates a subagent group after an expanded read", async () => {
+    expect(await renderFrame(() => <LoadedReadBeforeSubagentFixture />, { width: 72, height: 8 })).toMatchSnapshot()
+  })
 })
 })