Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/tui/src/component/spinner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}>
Expand Down
86 changes: 67 additions & 19 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1978,6 +1978,7 @@ function InlineTool(props: {
pending: string
failure?: string
spinner?: boolean
status?: JSX.Element
children: JSX.Element
part: SessionMessageAssistantTool
onClick?: () => void
Expand Down Expand Up @@ -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={() => {
Expand Down Expand Up @@ -2057,6 +2059,7 @@ export function InlineToolRow(props: {
pending: string
failure?: string
spinner?: boolean
status?: JSX.Element
children: JSX.Element
onMouseOver?: () => void
onMouseOut?: () => void
Expand All @@ -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}>
Expand All @@ -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>
Expand All @@ -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 }
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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}`
}
Expand Down
26 changes: 18 additions & 8 deletions packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { For } from "solid-js"
import { testRender, type JSX } from "@opentui/solid"
import {
formatSubagentRetry,
formatSubagentTitle,
InlineToolRow,
parseApplyPatchFiles,
parseDiagnostics,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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")
})
Expand Down
Loading