Expose theme-aware status widget integration
This commit is contained in:
+390
-26
@@ -141,6 +141,37 @@ async function emitMockEvent(
|
||||
}
|
||||
}
|
||||
|
||||
type SharedRuntimeTestState = {
|
||||
runningSubagents: Map<string, unknown>;
|
||||
latestCtx: unknown;
|
||||
latestPi: unknown;
|
||||
widgetInterval: ReturnType<typeof setInterval> | null;
|
||||
statusInterval: ReturnType<typeof setInterval> | null;
|
||||
standaloneWidgetSyncScheduled: boolean;
|
||||
pendingDeliveries: { length: number };
|
||||
deliveryFlushScheduled: boolean;
|
||||
activeRuntimeToken: symbol | null;
|
||||
registeredRuntimeToken: symbol | null;
|
||||
statusWidgetClaim: unknown;
|
||||
statusWidget?: unknown;
|
||||
};
|
||||
|
||||
function resetSharedRuntimeForTest(shared: SharedRuntimeTestState): void {
|
||||
if (shared.widgetInterval) clearInterval(shared.widgetInterval);
|
||||
if (shared.statusInterval) clearInterval(shared.statusInterval);
|
||||
shared.widgetInterval = null;
|
||||
shared.statusInterval = null;
|
||||
shared.standaloneWidgetSyncScheduled = false;
|
||||
shared.runningSubagents.clear();
|
||||
shared.latestCtx = null;
|
||||
shared.latestPi = null;
|
||||
shared.statusWidgetClaim = null;
|
||||
shared.pendingDeliveries.length = 0;
|
||||
shared.deliveryFlushScheduled = false;
|
||||
shared.activeRuntimeToken = null;
|
||||
shared.registeredRuntimeToken = null;
|
||||
}
|
||||
|
||||
function restoreEnvVar(name: string, value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
@@ -1860,33 +1891,11 @@ describe("commands", () => {
|
||||
|
||||
describe("reload lifecycle", () => {
|
||||
const testApi = (subagentsModule as any).__test__;
|
||||
const shared = testApi.sharedRuntime as {
|
||||
runningSubagents: Map<string, any>;
|
||||
latestCtx: any;
|
||||
latestPi: any;
|
||||
widgetInterval: ReturnType<typeof setInterval> | null;
|
||||
statusInterval: ReturnType<typeof setInterval> | null;
|
||||
pendingDeliveries: Array<(pi: any) => void>;
|
||||
deliveryFlushScheduled: boolean;
|
||||
activeRuntimeToken: symbol | null;
|
||||
};
|
||||
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
|
||||
const ctx = { hasUI: false, ui: { setWidget() {} } };
|
||||
|
||||
function resetSharedRuntime() {
|
||||
if (shared.widgetInterval) clearInterval(shared.widgetInterval);
|
||||
if (shared.statusInterval) clearInterval(shared.statusInterval);
|
||||
shared.widgetInterval = null;
|
||||
shared.statusInterval = null;
|
||||
shared.runningSubagents.clear();
|
||||
shared.latestCtx = null;
|
||||
shared.latestPi = null;
|
||||
shared.pendingDeliveries.length = 0;
|
||||
shared.deliveryFlushScheduled = false;
|
||||
shared.activeRuntimeToken = null;
|
||||
}
|
||||
|
||||
beforeEach(resetSharedRuntime);
|
||||
after(resetSharedRuntime);
|
||||
beforeEach(() => resetSharedRuntimeForTest(shared));
|
||||
after(() => resetSharedRuntimeForTest(shared));
|
||||
|
||||
it("preserves a live subagent and delivers its queued result through the fresh runtime", async () => {
|
||||
const oldRuntime = createMockExtensionApi();
|
||||
@@ -2623,6 +2632,71 @@ describe("subagent startup delay", () => {
|
||||
});
|
||||
});
|
||||
describe("subagents widget rendering", () => {
|
||||
function createThemeSpy() {
|
||||
const calls: Array<{ color: string; text: string }> = [];
|
||||
return {
|
||||
calls,
|
||||
theme: {
|
||||
fg(color: string, text: string) {
|
||||
calls.push({ color, text });
|
||||
return `\x1b[38;5;${calls.length}m${text}\x1b[0m`;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWidgetAgent(
|
||||
id: string,
|
||||
kind: "active" | "running" | "stalled" | "starting" | "waiting",
|
||||
) {
|
||||
const startTime = kind === "starting" ? 1_061_000 - 5_000 : 1_000_000 - 5_000;
|
||||
let statusState = createStatusState({
|
||||
source: kind === "running" ? "claude" : "pi",
|
||||
startTimeMs: startTime,
|
||||
});
|
||||
if (kind === "active" || kind === "waiting") {
|
||||
statusState = observeStatus(
|
||||
statusState,
|
||||
{
|
||||
snapshot: "present",
|
||||
updatedAt: startTime + 1_000,
|
||||
sequence: 1,
|
||||
phase: kind,
|
||||
...(kind === "active"
|
||||
? {
|
||||
active: true,
|
||||
activeScope: "tool",
|
||||
activeSince: startTime + 1_000,
|
||||
activityLabel: "bash",
|
||||
}
|
||||
: { waitingSince: startTime + 1_000 }),
|
||||
},
|
||||
startTime + 1_000,
|
||||
);
|
||||
} else if (kind === "stalled") {
|
||||
statusState = observeStatus(
|
||||
statusState,
|
||||
{ snapshot: "missing" },
|
||||
startTime + 1_000,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
agent: "worker",
|
||||
task: "",
|
||||
surface: "surface",
|
||||
startTime,
|
||||
sessionFile: `${id}.jsonl`,
|
||||
statusState,
|
||||
};
|
||||
}
|
||||
|
||||
function createActiveWidgetAgent(id: string) {
|
||||
return createWidgetAgent(id, "active");
|
||||
}
|
||||
|
||||
it("keeps every rendered line within a very narrow width", () => {
|
||||
const testApi = (subagentsModule as any).__test__;
|
||||
assert.ok(testApi, "expected subagents test helpers to be exported");
|
||||
@@ -2663,13 +2737,33 @@ describe("subagents widget rendering", () => {
|
||||
|
||||
assert.deepEqual(
|
||||
lines.map((line: string) => visibleWidth(line)),
|
||||
[16, 16, 16, 16, 16],
|
||||
[16, 16, 16, 16],
|
||||
);
|
||||
assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line)));
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
|
||||
it("sanitizes whitespace in agent names before rendering rows", () => {
|
||||
const testApi = (subagentsModule as any).__test__;
|
||||
const startTime = Date.now() - 5_000;
|
||||
const lines = testApi.renderSubagentWidgetLines([
|
||||
{
|
||||
id: "unsafe",
|
||||
name: "worker\nwith\ttabs",
|
||||
task: "",
|
||||
surface: "s1",
|
||||
startTime,
|
||||
sessionFile: "sess1",
|
||||
statusState: createStatusState({ source: "pi", startTimeMs: startTime }),
|
||||
},
|
||||
], 80);
|
||||
|
||||
assert.ok(lines.every((line: string) => !/[\r\n]/.test(line)));
|
||||
assert.match(lines[1] ?? "", /worker with tabs/);
|
||||
});
|
||||
|
||||
it("truncates the right-hand status instead of overflowing when it alone is too wide", () => {
|
||||
const testApi = (subagentsModule as any).__test__;
|
||||
assert.ok(testApi, "expected subagents test helpers to be exported");
|
||||
@@ -2707,6 +2801,276 @@ describe("subagents widget rendering", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the standalone widget factory theme for semantic status colors", () => {
|
||||
const testApi = (subagentsModule as any).__test__;
|
||||
const widgetCalls: any[] = [];
|
||||
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
|
||||
shared.latestCtx = {
|
||||
hasUI: true,
|
||||
ui: { setWidget: (...args: any[]) => widgetCalls.push(args) },
|
||||
};
|
||||
shared.runningSubagents.set("active", createActiveWidgetAgent("active"));
|
||||
|
||||
testApi.updateWidget();
|
||||
const { theme, calls } = createThemeSpy();
|
||||
const component = widgetCalls[0][1](undefined, theme);
|
||||
const lines = withMockedNow(1_000_000, () => component.render(80));
|
||||
|
||||
assert.equal(lines.length, 2, "header plus one body row");
|
||||
assert.equal(calls.find((call) => call.text === "Subagents")?.color, "accent");
|
||||
assert.equal(calls.find((call) => call.text === "1 running")?.color, "muted");
|
||||
assert.ok(calls.some((call) => call.color === "warning" && call.text.includes("active")));
|
||||
assert.equal(calls.find((call) => call.text === " (worker)")?.color, "dim");
|
||||
assert.ok(calls.some((call) => call.color === "dim" && call.text === "00:05"));
|
||||
assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line)));
|
||||
});
|
||||
|
||||
it("uses the optional theme passed to hosted rendering", () => {
|
||||
const testApi = (subagentsModule as any).__test__;
|
||||
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
|
||||
shared.runningSubagents.set("active", createActiveWidgetAgent("active"));
|
||||
const { theme, calls } = createThemeSpy();
|
||||
|
||||
const lines = withMockedNow(1_000_000, () => testApi.statusWidget.render(80, undefined, theme));
|
||||
|
||||
assert.equal(lines.length, 2, "header plus one body row");
|
||||
assert.ok(calls.some((call) => call.color === "accent" && call.text === "Subagents"));
|
||||
assert.ok(calls.some((call) => call.color === "muted" && call.text === "1 running"));
|
||||
assert.ok(calls.some((call) => call.color === "warning" && call.text.includes("active")));
|
||||
assert.ok(calls.some((call) => call.color === "dim" && call.text === "00:05"));
|
||||
assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line)));
|
||||
});
|
||||
|
||||
it("maps every status kind to a semantic row color", () => {
|
||||
const testApi = (subagentsModule as any).__test__;
|
||||
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
|
||||
for (const [id, kind] of [
|
||||
["active", "active"],
|
||||
["running", "running"],
|
||||
["stalled", "stalled"],
|
||||
["starting", "starting"],
|
||||
["waiting", "waiting"],
|
||||
] as const) {
|
||||
shared.runningSubagents.set(id, createWidgetAgent(id, kind));
|
||||
}
|
||||
const { theme, calls } = createThemeSpy();
|
||||
|
||||
withMockedNow(1_061_000, () => testApi.statusWidget.render(120, undefined, theme));
|
||||
|
||||
const expected = {
|
||||
active: "warning",
|
||||
running: "warning",
|
||||
stalled: "error",
|
||||
starting: "dim",
|
||||
waiting: "accent",
|
||||
} as const;
|
||||
for (const [id, kind] of [
|
||||
["active", "active"],
|
||||
["running", "running"],
|
||||
["stalled", "stalled"],
|
||||
["starting", "starting"],
|
||||
["waiting", "waiting"],
|
||||
] as const) {
|
||||
assert.ok(
|
||||
calls.some((call) => call.text === id && call.color === expected[kind]),
|
||||
`${id} should use ${expected[kind]}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("status widget host integration", () => {
|
||||
const testApi = (subagentsModule as any).__test__;
|
||||
const statusWidget = (globalThis as any).__pi_interactive_subagents.statusWidget;
|
||||
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
|
||||
|
||||
function addRunning(id: string) {
|
||||
const startTime = Date.now();
|
||||
shared.runningSubagents.set(id, {
|
||||
id,
|
||||
name: id,
|
||||
task: "",
|
||||
surface: "surface",
|
||||
startTime,
|
||||
sessionFile: `${id}.jsonl`,
|
||||
statusState: createStatusState({ source: "pi", startTimeMs: startTime }),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => resetSharedRuntimeForTest(shared));
|
||||
after(() => resetSharedRuntimeForTest(shared));
|
||||
|
||||
it("falls back to the standalone widget when unclaimed", () => {
|
||||
const widgetCalls: any[] = [];
|
||||
shared.latestCtx = {
|
||||
hasUI: true,
|
||||
ui: { setWidget: (...args: any[]) => widgetCalls.push(args) },
|
||||
};
|
||||
addRunning("worker");
|
||||
|
||||
testApi.updateWidget();
|
||||
|
||||
assert.equal(widgetCalls.length, 1);
|
||||
assert.equal(widgetCalls[0][0], "subagent-status");
|
||||
assert.equal(typeof widgetCalls[0][1], "function");
|
||||
});
|
||||
|
||||
it("suppresses, notifies, and restores the standalone widget around a claim", async () => {
|
||||
const widgetCalls: any[] = [];
|
||||
shared.latestCtx = {
|
||||
hasUI: true,
|
||||
ui: { setWidget: (...args: any[]) => widgetCalls.push(args) },
|
||||
};
|
||||
addRunning("worker");
|
||||
testApi.updateWidget();
|
||||
|
||||
let changes = 0;
|
||||
const onChange = () => changes++;
|
||||
const release = statusWidget.claim(onChange);
|
||||
assert.equal(statusWidget.isClaimed(onChange), true);
|
||||
assert.equal(statusWidget.isClaimed(() => {}), false);
|
||||
assert.equal(typeof widgetCalls.at(-1)[1], "function", "claim sync is deferred");
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(widgetCalls.at(-1)[1], undefined);
|
||||
|
||||
testApi.updateWidget();
|
||||
assert.equal(changes, 1);
|
||||
|
||||
release();
|
||||
assert.equal(statusWidget.isClaimed(onChange), false);
|
||||
assert.equal(widgetCalls.at(-1)[1], undefined, "release sync is deferred");
|
||||
await Promise.resolve();
|
||||
assert.equal(typeof widgetCalls.at(-1)[1], "function");
|
||||
release();
|
||||
assert.equal(widgetCalls.length, 4, "release is idempotent");
|
||||
});
|
||||
|
||||
it("does not restore standalone UI between an immediate release and re-claim", async () => {
|
||||
const widgetCalls: any[] = [];
|
||||
shared.latestCtx = {
|
||||
hasUI: true,
|
||||
ui: { setWidget: (...args: any[]) => widgetCalls.push(args) },
|
||||
};
|
||||
addRunning("worker");
|
||||
testApi.updateWidget();
|
||||
|
||||
const firstChange = () => {};
|
||||
const releaseFirst = statusWidget.claim(firstChange);
|
||||
releaseFirst();
|
||||
const secondChange = () => {};
|
||||
const releaseSecond = statusWidget.claim(secondChange);
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(statusWidget.isClaimed(firstChange), false);
|
||||
assert.equal(statusWidget.isClaimed(secondChange), true);
|
||||
assert.equal(widgetCalls.at(-1)[1], undefined);
|
||||
releaseSecond();
|
||||
await Promise.resolve();
|
||||
assert.equal(typeof widgetCalls.at(-1)[1], "function");
|
||||
});
|
||||
|
||||
it("caps body rows with an honest overflow row", () => {
|
||||
addRunning("worker");
|
||||
addRunning("scout");
|
||||
addRunning("reviewer");
|
||||
|
||||
const lines = statusWidget.render(40, 2);
|
||||
assert.equal(lines.length, 3, "header + one agent + overflow");
|
||||
assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line)));
|
||||
assert.match(lines[2], /\+2 more running\./);
|
||||
assert.equal(statusWidget.getBodyRowCount(), 3);
|
||||
assert.equal(statusWidget.hasContent(), true);
|
||||
});
|
||||
|
||||
it("keeps capped output within narrow widths", () => {
|
||||
addRunning("worker");
|
||||
addRunning("scout");
|
||||
|
||||
for (const width of [0, 1, 2, 4, 8]) {
|
||||
for (const line of statusWidget.render(width, 1)) {
|
||||
assert.ok(
|
||||
visibleWidth(line) <= width,
|
||||
`expected line width <= ${width}, got ${visibleWidth(line)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("drops old claims during reload and allows a fresh runtime to claim", async () => {
|
||||
const oldRuntime = createMockExtensionApi();
|
||||
(subagentsModule as any).default(oldRuntime.api);
|
||||
const oldCtx = {
|
||||
hasUI: true,
|
||||
ui: { setWidget() {} },
|
||||
};
|
||||
await emitMockEvent(oldRuntime, "session_start", { reason: "startup" }, oldCtx);
|
||||
addRunning("worker");
|
||||
|
||||
let oldChanges = 0;
|
||||
const oldOnChange = () => oldChanges++;
|
||||
const oldRelease = statusWidget.claim(oldOnChange);
|
||||
|
||||
// Bind the replacement before the old runtime emits shutdown. The old
|
||||
// shutdown must not undo the fresh binding or retain its old callback.
|
||||
const freshRuntime = createMockExtensionApi();
|
||||
(subagentsModule as any).default(freshRuntime.api);
|
||||
await emitMockEvent(freshRuntime, "session_start", { reason: "reload" }, oldCtx);
|
||||
// A delayed old start must also be ignored once the fresh runtime is bound.
|
||||
await emitMockEvent(oldRuntime, "session_start", { reason: "reload" }, oldCtx);
|
||||
assert.equal(shared.latestPi, freshRuntime.api);
|
||||
assert.equal(statusWidget.isClaimed(oldOnChange), false);
|
||||
await emitMockEvent(oldRuntime, "session_shutdown", { reason: "reload" }, oldCtx);
|
||||
testApi.updateWidget();
|
||||
|
||||
assert.equal(oldChanges, 0);
|
||||
assert.equal(shared.statusWidgetClaim, null);
|
||||
assert.equal(shared.runningSubagents.size, 1);
|
||||
|
||||
let freshChanges = 0;
|
||||
const freshRelease = statusWidget.claim(() => freshChanges++);
|
||||
oldRelease();
|
||||
testApi.updateWidget();
|
||||
assert.equal(freshChanges, 1, "a stale release cannot affect the fresh claim");
|
||||
freshRelease();
|
||||
});
|
||||
|
||||
it("replaces malformed and old widget contracts on a duplicate load", async () => {
|
||||
const globalApi = (globalThis as any).__pi_interactive_subagents;
|
||||
(globalThis as any).__pi_interactive_subagents = "old-api";
|
||||
shared.statusWidgetClaim = { id: 1, onChange: () => {} };
|
||||
await import(`../pi-extension/subagents/index.ts?malformed-global=${Date.now()}`);
|
||||
const repairedApi = (globalThis as any).__pi_interactive_subagents;
|
||||
assert.equal(typeof repairedApi.registerToolExtension, "function");
|
||||
assert.equal(repairedApi.statusWidget, globalApi.statusWidget);
|
||||
|
||||
shared.statusWidgetClaim = { id: 1, onChange: () => {} };
|
||||
await import(`../pi-extension/subagents/index.ts?old-widget-claim=${Date.now()}`);
|
||||
assert.equal(shared.statusWidgetClaim, null);
|
||||
|
||||
const oldContract = {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 1,
|
||||
render: () => [],
|
||||
claim: () => () => {},
|
||||
};
|
||||
shared.statusWidget = oldContract;
|
||||
(globalThis as any).__pi_interactive_subagents.statusWidget = oldContract;
|
||||
|
||||
await import(`../pi-extension/subagents/index.ts?old-widget-contract=${Date.now()}`);
|
||||
|
||||
const currentApi = (globalThis as any).__pi_interactive_subagents;
|
||||
const current = currentApi.statusWidget;
|
||||
assert.equal(currentApi, repairedApi);
|
||||
assert.notEqual(current, oldContract);
|
||||
assert.equal(typeof current.hasContent, "function");
|
||||
assert.equal(typeof current.getBodyRowCount, "function");
|
||||
assert.equal(typeof current.render, "function");
|
||||
assert.equal(typeof current.claim, "function");
|
||||
assert.equal(typeof current.isClaimed, "function");
|
||||
assert.equal(shared.statusWidget, current);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subagent display helpers", () => {
|
||||
|
||||
Reference in New Issue
Block a user