fix(tui): use disclosure icon for low activity errors (#52097)

Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-09-29 11:10:32 -04:00
committed by GitHub
parent 0f931d3939
commit 948047a8ca
4 changed files with 56 additions and 17 deletions
@@ -91,7 +91,6 @@ export function activitySummary(items: readonly Item[], instructions: number, cl
.map(([name, count]) => `${count} ${name}${count === 1 ? "" : "s"}`)
.join(", "),
active: items.some((item) => isActive(item, closed)),
failed: items.some(failed),
}
}
@@ -100,11 +99,3 @@ function isActive(item: Item, closed: boolean) {
return !closed && item.part.time?.completed === undefined && item.message.time.completed === undefined
return item.part.state.status === "streaming" || item.part.state.status === "running"
}
function failed(item: Item) {
if (item.part.type !== "tool") return false
if (item.part.state.status === "error") return true
if (canonicalToolName(item.part.name) !== "execute" || item.part.state.status === "streaming") return false
const metadata = item.part.state.metadata
return metadata?.error === true || executeCalls(metadata?.toolCalls).some((call) => call.status === "error")
}
@@ -211,7 +211,7 @@ function ActivityGroup(props: GroupProps) {
<GroupAnchor groupID={disclosure.id()} active={summary().label !== ""}>
<Show when={summary().label}>
<InlineToolRow
icon={summary().failed ? "✗" : disclosure.expanded() ? "−" : "+"}
icon={disclosure.expanded() ? "−" : "+"}
color={hover() ? theme.text.base : theme.text.muted}
complete={true}
pending={summary().label}
@@ -200,6 +200,57 @@ test("a low activity group with nothing finished stays collapsed behind a status
}
})
test("failed low activity uses a disclosure icon and keeps details expandable", async () => {
const config = createTuiResolvedConfig({ animations: false })
const anchors = createTimelineAnchors()
const [expanded, setExpanded] = createStore<Record<string, boolean>>({})
const message: SessionMessageAssistant = {
id: "a",
type: "assistant",
agent: "build",
model: { providerID: "fixture", id: "fixture" },
time: { created: 0, completed: 2 },
content: [
{
type: "tool",
id: "failed-shell",
name: "shell",
time: { created: 0, completed: 2 },
state: { status: "error", input: {}, error: { type: "Fixture", message: "command failed" } },
},
],
}
const row: SessionGroup = {
type: "group",
kind: "activity",
size: 1,
completed: true,
pending: [],
children: [{ type: "entry", size: 1, entry: { type: "part", ref: { messageID: "a", partID: "failed-shell" } } }],
}
const app = await mount({
row,
anchors,
config,
expanded: (id) => expanded[id],
setExpanded: (id, value) => setExpanded(id, value),
message: () => message,
entry: () => <text>command failed</text>,
})
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("1 command"))
expect(app.captureCharFrame()).toContain("+ 1 command")
expect(app.captureCharFrame()).not.toContain("command failed")
await app.mockMouse.click(4, anchors.get({ type: "group", groupID: groupID(row, 0)! })?.node.y ?? -1)
await app.renderOnce()
expect(app.captureCharFrame()).toContain("− 1 command")
expect(app.captureCharFrame()).toContain("command failed")
} finally {
app.renderer.destroy()
}
})
function mount(input: {
row: SessionGroup
anchors: ReturnType<typeof createTimelineAnchors>
@@ -180,11 +180,10 @@ test("activity summary counts finished work by category", () => {
expect(activitySummary(items, 2)).toEqual({
label: "2 commands, 2 edits, 1 thought, 1 read, 1 tool, 2 instructions",
active: false,
failed: false,
})
})
test("running work is active but not counted, and failures are reported", () => {
test("running work is active but not counted, and failed tools are counted", () => {
const open = { ...assistant("a", []), time: { created: 1 } }
const summary = activitySummary(
[
@@ -195,7 +194,7 @@ test("running work is active but not counted, and failures are reported", () =>
],
0,
)
expect(summary).toEqual({ label: "1 read, 1 tool", active: true, failed: true })
expect(summary).toEqual({ label: "1 read, 1 tool", active: true })
})
test("execute counts its finished nested calls instead of itself", () => {
@@ -219,7 +218,6 @@ test("execute counts its finished nested calls instead of itself", () => {
expect(activitySummary([{ message, part: execute }], 0)).toEqual({
label: "2 reads, 1 tool",
active: false,
failed: true,
})
})
@@ -257,8 +255,8 @@ test("a thought still streaming counts as finished once a later row closes its g
{ message: open, part: { type: "reasoning" as const, text: "Planning", time: { created: 1 } } },
{ message: open, part: tool("r1", "read") },
]
expect(activitySummary(items, 0)).toEqual({ label: "1 read", active: true, failed: false })
expect(activitySummary(items, 0, true)).toEqual({ label: "1 thought, 1 read", active: false, failed: false })
expect(activitySummary(items, 0)).toEqual({ label: "1 read", active: true })
expect(activitySummary(items, 0, true)).toEqual({ label: "1 thought, 1 read", active: false })
})
test("until something finishes, the label is the first running item's status", () => {
@@ -271,7 +269,6 @@ test("until something finishes, the label is the first running item's status", (
expect(summarizeActivity(activity, () => open, [], false)).toEqual({
label: "Running edit…",
active: true,
failed: false,
})
})