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

This commit is contained in:
Kit Langton 2026-07-13 17:57:12 -04:00 committed by GitHub
commit ce2e301e24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 89 additions and 28 deletions

View file

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

View file

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

View file

@ -3,7 +3,6 @@ import { For } from "solid-js"
import { testRender, type JSX } from "@opentui/solid" import { testRender, type JSX } from "@opentui/solid"
import { import {
formatSubagentRetry, formatSubagentRetry,
formatSubagentTitle,
InlineToolRow, InlineToolRow,
parseApplyPatchFiles, parseApplyPatchFiles,
parseDiagnostics, 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 }) { async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) {
testSetup?.renderer.destroy()
testSetup = await testRender(component, options) testSetup = await testRender(component, options)
await testSetup.renderOnce() await testSetup.renderOnce()
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", () => { test("filters malformed nested tool wire data", () => {
expect( expect(
parseApplyPatchFiles([ parseApplyPatchFiles([
@ -180,13 +197,6 @@ describe("TUI inline tool wrapping", () => {
).toEqual([{ message: "valid", range: { start: { line: 2, character: 3 } } }]) ).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", () => { test("keeps retry status ahead of wrapping messages", () => {
expect(formatSubagentRetry(2, "Rate limited by provider")).toBe("Retrying (attempt 2) · Rate limited by provider") expect(formatSubagentRetry(2, "Rate limited by provider")).toBe("Retrying (attempt 2) · Rate limited by provider")
}) })