Files
2026-08-30 13:00:14 -04:00

911 lines
32 KiB
TypeScript

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<T>(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<string>();
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,
});
});