import assert from "node:assert/strict"; import test from "node:test"; import { visibleWidth } from "@earendil-works/pi-tui"; import { currentTaskLabel, currentTaskTokens } from "../src/herdr.ts"; import { TaskState } from "../src/state.ts"; import { formatDuration, renderProgressHeader, renderTaskLine, renderToolResult, renderTodoPanel, renderTree, renderWidgetTree, snapshotStats, } from "../src/ui.ts"; import { clearTaskWidget, TODO_ANIMATION_INTERVAL_MS, updateTaskWidget } from "../src/widget.ts"; const plainTheme = { fg: (_color: string, text: string) => text, bold: (text: string) => text, strikethrough: (text: string) => `~${text}~`, }; type TestTui = { requestRender(): void }; type TestWidget = { render(width: number): string[]; dispose(): void }; function mountWidget(widget: unknown, tui: TestTui = { requestRender() {} }, theme: unknown = plainTheme): TestWidget { return (widget as (tui: TestTui, theme: unknown) => TestWidget)(tui, theme); } function setSubagentsIntegration(integration: unknown): void { const globals = globalThis as typeof globalThis & { __pi_interactive_subagents?: unknown }; if (integration === undefined) delete globals.__pi_interactive_subagents; else globals.__pi_interactive_subagents = integration; } function withSubagentsIntegration(integration: unknown, callback: () => T): T { const globals = globalThis as typeof globalThis & { __pi_interactive_subagents?: unknown }; const previous = globals.__pi_interactive_subagents; setSubagentsIntegration(integration); try { return callback(); } finally { setSubagentsIntegration(previous); } } function stripAnsi(text: string): string { return text.replace(/\x1b\[[0-9;]*m/g, ""); } function fixture(): TaskState { const state = new TaskState(() => 0); state.add([ { name: "Build auth", description: "Auth work", status: "in-progress", children: [ { name: "Resolve token issue", description: "Investigate token validation and provider behavior.", status: "blocked", children: [{ name: "Inspect provider response", description: "Inspect response" }], }, { name: "Add middleware", description: "Middleware" }, ], }, { name: "Define schema", description: "Schema", status: "done" }, ]); return state; } test("renders an adaptive progress bar and stays within width", () => { const stats = fixture().stats(); const wide = stripAnsi(renderProgressHeader(stats, plainTheme as never, 40)); assert.match(wide, /^ Todos 1\/5 · !1 ━{4}─{16}$/); assert.ok(visibleWidth(wide) <= 40); const narrow = renderProgressHeader(stats, plainTheme as never, 12); assert.ok(visibleWidth(narrow) <= 12); assert.doesNotMatch(stripAnsi(narrow), /━|─/); }); test("renders lightweight connectors without a detached root continuation", () => { const output = renderTree(fixture().list(), plainTheme as never, 80).map(stripAnsi); assert.deepEqual(output, [ " ◉ 1. Build auth · 00:00", " ├─ ! 2. Resolve token issue", " │ └─ ○ 3. Inspect provider response", " └─ ○ 4. Add middleware", " ✓ 5. ~Define schema~", ]); }); test("collapses completed widget subtrees while keeping list trees expanded", () => { const state = new TaskState(() => 0); state.add([ { name: "Release", children: [ { name: "Completed branch", children: [ { name: "Done one", status: "done" }, { name: "Done two", status: "done" }, ], }, { name: "Open branch", children: [{ name: "Open leaf" }] }, ], }, { name: "Pending parent", children: [{ name: "Finished child", status: "done" }], }, ]); const widgetLines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi); assert.deepEqual(widgetLines, [ " ├─ I. ○ Release", " │ ├─ 1. ○ Completed branch · 2/2 done", " │ └─ 2. ○ Open branch", " │ └─ ○ Open leaf", " └─ II. ○ Pending parent · 1/1 done", ]); const fullTree = renderTree(state.list(), plainTheme as never, 80).map(stripAnsi); assert.ok(fullTree.some((line) => line.includes("Done one"))); assert.ok(fullTree.some((line) => line.includes("Finished child"))); state.update([{ id: 3, status: "pending" }]); const reopened = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi); assert.ok(reopened.some((line) => line.includes("Done one"))); assert.ok(reopened.some((line) => line.includes("Done two"))); }); test("uses only the outline levels needed while leaving leaf tasks unlabeled", () => { const flatState = new TaskState(() => 0); flatState.add([{ name: "First leaf" }, { name: "Second leaf" }]); assert.deepEqual(renderWidgetTree(flatState.list(), plainTheme as never, 80).map(stripAnsi), [ " ├─ ○ First leaf", " └─ ○ Second leaf", ]); const twoLevelState = new TaskState(() => 0); twoLevelState.add([ { name: "First group", children: [{ name: "First nested leaf" }] }, { name: "Second group", children: [{ name: "Second nested leaf" }] }, ]); assert.deepEqual(renderWidgetTree(twoLevelState.list(), plainTheme as never, 80).map(stripAnsi), [ " ├─ 1. ○ First group", " │ └─ ○ First nested leaf", " └─ 2. ○ Second group", " └─ ○ Second nested leaf", ]); const state = new TaskState(() => 0); state.add([ { name: "Category one" }, { name: "Category two" }, { name: "Category three" }, { name: "Category four", children: [ { name: "First item", children: [{ name: "Leaf sub-item" }] }, { name: "Second item" }, ], }, { name: "Category five", children: [{ name: "Reset item numbering" }] }, ]); const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi); assert.deepEqual(lines, [ " ├─ I. ○ Category one", " ├─ II. ○ Category two", " ├─ III. ○ Category three", " ├─ IV. ○ Category four", " │ ├─ 1. ○ First item", " │ │ └─ ○ Leaf sub-item", " │ └─ 2. ○ Second item", " └─ V. ○ Category five", " └─ 1. ○ Reset item numbering", ]); const narrow = renderWidgetTree(state.list(), plainTheme as never, 18); assert.ok(narrow.every((line) => visibleWidth(line) <= 18)); const statusPriority = renderWidgetTree(state.list(), plainTheme as never, 6).map(stripAnsi); assert.match(statusPriority[3] ?? "", /○/); const childStatusPriority = renderWidgetTree(state.list(), plainTheme as never, 10).map(stripAnsi); assert.match(childStatusPriority[4] ?? "", /○/); const leafStatusPriority = renderWidgetTree(state.list(), plainTheme as never, 12).map(stripAnsi); assert.match(leafStatusPriority[5] ?? "", /○/); const listLeafStatusPriority = renderTree(state.list(), plainTheme as never, 12).map(stripAnsi); assert.match(listLeafStatusPriority[5] ?? "", /○/); const timedState = new TaskState(() => 0); timedState.add([ { name: "Timed category", status: "in-progress", children: [{ name: "Timed item", status: "in-progress" }] }, ]); const timedRoot = stripAnsi(renderWidgetTree(timedState.list(), plainTheme as never, 15)[0] ?? ""); const timedChild = stripAnsi(renderWidgetTree(timedState.list(), plainTheme as never, 18)[1] ?? ""); assert.match(timedRoot, /◉/); assert.match(timedChild, /◉/); }); test("animates only in-progress widget circles while preserving narrow-width status markers", () => { const state = new TaskState(() => 0); state.add([ { name: "Active", status: "in-progress" }, { name: "Pending" }, { name: "Blocked", status: "blocked" }, { name: "Done", status: "done" }, ]); const orbitFrames = ["◐", "◓", "◑", "◒"]; for (const [frame, icon] of orbitFrames.entries()) { const lines = renderWidgetTree(state.list(), plainTheme as never, 80, " ", frame).map(stripAnsi); assert.match(lines[0] ?? "", new RegExp(icon)); assert.match(lines[1] ?? "", /○/); assert.match(lines[2] ?? "", /!/); assert.match(lines[3] ?? "", /✓/); const narrow = renderWidgetTree(state.list(), plainTheme as never, 6, " ", frame).map(stripAnsi); assert.match(narrow[0] ?? "", new RegExp(icon)); assert.ok(narrow.every((line) => visibleWidth(line) <= 6)); } assert.match(stripAnsi(renderTree(state.list(), plainTheme as never, 80)[0] ?? ""), /◉/); }); test("renumbers widget labels after task reordering and reparenting", () => { const state = new TaskState(() => 0); state.add([ { name: "Category A", children: [{ name: "A1" }, { name: "A2" }] }, { name: "Category B", children: [{ name: "B1" }] }, ]); state.update([ { id: 4, position: 0 }, { id: 3, parentId: 4, position: 0 }, ]); const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi); assert.deepEqual(lines, [ " ├─ 1. ○ Category B", " │ ├─ ○ A2", " │ └─ ○ B1", " └─ 2. ○ Category A", " └─ ○ A1", ]); }); test("caps the widget at twelve task rows with an honest overflow summary", () => { const state = new TaskState(() => 0); state.add(Array.from({ length: 20 }, (_, index) => ({ name: `Task ${index + 1}` }))); const lines = renderWidgetTree(state.list(), plainTheme as never, 60).map(stripAnsi); assert.equal(lines.length, 13); assert.match(lines[0] ?? "", /Task 1/); assert.match(lines[11] ?? "", /Task 12/); assert.equal(lines[12], " … 8 more tasks"); assert.ok(lines.every((line) => visibleWidth(line) <= 60)); const collapsedOverflow = new TaskState(() => 0); collapsedOverflow.add([ ...Array.from({ length: 12 }, (_, index) => ({ name: `Visible ${index + 1}` })), { name: "Hidden completed branch", children: [ { name: "Hidden done one", status: "done" as const }, { name: "Hidden done two", status: "done" as const }, ], }, ]); const overflowLines = renderWidgetTree(collapsedOverflow.list(), plainTheme as never, 60).map(stripAnsi); assert.equal(overflowLines[12], " … 3 more tasks"); }); test("hides a completed widget summary before squeezing the task name", () => { const state = new TaskState(() => 0); state.add([{ name: "A long parent task name", children: [{ name: "Done", status: "done" }] }]); const line = renderWidgetTree(state.list(), plainTheme as never, 20)[0] ?? ""; assert.ok(visibleWidth(line) <= 20); assert.doesNotMatch(stripAnsi(line), /done$/); }); test("formats compact timers and adds hours only when needed", () => { assert.equal(formatDuration(0), "00:00"); assert.equal(formatDuration(65_432), "01:05"); assert.equal(formatDuration(3_599_999), "59:59"); assert.equal(formatDuration(3_600_000), "01:00:00"); let now = 0; const state = new TaskState(() => now); state.add([{ name: "Timed", status: "in-progress" }]); now = 65_432; const line = stripAnsi(renderTaskLine(state.get([1])[0]!, " ", plainTheme as never, 24)); assert.match(line, / · 01:05$/); assert.ok(visibleWidth(line) <= 24); }); test("truncates every task row at narrow widths", () => { const lines = renderTree(fixture().list(), plainTheme as never, 20); assert.ok(lines.every((line) => visibleWidth(line) <= 20)); assert.ok(lines.some((line) => stripAnsi(line).includes("..."))); }); test("uses semantic icon colors and old-plugin text styling", () => { const calls: Array<{ color: string; text: string }> = []; const theme = { fg: (color: string, text: string) => { calls.push({ color, text }); return text; }, bold: (text: string) => text, strikethrough: (text: string) => `~${text}~`, }; const tasks = fixture().list(); for (const task of tasks) renderTaskLine(task, "", theme as never, 80); assert.ok(calls.some((call) => call.color === "dim" && call.text === "○")); assert.ok(calls.some((call) => call.color === "warning" && call.text === "◉")); assert.ok(calls.some((call) => call.color === "error" && call.text === "!")); assert.ok(calls.some((call) => call.color === "success" && call.text === "✓")); assert.ok(calls.some((call) => call.color === "warning" && call.text === "Build auth")); assert.ok(calls.some((call) => call.color === "dim" && call.text === "~Define schema~")); assert.ok(!calls.some((call) => call.color === "error" && call.text === "Resolve token issue")); }); test("selects the most specific in-progress task and compresses parallel leaves", () => { const state = new TaskState(); state.add([ { name: "Parent", description: "Parent", status: "in-progress", children: [{ name: "Child", description: "Child", status: "in-progress" }], }, { name: "Parallel", description: "Parallel", status: "in-progress" }, ]); assert.equal(currentTaskLabel(state.list()), "#2 Child +1"); assert.deepEqual(currentTaskTokens(state.list()), { task: "#2 Child", task_count: "+1", task_progress: "0/3", }); }); test("uses blocked tasks only when nothing is in progress", () => { const state = new TaskState(); state.add([ { name: "Blocked one", description: "One", status: "blocked" }, { name: "Blocked two", description: "Two", status: "blocked" }, ]); assert.equal(currentTaskLabel(state.list()), "#1 Blocked one +1"); assert.deepEqual(currentTaskTokens(state.list()), { task: "#1 Blocked one", task_count: "+1", task_progress: "0/2", }); state.update([{ id: 1, status: "done" }, { id: 2, status: "done" }]); assert.equal(currentTaskLabel(state.list()), null); assert.deepEqual(currentTaskTokens(state.list()), { task: null, task_count: null, task_progress: null, }); }); test("clears sidebar progress for an empty task tree", () => { assert.deepEqual(currentTaskTokens([]), { task: null, task_count: null, task_progress: null, }); }); test("hides an empty widget and renders a populated one", () => { let widget: unknown = "unset"; const ctx = { ui: { setWidget: (_id: string, content: unknown) => { widget = content; }, }, }; const state = new TaskState(); updateTaskWidget(state, ctx as never, true); assert.equal(widget, undefined); state.add([{ name: "Task", description: "Task" }]); updateTaskWidget(state, ctx as never, true); assert.equal(typeof widget, "function"); const component = mountWidget(widget); const lines = component.render(30).map(stripAnsi); assert.equal(lines.length, 2); assert.match(lines[0] ?? "", /^Todos.*0\/1/); assert.doesNotMatch(lines.join("\n"), /[╭╮╰╯]/); assert.ok(lines.every((line) => visibleWidth(line) <= 30)); }); test("renders compact unboxed solo panels at narrow widths without overflowing", () => { const state = new TaskState(() => 0); state.add([{ name: "A long task name", status: "blocked" }]); for (const width of [80, 60]) { const lines = renderTodoPanel(state.list(), plainTheme as never, width).map(stripAnsi); assert.ok(lines.every((line) => visibleWidth(line) <= width)); assert.match(lines[0] ?? "", /^Todos.*0\/1.*!1/); assert.match(lines[0] ?? "", /━|─/); assert.doesNotMatch(lines.join("\n"), /[╭╮╰╯]/); } const narrow = renderTodoPanel(state.list(), plainTheme as never, 20).map(stripAnsi); assert.ok(narrow.every((line) => visibleWidth(line) <= 20)); assert.doesNotMatch(narrow.join("\n"), /[╭╮╰╯]/); assert.match(narrow[0] ?? "", /0\/1/); const boundary = new TaskState(() => 0); boundary.add(Array.from({ length: 13 }, (_, index) => ({ name: `Boundary ${index + 1}` }))); const boundaryLines = renderTodoPanel(boundary.list(), plainTheme as never, 80).map(stripAnsi); assert.equal(boundaryLines.length, 14); assert.match(boundaryLines[12] ?? "", /Boundary 12/); assert.match(boundaryLines[13] ?? "", /… 1 more task/); assert.ok(boundaryLines.every((line) => !line.includes("Boundary 13"))); }); test("passes the current Pi theme unchanged to composed Subagents rendering", () => { const state = new TaskState(() => 0); state.add([{ name: "Todo" }]); const theme = { fg: (_color: string, text: string) => text, bold: (text: string) => text, strikethrough: (text: string) => text, }; let receivedTheme: unknown; const statusWidget = { hasContent: () => true, getBodyRowCount: () => 1, isClaimed: () => true, render: (_width: number, _maxBodyRows?: number, providerTheme?: unknown) => { receivedTheme = providerTheme; return ["subagents"]; }, claim: () => () => {}, }; withSubagentsIntegration({ statusWidget }, () => { let widget: unknown; const ctx = { mode: "tui" as const, ui: { setWidget: (_id: string, content: unknown) => (widget = content) }, }; updateTaskWidget(state, ctx as never, true); const component = mountWidget(widget, { requestRender() {} }, theme); component.render(80); assert.equal(receivedTheme, theme); component.dispose(); }); }); test("keeps an unboxed Subagents header plus capped body rows composed at 120 columns", () => { const state = new TaskState(() => 0); state.add([{ name: "Todo" }]); let receivedMaxBodyRows: number | undefined; const statusWidget = { hasContent: () => true, getBodyRowCount: () => 2, isClaimed: () => true, render: (_width: number, maxBodyRows?: number) => { receivedMaxBodyRows = maxBodyRows; return ["Subagents", "agent one", "agent two"]; }, claim: () => () => {}, }; withSubagentsIntegration({ statusWidget }, () => { let widget: unknown; const ctx = { mode: "tui" as const, ui: { setWidget: (_id: string, content: unknown) => (widget = content) }, }; updateTaskWidget(state, ctx as never, true); const component = mountWidget(widget); const lines = component.render(120).map(stripAnsi); assert.equal(receivedMaxBodyRows, 2); assert.ok(lines.some((line) => line.includes("Subagents"))); assert.ok(lines.some((line) => line.includes("agent two"))); assert.equal(lines[2], ""); assert.ok(lines.every((line) => visibleWidth(line) <= 120)); assert.doesNotMatch(lines.join("\n"), /[╭╮╰╯]/); component.dispose(); }); }); test("switches from side-by-side to stacked panels at the 120-column breakpoint", () => { const state = new TaskState(() => 0); state.add([{ name: "Todo" }]); let claimCount = 0; let releaseCount = 0; const renderCalls: Array<[number, number | undefined]> = []; const statusWidget = { hasContent: () => true, getBodyRowCount: () => 2, isClaimed: () => true, render: (width: number, maxBodyRows?: number) => { renderCalls.push([width, maxBodyRows]); return ["Subagents"]; }, claim: (_onChange: () => void) => { claimCount += 1; return () => { releaseCount += 1; }; }, }; withSubagentsIntegration({ statusWidget }, () => { let widget: unknown; const ctx = { mode: "tui" as const, ui: { setWidget: (_id: string, content: unknown) => (widget = content) }, }; const tui = { requestRender() {} }; updateTaskWidget(state, ctx as never, true); const component = mountWidget(widget, tui); const side = component.render(121).map(stripAnsi); assert.match(side[0] ?? "", /Todos.*Subagents/); assert.ok(side.every((line) => visibleWidth(line) === 121)); assert.equal(side[1]?.slice(61), " ".repeat(60)); assert.doesNotMatch(side.join("\n"), /[╭╮╰╯]/); state.add(Array.from({ length: 20 }, (_, index) => ({ name: `Extra ${index + 1}` }))); const unequal = component.render(121).map(stripAnsi); const rightPanel = unequal.map((line) => line.slice(61)); assert.equal(rightPanel.at(-1), " ".repeat(60)); assert.doesNotMatch(unequal.join("\n"), /[╭╮╰╯]/); const stacked = component.render(120); assert.match(stripAnsi(stacked[0] ?? ""), /Todos/); const stackedSubagentsIndex = stacked.findIndex((line) => stripAnsi(line).includes("Subagents")); assert.ok(stackedSubagentsIndex > 0); assert.ok(stackedSubagentsIndex < stacked.length); assert.ok(stacked.every((line) => visibleWidth(line) <= 120)); assert.equal(stacked[stackedSubagentsIndex - 1], ""); assert.doesNotMatch(stacked.map(stripAnsi).join("\n"), /[╭╮╰╯]/); assert.deepEqual(renderCalls.map(([, maxRows]) => maxRows), [10, 10, 2]); assert.equal(claimCount, 1); component.dispose(); assert.equal(releaseCount, 1); }); }); test("falls back to a full-width Todo panel for absent and old Subagents APIs", () => { const state = new TaskState(() => 0); state.add([{ name: "Todo" }]); let widget: unknown; const ctx = { mode: "tui" as const, ui: { setWidget: (_id: string, content: unknown) => (widget = content) }, }; const tui = { requestRender() {} }; const empty = { statusWidget: { hasContent: () => true, getBodyRowCount: () => 1, render: () => [], claim: () => () => {}, isClaimed: () => true, }, }; const malformed = { statusWidget: { hasContent: () => true, getBodyRowCount: () => 1, render: () => new Array(1), claim: () => () => {}, isClaimed: () => true, }, }; for (const integration of [undefined, { statusWidget: { render: () => [] } }, empty, malformed]) { withSubagentsIntegration(integration, () => { updateTaskWidget(state, ctx as never, true); const component = mountWidget(widget, tui); const lines = component.render(80).map(stripAnsi); assert.equal(lines.length, 2); assert.match(lines[0] ?? "", /Todos/); assert.doesNotMatch(lines.join("\n"), /[╭╮╰╯]/); component.dispose(); }); } }); test("reacquires a claim after the provider invalidates its callback", () => { const state = new TaskState(() => 0); state.add([{ name: "Todo" }]); let claims = 0; let releases = 0; let providerCallback: (() => void) | undefined; let providerClaimed = false; withSubagentsIntegration({ statusWidget: { hasContent: () => true, getBodyRowCount: () => 1, render: () => ["subagents"], isClaimed: (onChange: () => void) => providerClaimed && providerCallback === onChange, claim: (onChange: () => void) => { claims += 1; providerCallback = onChange; providerClaimed = true; return () => { releases += 1; if (providerCallback === onChange) { providerCallback = undefined; providerClaimed = false; } }; }, }, }, () => { let widget: unknown; const ctx = { mode: "tui" as const, ui: { setWidget: (_id: string, content: unknown) => (widget = content) }, }; updateTaskWidget(state, ctx as never, true); const component = mountWidget(widget); component.render(80); assert.equal(claims, 1); component.render(80); assert.equal(claims, 1); providerClaimed = false; providerCallback = undefined; component.render(80); assert.equal(claims, 2); assert.equal(releases, 1); component.dispose(); assert.equal(releases, 2); }); }); test("does not mask Todo rendering failures as optional integration failures", () => { const state = new TaskState(() => 0); state.add([{ name: "Todo" }]); withSubagentsIntegration({ statusWidget: { hasContent: () => true, getBodyRowCount: () => 1, render: () => ["subagents"], isClaimed: () => true, claim: () => () => {}, }, }, () => { let widget: unknown; const ctx = { mode: "tui" as const, ui: { setWidget: (_id: string, content: unknown) => (widget = content) }, }; const failure = new Error("Todo renderer failed"); const badTheme = { fg: () => { throw failure; }, bold: (text: string) => text, strikethrough: (text: string) => text, }; updateTaskWidget(state, ctx as never, true); const component = mountWidget(widget, { requestRender() {} }, badTheme); assert.throws(() => component.render(80), (error) => error === failure); component.dispose(); }); }); test("does not compose or claim Subagents outside the TUI mode", () => { const state = new TaskState(() => 0); state.add([{ name: "Todo" }]); let claims = 0; withSubagentsIntegration({ statusWidget: { hasContent: () => true, getBodyRowCount: () => 1, render: () => ["Subagents"], claim: () => { claims += 1; return () => {}; }, }, }, () => { let widget: unknown; const ctx = { mode: "rpc" as const, ui: { setWidget: (_id: string, content: unknown) => (widget = content) }, }; updateTaskWidget(state, ctx as never, true); const component = mountWidget(widget); const lines = component.render(80).map(stripAnsi); assert.equal(claims, 0); assert.ok(lines.every((line) => !line.includes("Subagents"))); component.dispose(); }); }); test("shares ten stacked body rows and keeps Todo overflow honest", () => { const state = new TaskState(() => 0); state.add(Array.from({ length: 20 }, (_, index) => ({ name: `Task ${index + 1}` }))); const calls: Array<[number, number | undefined]> = []; const statusWidget = { hasContent: () => true, getBodyRowCount: () => 3, isClaimed: () => true, render: (width: number, maxBodyRows?: number) => { calls.push([width, maxBodyRows]); return ["one", "two", "three"]; }, claim: () => () => {}, }; withSubagentsIntegration({ statusWidget }, () => { let widget: unknown; const ctx = { mode: "tui" as const, ui: { setWidget: (_id: string, content: unknown) => (widget = content) }, }; updateTaskWidget(state, ctx as never, true); const component = mountWidget(widget); const lines = component.render(120).map(stripAnsi); assert.deepEqual(calls, [[120, 3]]); assert.ok(lines.some((line) => line.includes("… 14 more tasks"))); assert.ok(lines.every((line) => visibleWidth(line) <= 120)); component.dispose(); }); }); test("releases a Subagents claim when Todos hide or clear", () => { const state = new TaskState(() => 0); state.add([{ name: "Todo" }]); let releases = 0; withSubagentsIntegration({ statusWidget: { hasContent: () => true, getBodyRowCount: () => 1, isClaimed: () => true, render: () => ["subagents"], claim: () => () => { releases += 1; }, }, }, () => { let widget: unknown; const ctx = { mode: "tui" as const, ui: { setWidget: (_id: string, content: unknown) => (widget = content) }, }; updateTaskWidget(state, ctx as never, true); const component = mountWidget(widget); component.render(80); updateTaskWidget(state, ctx as never, false); assert.equal(releases, 1); updateTaskWidget(state, ctx as never, true); const second = mountWidget(widget); second.render(80); state.remove([1]); updateTaskWidget(state, ctx as never, true); assert.equal(releases, 2); second.dispose(); }); }); test("handles zero and one-column panel widths cleanly", () => { const state = new TaskState(() => 0); state.add([{ name: "Todo" }]); assert.deepEqual(renderTodoPanel(state.list(), plainTheme as never, 0), []); const oneColumn = renderTodoPanel(state.list(), plainTheme as never, 1); assert.ok(oneColumn.length > 0); assert.ok(oneColumn.every((line) => visibleWidth(line) <= 1)); }); test("ignores stale widget generations when Pi replaces the widget", () => { const state = new TaskState(() => 0); state.add([{ name: "Todo", status: "in-progress" }]); let claims = 0; let releases = 0; withSubagentsIntegration({ statusWidget: { hasContent: () => true, getBodyRowCount: () => 1, isClaimed: () => true, render: () => ["subagents"], claim: () => { claims += 1; return () => { releases += 1; }; }, }, }, () => { const widgets: unknown[] = []; const ctx = { mode: "tui" as const, ui: { setWidget: (_id: string, content: unknown) => widgets.push(content) }, }; const tui = { requestRender() {} }; updateTaskWidget(state, ctx as never, true, 5); const first = mountWidget(widgets[0], tui); first.render(80); updateTaskWidget(state, ctx as never, true, 5); const second = mountWidget(widgets[1], tui); second.render(80); assert.equal(claims, 2); assert.equal(releases, 1); assert.deepEqual(first.render(80), []); first.dispose(); assert.equal(releases, 1); clearTaskWidget(ctx as never); assert.equal(releases, 2); second.dispose(); }); }); test("animates active todos twice per second", () => { assert.equal(TODO_ANIMATION_INTERVAL_MS, 500); }); test("live widgets request rerenders and dispose their interval", async () => { let now = 0; const state = new TaskState(() => now); state.add([{ name: "Timed", status: "in-progress" }]); let widget: unknown; const ctx = { ui: { setWidget: (_id: string, content: unknown) => { widget = content; }, }, }; updateTaskWidget(state, ctx as never, true, 5); let renderRequests = 0; const observedFrames = new Set(); let component: TestWidget; component = mountWidget(widget, { requestRender() { renderRequests += 1; const frame = stripAnsi(component.render(40)[1] ?? "").match(/[◐◓◑◒]/)?.[0]; if (frame !== undefined) observedFrames.add(frame); }, }); try { assert.match(stripAnsi(component.render(40)[1] ?? ""), /◐.* · 00:00/); now = 65_000; await new Promise((resolve) => setTimeout(resolve, 20)); assert.ok(renderRequests > 0); assert.ok(observedFrames.size > 0); assert.match(stripAnsi(component.render(40)[1] ?? ""), /[◐◓◑◒].* · 01:05/); } finally { component.dispose(); } const requestsAfterDispose = renderRequests; await new Promise((resolve) => setTimeout(resolve, 15)); assert.equal(renderRequests, requestsAfterDispose); }); test("tool results collapse summaries and expand themed details", () => { const state = fixture(); const details = { action: "list" as const, snapshot: state.snapshot(), affected: state.list() }; const result = { content: [{ type: "text", text: "list" }], details }; const collapsed = renderToolResult(result, false, plainTheme as never).render(80).map(stripAnsi); assert.deepEqual(collapsed, ["✓ 5 tasks · 1/5 done"]); const expanded = renderToolResult(result, true, plainTheme as never).render(80).map(stripAnsi); assert.equal(expanded.length, 6); assert.match(expanded[1] ?? "", /Build auth/); }); test("tool errors show one summary line until expanded", () => { const result = { content: [{ type: "text", text: "Update failed · 2 validation errors\n- first\n- second" }], isError: true, }; const collapsed = renderToolResult(result, false, plainTheme as never).render(80).map(stripAnsi); assert.deepEqual(collapsed, ["✗ Update failed · 2 validation errors"]); const expanded = renderToolResult(result, true, plainTheme as never).render(80).map(stripAnsi); assert.deepEqual(expanded, ["✗ Update failed · 2 validation errors", " - first", " - second"]); }); test("expanded task details tolerate an omitted description", () => { const state = new TaskState(); state.add([{ name: "Name only" }]); const result = { content: [{ type: "text", text: "details" }], details: { action: "get" as const, snapshot: state.snapshot(), affected: state.list() }, }; const lines = renderToolResult(result, true, plainTheme as never).render(80).map(stripAnsi); assert.deepEqual(lines, ["✓ 1 task", " ○ 1. Name only", " Parent root · Children none"]); }); test("snapshot stats count every task node", () => { assert.deepEqual(snapshotStats(fixture().list()), { total: 5, pending: 2, inProgress: 1, blocked: 1, done: 1, }); });