911 lines
32 KiB
TypeScript
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,
|
|
});
|
|
});
|