Sfoglia il codice sorgente

feat(tui): style background subagent indicator (#36751)

Kit Langton 4 settimane fa
parent
commit
ce2e301e24

+ 4 - 1
packages/tui/src/component/spinner.tsx

@@ -14,7 +14,10 @@ export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
   const config = useConfig().data
   const color = () => props.color ?? theme.textMuted
   return (
-    <Show when={config.animations ?? true} fallback={<text fg={color()}>⋯ {props.children}</text>}>
+    <Show
+      when={config.animations ?? true}
+      fallback={<text fg={color()}>{props.children ? <>⋯ {props.children}</> : "⋯"}</text>}
+    >
       <box flexDirection="row" gap={1}>
         <spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
         <Show when={props.children}>

+ 67 - 19
packages/tui/src/routes/session/index.tsx

@@ -1978,6 +1978,7 @@ function InlineTool(props: {
   pending: string
   failure?: string
   spinner?: boolean
+  status?: JSX.Element
   children: JSX.Element
   part: SessionMessageAssistantTool
   onClick?: () => void
@@ -2028,6 +2029,7 @@ function InlineTool(props: {
       pending={props.pending}
       failure={props.failure}
       spinner={props.spinner}
+      status={props.status}
       onMouseOver={() => clickable() && setHover(true)}
       onMouseOut={() => setHover(false)}
       onMouseUp={() => {
@@ -2057,6 +2059,7 @@ export function InlineToolRow(props: {
   pending: string
   failure?: string
   spinner?: boolean
+  status?: JSX.Element
   children: JSX.Element
   onMouseOver?: () => void
   onMouseOut?: () => void
@@ -2066,7 +2069,16 @@ export function InlineToolRow(props: {
     <box paddingLeft={3} onMouseOver={props.onMouseOver} onMouseOut={props.onMouseOut} onMouseUp={props.onMouseUp}>
       <Switch>
         <Match when={props.spinner}>
-          <Spinner color={props.color} children={props.children} />
+          <Show when={props.status} fallback={<Spinner color={props.color} children={props.children} />}>
+            {(status) => (
+              <box flexDirection="row" gap={1}>
+                <Spinner color={props.color} />
+                <InlineToolLabel color={props.color} status={status()}>
+                  {props.children}
+                </InlineToolLabel>
+              </box>
+            )}
+          </Show>
         </Match>
         <Match when={true}>
           <Show fallback={<Spinner color={props.color}>{props.pending}</Spinner>} when={props.complete || props.failed}>
@@ -2078,13 +2090,28 @@ export function InlineToolRow(props: {
               >
                 {props.icon}
               </text>
-              <text
-                flexGrow={1}
-                fg={props.failed ? props.errorColor : props.color}
-                attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
+              <Show
+                when={props.status}
+                fallback={
+                  <text
+                    flexGrow={1}
+                    fg={props.failed ? props.errorColor : props.color}
+                    attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
+                  >
+                    {props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
+                  </text>
+                }
               >
-                {props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
-              </text>
+                {(status) => (
+                  <InlineToolLabel
+                    color={props.failed ? props.errorColor : props.color}
+                    denied={props.denied}
+                    status={status()}
+                  >
+                    {props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
+                  </InlineToolLabel>
+                )}
+              </Show>
             </box>
           </Show>
         </Match>
@@ -2098,6 +2125,32 @@ export function InlineToolRow(props: {
   )
 }
 
+function InlineToolLabel(props: { color?: RGBA; denied?: boolean; status: JSX.Element; children: JSX.Element }) {
+  return (
+    <box flexDirection="row" flexWrap="wrap" columnGap={1} flexGrow={1}>
+      <text
+        maxWidth="100%"
+        flexShrink={0}
+        fg={props.color}
+        attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
+      >
+        {props.children}
+      </text>
+      {props.status}
+    </box>
+  )
+}
+
+function StatusBadge(props: { children: string }) {
+  const { theme } = useTheme()
+  return (
+    <text flexShrink={0} bg={theme.backgroundElement} fg={theme.textMuted}>
+      {" "}
+      {props.children}{" "}
+    </text>
+  )
+}
+
 function BlockTool(props: {
   title?: string
   path?: { label: string; value: string }
@@ -2241,9 +2294,7 @@ function Shell(props: ToolProps) {
           </Show>
         </Show>
         <Show when={shellID()}>
-          <text>
-            <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> Background </span>
-          </text>
+          <StatusBadge>Background</StatusBadge>
         </Show>
         <Show when={collapsed().overflow}>
           <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
@@ -2386,20 +2437,17 @@ function Subagent(props: ToolProps) {
         const id = sessionID()
         if (id) navigate({ type: "session", sessionID: id })
       }}
+      status={
+        props.input.background === true || props.metadata.status === "running" ? (
+          <StatusBadge>Background</StatusBadge>
+        ) : undefined
+      }
     >
-      {formatSubagentTitle(
-        Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General"),
-        description() ?? "Subagent",
-        props.input.background === true || props.metadata.status === "running",
-      )}
+      {`${Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General")} Subagent — ${description() ?? "Subagent"}`}
     </InlineTool>
   )
 }
 
-export function formatSubagentTitle(agent: string, description: string, background: boolean) {
-  return `${agent} Subagent — ${description}${background ? " [background]" : ""}`
-}
-
 export function formatSubagentRetry(attempt: number, message: string) {
   return `Retrying (attempt ${attempt}) · ${message}`
 }

+ 18 - 8
packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx

@@ -3,7 +3,6 @@ import { For } from "solid-js"
 import { testRender, type JSX } from "@opentui/solid"
 import {
   formatSubagentRetry,
-  formatSubagentTitle,
   InlineToolRow,
   parseApplyPatchFiles,
   parseDiagnostics,
@@ -99,7 +98,16 @@ function ReminderAlignmentFixture() {
   )
 }
 
+function TrailingStatusFixture() {
+  return (
+    <InlineToolRow icon=":" complete={true} pending="" status={<text flexShrink={0}> Background </text>}>
+      Explore Subagent — Inspect renderer status styling
+    </InlineToolRow>
+  )
+}
+
 async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) {
+  testSetup?.renderer.destroy()
   testSetup = await testRender(component, options)
   await testSetup.renderOnce()
   await testSetup.renderOnce()
@@ -142,6 +150,15 @@ describe("TUI inline tool wrapping", () => {
     )
   })
 
+  test("wraps a trailing status as one padded item", async () => {
+    expect(await renderFrame(() => <TrailingStatusFixture />, { width: 70, height: 2 })).toBe(
+      "   : Explore Subagent — Inspect renderer status styling  Background",
+    )
+    expect(await renderFrame(() => <TrailingStatusFixture />, { width: 62, height: 2 })).toBe(
+      "   : Explore Subagent — Inspect renderer status styling\n      Background",
+    )
+  })
+
   test("filters malformed nested tool wire data", () => {
     expect(
       parseApplyPatchFiles([
@@ -180,13 +197,6 @@ describe("TUI inline tool wrapping", () => {
     ).toEqual([{ message: "valid", range: { start: { line: 2, character: 3 } } }])
   })
 
-  test("keeps background state attached to the subagent identity", () => {
-    expect(formatSubagentTitle("Explore", "Inspect renderer", false)).toBe("Explore Subagent — Inspect renderer")
-    expect(formatSubagentTitle("Explore", "Inspect renderer", true)).toBe(
-      "Explore Subagent — Inspect renderer [background]",
-    )
-  })
-
   test("keeps retry status ahead of wrapping messages", () => {
     expect(formatSubagentRetry(2, "Rate limited by provider")).toBe("Retrying (attempt 2) · Rate limited by provider")
   })