Compose Todo and Subagent widgets
This commit is contained in:
+459
-24
@@ -8,11 +8,12 @@ import {
|
||||
renderProgressHeader,
|
||||
renderTaskLine,
|
||||
renderToolResult,
|
||||
renderTodoPanel,
|
||||
renderTree,
|
||||
renderWidgetTree,
|
||||
snapshotStats,
|
||||
} from "../src/ui.ts";
|
||||
import { TODO_ANIMATION_INTERVAL_MS, updateTaskWidget } from "../src/widget.ts";
|
||||
import { clearTaskWidget, TODO_ANIMATION_INTERVAL_MS, updateTaskWidget } from "../src/widget.ts";
|
||||
|
||||
const plainTheme = {
|
||||
fg: (_color: string, text: string) => text,
|
||||
@@ -20,6 +21,30 @@ const plainTheme = {
|
||||
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, "");
|
||||
}
|
||||
@@ -367,15 +392,433 @@ test("hides an empty widget and renders a populated one", () => {
|
||||
state.add([{ name: "Task", description: "Task" }]);
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
assert.equal(typeof widget, "function");
|
||||
const component = (widget as unknown as (tui: unknown, theme: unknown) => { render(width: number): string[] })(
|
||||
{},
|
||||
plainTheme,
|
||||
);
|
||||
const lines = component.render(30);
|
||||
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);
|
||||
});
|
||||
@@ -397,32 +840,24 @@ test("live widgets request rerenders and dispose their interval", async () => {
|
||||
|
||||
let renderRequests = 0;
|
||||
const observedFrames = new Set<string>();
|
||||
let component: { render(width: number): string[]; dispose?(): void };
|
||||
component = (
|
||||
widget as unknown as (
|
||||
tui: { requestRender(): void },
|
||||
theme: unknown,
|
||||
) => { render(width: number): string[]; dispose?(): void }
|
||||
)(
|
||||
{
|
||||
requestRender() {
|
||||
renderRequests += 1;
|
||||
const frame = stripAnsi(component.render(40)[1] ?? "").match(/[◐◓◑◒]/)?.[0];
|
||||
if (frame !== undefined) observedFrames.add(frame);
|
||||
},
|
||||
let component: TestWidget;
|
||||
component = mountWidget(widget, {
|
||||
requestRender() {
|
||||
renderRequests += 1;
|
||||
const frame = stripAnsi(component.render(40)[1] ?? "").match(/[◐◓◑◒]/)?.[0];
|
||||
if (frame !== undefined) observedFrames.add(frame);
|
||||
},
|
||||
plainTheme,
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
assert.match(stripAnsi(component.render(40)[1] ?? ""), /◐.* · 00:00$/);
|
||||
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$/);
|
||||
assert.match(stripAnsi(component.render(40)[1] ?? ""), /[◐◓◑◒].* · 01:05/);
|
||||
} finally {
|
||||
component.dispose?.();
|
||||
component.dispose();
|
||||
}
|
||||
|
||||
const requestsAfterDispose = renderRequests;
|
||||
|
||||
Reference in New Issue
Block a user