mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
554 lines
23 KiB
TypeScript
554 lines
23 KiB
TypeScript
import { Editor, visibleWidth } from "@earendil-works/pi-tui";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type { AgentManager } from "../src/agent-manager.js";
|
|
import { registerAgents } from "../src/agent-types.js";
|
|
import type { AgentConfig, AgentRecord } from "../src/types.js";
|
|
import { type AgentActivity, getDisplayName } from "../src/ui/agent-widget.js";
|
|
import { FleetList, type FleetUICtx, formatFleetElapsed, formatFleetTokens } from "../src/ui/fleet-list.js";
|
|
|
|
// ---- Key sequences (see node_modules/@earendil-works/pi-tui/dist/keys.js) ----
|
|
const DOWN = "\x1b[B";
|
|
const UP = "\x1b[A";
|
|
const LEFT = "\x1b[D";
|
|
const RIGHT = "\x1b[C";
|
|
const ESC = "\x1b";
|
|
const ENTER = "\r";
|
|
// Kitty-protocol key-RELEASE for ↓ (event type 3) — listeners receive these too.
|
|
const DOWN_RELEASE = "\x1b[1;1:3B";
|
|
|
|
const theme = { fg: (c: string, s: string) => `<${c}>${s}</${c}>`, bold: (s: string) => `*${s}*` };
|
|
|
|
/** An agent that renders as a badge — no default agent configures a color. */
|
|
const BADGED_TYPE = "colored-reviewer";
|
|
const PURPLE_BACKGROUND = "\u001b[48;2;130;125;189m";
|
|
const BADGED_CONFIG: AgentConfig = {
|
|
name: BADGED_TYPE,
|
|
displayName: "Code Reviewer",
|
|
color: "purple",
|
|
description: "Reviews code",
|
|
extensions: false,
|
|
skills: false,
|
|
systemPrompt: "Review code.",
|
|
promptMode: "replace",
|
|
};
|
|
|
|
/**
|
|
* Visible text of a rendered row: ANSI stripped, along with this theme's fake
|
|
* `<color>` / `*bold*` markers — all three stand in for zero-width escapes.
|
|
*/
|
|
function plain(row: string): string {
|
|
return row.replace(/\u001b\[[0-9;]*m/g, "").replace(/<\/?[a-zA-Z]+>|\*/g, "");
|
|
}
|
|
|
|
/** A no-op session so a record is "openable" by default (the list hides session-less agents). */
|
|
const FAKE_SESSION = { subscribe: () => () => {}, messages: [] };
|
|
|
|
function makeRecord(over: Partial<AgentRecord> = {}): AgentRecord {
|
|
return {
|
|
id: "a1",
|
|
type: "general-purpose",
|
|
description: "Sleep then report 1",
|
|
status: "running",
|
|
toolUses: 0,
|
|
startedAt: Date.now(),
|
|
session: FAKE_SESSION as any,
|
|
lifetimeUsage: { input: 13100, output: 0, cacheWrite: 0 },
|
|
compactionCount: 0,
|
|
...over,
|
|
} as AgentRecord;
|
|
}
|
|
|
|
/** Fake manager exposing only what FleetList touches. */
|
|
function fakeManager(agents: AgentRecord[]): AgentManager {
|
|
return {
|
|
listAgents: () => agents,
|
|
abort: () => true,
|
|
steer: vi.fn(() => true),
|
|
} as unknown as AgentManager;
|
|
}
|
|
|
|
interface Harness {
|
|
fleet: FleetList;
|
|
ui: FleetUICtx;
|
|
manager: AgentManager;
|
|
/** The overlay component (a real ConversationViewer) once one is opened. */
|
|
overlayComponent: () => { handleInput(data: string): void } | undefined;
|
|
/** Feed a key to the registered input handler; returns the consume result. */
|
|
press: (data: string) => { consume?: boolean } | undefined;
|
|
/** Render the currently-registered below-editor widget at the given width. */
|
|
render: (width?: number) => string[];
|
|
setEditorText: (t: string) => void;
|
|
/** Whether an overlay has been opened. */
|
|
overlayOpened: () => boolean;
|
|
/** Whether the most recently opened overlay's `done` was invoked (closed). */
|
|
overlayClosed: () => boolean;
|
|
/** Simulate the viewer closing itself (Esc → done); flushes the close microtask. */
|
|
closeOverlay: () => Promise<void>;
|
|
/** The fake `tui` handed to the widget factory; tests set `focusedComponent` on it. */
|
|
widgetTui: { requestRender(): void; focusedComponent?: unknown };
|
|
}
|
|
|
|
function harness(agents: AgentRecord[]): Harness {
|
|
let inputHandler: ((data: string) => { consume?: boolean } | undefined) | undefined;
|
|
let widgetFactory: ((tui: any, theme: any) => { render(w: number): string[] }) | undefined;
|
|
let editorText = "";
|
|
let opened = false;
|
|
let closed = false;
|
|
let overlayDone: ((r: undefined) => void) | undefined;
|
|
let overlayComponent: { handleInput(data: string): void } | undefined;
|
|
const fakeTui = { requestRender: () => {}, terminal: { columns: 120, rows: 40 } };
|
|
|
|
const ui: FleetUICtx = {
|
|
setWidget: (_key, content) => { widgetFactory = content as any; },
|
|
onTerminalInput: (h) => { inputHandler = h; return () => { inputHandler = undefined; }; },
|
|
getEditorText: () => editorText,
|
|
notify: () => {},
|
|
custom: ((factory: any) => {
|
|
opened = true;
|
|
return new Promise<undefined>((resolve) => {
|
|
const done = (r: undefined) => { closed = true; overlayDone = undefined; resolve(r); };
|
|
overlayDone = done;
|
|
// Construct the overlay component so the controller wires viewerClose,
|
|
// and keep it so tests can drive the real ConversationViewer's input.
|
|
overlayComponent = factory(fakeTui, theme, undefined, done);
|
|
});
|
|
}) as FleetUICtx["custom"],
|
|
};
|
|
|
|
const manager = fakeManager(agents);
|
|
const fleet = new FleetList(manager, new Map());
|
|
fleet.setUICtx(ui);
|
|
fleet.update();
|
|
|
|
return {
|
|
fleet,
|
|
ui,
|
|
manager,
|
|
overlayComponent: () => overlayComponent,
|
|
press: (data) => inputHandler?.(data),
|
|
render: (width = 120) => (widgetFactory ? widgetFactory(fakeTui, theme).render(width) : []),
|
|
setEditorText: (t) => { editorText = t; },
|
|
overlayOpened: () => opened,
|
|
overlayClosed: () => closed,
|
|
closeOverlay: async () => { overlayDone?.(undefined); await Promise.resolve(); },
|
|
widgetTui: fakeTui,
|
|
};
|
|
}
|
|
|
|
describe("formatFleetElapsed", () => {
|
|
it("renders integer seconds (no decimal, no suffix)", () => {
|
|
expect(formatFleetElapsed(0)).toBe("0s");
|
|
expect(formatFleetElapsed(11_000)).toBe("11s");
|
|
expect(formatFleetElapsed(11_400)).toBe("11s");
|
|
expect(formatFleetElapsed(11_600)).toBe("12s");
|
|
});
|
|
it("floors negatives to 0s", () => {
|
|
expect(formatFleetElapsed(-500)).toBe("0s");
|
|
});
|
|
});
|
|
|
|
describe("formatFleetTokens", () => {
|
|
it("prefixes a down-arrow and uses plural 'tokens'", () => {
|
|
expect(formatFleetTokens(13_100)).toBe("↓ 13.1k tokens");
|
|
expect(formatFleetTokens(950)).toBe("↓ 950 tokens");
|
|
expect(formatFleetTokens(1_200_000)).toBe("↓ 1.2M tokens");
|
|
});
|
|
});
|
|
|
|
describe("FleetList navigation", () => {
|
|
it("does not register a widget when there are no agents", () => {
|
|
const h = harness([]);
|
|
expect(h.render()).toEqual([]);
|
|
});
|
|
|
|
it("hides nested child records from the coordinator fleet", () => {
|
|
const h = harness([
|
|
makeRecord({ id: "top", description: "top-level" }),
|
|
makeRecord({ id: "nested", description: "nested-child", parentAgentId: "top" }),
|
|
]);
|
|
const output = h.render().join("\n");
|
|
expect(output).toContain("top-level");
|
|
expect(output).not.toContain("nested-child");
|
|
});
|
|
|
|
it("activates on ↓ at an empty prompt, consuming the key", () => {
|
|
const h = harness([makeRecord()]);
|
|
const res = h.press(DOWN);
|
|
expect(res).toEqual({ consume: true });
|
|
// main selected, list active → nav hint shown
|
|
expect(h.render().some(l => l.includes("enter view"))).toBe(true);
|
|
});
|
|
|
|
it("also activates on ← (matches the '← for agents' hint)", () => {
|
|
const h = harness([makeRecord()]);
|
|
expect(h.press(LEFT)).toEqual({ consume: true });
|
|
});
|
|
|
|
it("does NOT activate when the prompt is non-empty (typing is preserved)", () => {
|
|
const h = harness([makeRecord()]);
|
|
h.setEditorText("hello");
|
|
expect(h.press(DOWN)).toBeUndefined();
|
|
});
|
|
|
|
it("ignores key-release events so one tap moves exactly one row", () => {
|
|
const h = harness([
|
|
makeRecord({ id: "a1", description: "one" }),
|
|
makeRecord({ id: "a2", description: "two" }),
|
|
]);
|
|
h.press(DOWN); // activate → selection on main (idx 0)
|
|
h.press(DOWN_RELEASE); // release half of the SAME tap — must be a no-op
|
|
expect(h.render().find(l => l.includes("main"))).toContain("●");
|
|
h.press(DOWN); // a real second tap → first agent
|
|
h.press(DOWN_RELEASE);
|
|
expect(h.render().find(l => l.includes("one"))).toContain("●");
|
|
expect(h.render().find(l => l.includes("two"))).toContain("○");
|
|
});
|
|
|
|
it("renders the whole selected row in the theme's primary text color (#230)", () => {
|
|
const h = harness([
|
|
makeRecord({ id: "a1", description: "one" }),
|
|
makeRecord({ id: "a2", description: "two" }),
|
|
]);
|
|
h.press(DOWN); // activate → main
|
|
h.press(DOWN); // → a1
|
|
const selected = h.render().find(l => l.includes("one"))!;
|
|
// Selection marker keeps accent color; row content uses primary text color.
|
|
expect(selected).toContain("<accent>●</accent>");
|
|
expect(selected).toContain("<text>one</text>");
|
|
expect(selected).toMatch(/<text>\d+s · ↓ [\d.]+k? tokens<\/text>/);
|
|
// Agent display name rendered with the text token too (this type has no badge).
|
|
expect(selected).toContain(`<text>${getDisplayName("general-purpose")}</text>`);
|
|
// Inactive rows keep the muted/dim treatment.
|
|
const unselected = h.render().find(l => l.includes("two"))!;
|
|
expect(unselected).toContain("<dim>○</dim>");
|
|
expect(unselected).toMatch(/<dim>\d+s · ↓ [\d.]+k? tokens<\/dim>/);
|
|
expect(unselected).not.toContain("<text>");
|
|
});
|
|
|
|
it("keeps a color badge on the selected row, bolded, without shifting it (#230)", () => {
|
|
registerAgents(new Map([[BADGED_TYPE, BADGED_CONFIG]]));
|
|
try {
|
|
const h = harness([
|
|
makeRecord({ id: "a1", type: BADGED_TYPE, description: "one" }),
|
|
makeRecord({ id: "a2", type: BADGED_TYPE, description: "two" }),
|
|
]);
|
|
h.press(DOWN); // activate → main
|
|
const before = h.render().find(l => l.includes("one"))!;
|
|
expect(before).toContain(`${PURPLE_BACKGROUND}`);
|
|
expect(before).toContain(` ${BADGED_CONFIG.displayName} `);
|
|
|
|
h.press(DOWN); // → a1
|
|
const selected = h.render().find(l => l.includes("one"))!;
|
|
// Selection bolds the badge rather than repainting it (Claude Code's FleetView) …
|
|
expect(selected).toContain(PURPLE_BACKGROUND);
|
|
expect(selected).toContain(`* ${BADGED_CONFIG.displayName} *`);
|
|
expect(selected).not.toContain(`<text>${BADGED_CONFIG.displayName}`);
|
|
// … so the description stays in the same column as when unselected.
|
|
expect(plain(selected).indexOf("one")).toBe(plain(before).indexOf("one"));
|
|
} finally {
|
|
registerAgents(new Map());
|
|
}
|
|
});
|
|
|
|
it("moves selection down/up and clamps at the ends", () => {
|
|
const agents = [
|
|
makeRecord({ id: "a1", description: "one" }),
|
|
makeRecord({ id: "a2", description: "two" }),
|
|
];
|
|
const h = harness(agents);
|
|
h.press(DOWN); // activate → index 0 (main)
|
|
h.press(DOWN); // → 1 (a1)
|
|
expect(h.render().find(l => l.includes("one"))).toContain("●");
|
|
h.press(DOWN); // → 2 (a2)
|
|
h.press(DOWN); // clamp at 2
|
|
expect(h.render().find(l => l.includes("two"))).toContain("●");
|
|
expect(h.render().find(l => l.includes("one"))).toContain("○");
|
|
});
|
|
|
|
it("↑ above 'main' deactivates (returns to the prompt)", () => {
|
|
const h = harness([makeRecord()]);
|
|
h.press(DOWN); // activate, index 0
|
|
expect(h.press(UP)).toEqual({ consume: true });
|
|
// back to inactive hint
|
|
expect(h.render().some(l => l.includes("← for agents"))).toBe(true);
|
|
});
|
|
|
|
it("Esc deactivates", () => {
|
|
const h = harness([makeRecord()]);
|
|
h.press(DOWN);
|
|
expect(h.press(ESC)).toEqual({ consume: true });
|
|
expect(h.render().some(l => l.includes("← for agents"))).toBe(true);
|
|
});
|
|
|
|
it("passes non-nav keys through and cancels navigation", () => {
|
|
const h = harness([makeRecord()]);
|
|
h.press(DOWN);
|
|
expect(h.press(RIGHT)).toBeUndefined();
|
|
expect(h.render().some(l => l.includes("← for agents"))).toBe(true);
|
|
});
|
|
|
|
it("ignores all input while disabled and hides the widget", () => {
|
|
const h = harness([makeRecord()]);
|
|
h.fleet.setEnabled(false);
|
|
expect(h.press(DOWN)).toBeUndefined();
|
|
expect(h.render()).toEqual([]);
|
|
});
|
|
|
|
it("re-arms the refresh timer when the list is re-shown (toggle off→on)", () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const agents = [makeRecord({ id: "a1" })];
|
|
const listAgents = vi.fn(() => agents);
|
|
const manager = { listAgents, abort: () => true } as unknown as AgentManager;
|
|
const fleet = new FleetList(manager, new Map());
|
|
fleet.setUICtx({
|
|
setWidget: () => {}, onTerminalInput: () => () => {}, getEditorText: () => "",
|
|
notify: () => {}, custom: (() => new Promise<undefined>(() => {})) as FleetUICtx["custom"],
|
|
});
|
|
fleet.update(); // shows list, arms the timer
|
|
fleet.setEnabled(false); // hides, clears the timer
|
|
fleet.setEnabled(true); // re-shows — must re-arm the timer
|
|
const before = listAgents.mock.calls.length;
|
|
vi.advanceTimersByTime(250); // a tick should fire and re-read the roster
|
|
expect(listAgents.mock.calls.length).toBeGreaterThan(before);
|
|
fleet.dispose();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("FleetList vs other focused components (#123)", () => {
|
|
// pi dispatches terminal input to extension listeners BEFORE the focused
|
|
// component (pi-tui TUI.handleInput), and ctx.ui.select/confirm/input swap
|
|
// the prompt editor out of the editor container while getEditorText() still
|
|
// reads the detached (empty) editor. So while another component owns the
|
|
// keyboard — another extension's selector (rpiv-ask-user-question), pi's own
|
|
// menus, our /agents settings — the list must not consume its keys.
|
|
|
|
/** A minimal real Editor — what pi focuses at the prompt (CustomEditor extends it). */
|
|
function realEditor(): Editor {
|
|
const fakeTui = { requestRender: () => {} };
|
|
const theme = { borderColor: (s: string) => s, selectList: {} };
|
|
return new Editor(fakeTui as any, theme as any);
|
|
}
|
|
|
|
/** Hand the fleet list its `tui` (happens on first widget render in pi) with the given focus. */
|
|
function focusInHarness(h: Harness, focused: unknown): void {
|
|
h.widgetTui.focusedComponent = focused;
|
|
h.render();
|
|
}
|
|
|
|
it("does not steal ↓ from a focused selector (activation)", () => {
|
|
const h = harness([makeRecord()]);
|
|
focusInHarness(h, { kind: "selector" }); // e.g. ExtensionSelectorComponent
|
|
expect(h.press(DOWN)).toBeUndefined(); // must flow through to the selector
|
|
});
|
|
|
|
it("does not steal navigation keys from a selector opened while the list was active", () => {
|
|
const h = harness([makeRecord()]);
|
|
focusInHarness(h, realEditor());
|
|
expect(h.press(DOWN)).toEqual({ consume: true }); // activate at the prompt
|
|
focusInHarness(h, { kind: "selector" }); // a dialog takes focus
|
|
expect(h.press(DOWN)).toBeUndefined();
|
|
expect(h.press(ENTER)).toBeUndefined();
|
|
expect(h.press(ESC)).toBeUndefined();
|
|
// and the list dropped back to its inactive hint
|
|
expect(h.render().some(l => l.includes("← for agents"))).toBe(true);
|
|
});
|
|
|
|
it("still activates when the prompt editor has focus", () => {
|
|
const h = harness([makeRecord()]);
|
|
focusInHarness(h, realEditor());
|
|
expect(h.press(DOWN)).toEqual({ consume: true });
|
|
});
|
|
|
|
it("assumes the editor when focus is unknowable (no tui yet / nothing focused)", () => {
|
|
const h = harness([makeRecord()]);
|
|
// No render yet → the list has never seen a tui: activation must still work.
|
|
expect(h.press(DOWN)).toEqual({ consume: true });
|
|
});
|
|
});
|
|
|
|
describe("FleetList rendering", () => {
|
|
it("renders main + agent rows with markers, type, description and right-aligned stats", () => {
|
|
const h = harness([makeRecord({ description: "Sleep then report 1" })]);
|
|
const lines = h.render(120);
|
|
// hint + blank + main + one agent
|
|
expect(lines[0]).toContain("← for agents");
|
|
expect(lines.find(l => l.includes("main"))).toContain("●"); // main selected by default
|
|
const agentLine = lines.find(l => l.includes("Sleep then report 1"))!;
|
|
expect(agentLine).toContain("○");
|
|
expect(agentLine).toContain(getDisplayName("general-purpose"));
|
|
expect(agentLine).toContain("↓ 13.1k tokens");
|
|
expect(agentLine).toMatch(/\d+s · ↓/); // "<seconds>s · ↓ ..." (timing-agnostic)
|
|
});
|
|
|
|
it("orders agents earliest-launched first (top)", () => {
|
|
const agents = [
|
|
makeRecord({ id: "new", description: "newest", startedAt: 2000 }),
|
|
makeRecord({ id: "old", description: "oldest", startedAt: 1000 }),
|
|
];
|
|
const lines = harness(agents).render();
|
|
const oldIdx = lines.findIndex(l => l.includes("oldest"));
|
|
const newIdx = lines.findIndex(l => l.includes("newest"));
|
|
expect(oldIdx).toBeGreaterThanOrEqual(0);
|
|
expect(oldIdx).toBeLessThan(newIdx); // earliest sits above the later one
|
|
});
|
|
|
|
it("hides agents that have no session yet (pending)", () => {
|
|
const agents = [
|
|
makeRecord({ id: "live", description: "running one" }),
|
|
makeRecord({ id: "pending", description: "queued one", status: "queued", session: undefined }),
|
|
];
|
|
const lines = harness(agents).render();
|
|
expect(lines.some(l => l.includes("running one"))).toBe(true);
|
|
expect(lines.some(l => l.includes("queued one"))).toBe(false);
|
|
});
|
|
|
|
it("collapses overflow into a '↓ N more' indicator", () => {
|
|
const agents = Array.from({ length: 8 }, (_, i) =>
|
|
makeRecord({ id: `a${i}`, description: `report ${i}` }));
|
|
const h = harness(agents);
|
|
const lines = h.render(120);
|
|
// 8 agents, cap 5 visible → "↓ 3 more"
|
|
expect(lines.some(l => l.includes("↓ 3 more"))).toBe(true);
|
|
});
|
|
|
|
it("never emits a line wider than the terminal (guards wrap-induced flicker)", () => {
|
|
const agents = Array.from({ length: 8 }, (_, i) =>
|
|
makeRecord({ id: `a${i}`, description: `a very long agent description number ${i} that keeps going` }));
|
|
const h = harness(agents);
|
|
for (const w of [4, 8, 12, 20, 40, 80, 200]) {
|
|
for (const line of h.render(w)) {
|
|
expect(visibleWidth(line)).toBeLessThanOrEqual(w);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("windows the visible agents so the selection stays on screen", () => {
|
|
const agents = Array.from({ length: 8 }, (_, i) =>
|
|
makeRecord({ id: `a${i}`, description: `report ${i}` }));
|
|
const h = harness(agents);
|
|
h.press(DOWN); // activate (main)
|
|
// step down to the last agent (8 agents → roster index 8)
|
|
for (let i = 0; i < 8; i++) h.press(DOWN);
|
|
const lines = h.render(120);
|
|
expect(lines.find(l => l.includes("report 7"))).toContain("●");
|
|
expect(lines.some(l => l.includes("↑"))).toBe(true); // hidden-above indicator
|
|
});
|
|
});
|
|
|
|
describe("FleetList overlay lifecycle", () => {
|
|
it("Enter on 'main' just deactivates (no overlay)", () => {
|
|
const h = harness([makeRecord()]);
|
|
h.press(DOWN); // active, index 0 (main)
|
|
h.press(ENTER);
|
|
expect(h.overlayOpened()).toBe(false); // never opened an overlay
|
|
expect(h.render().some(l => l.includes("← for agents"))).toBe(true);
|
|
});
|
|
|
|
it("keeps the cursor on the viewed agent after closing, even if the list reordered", async () => {
|
|
const fakeSession = { subscribe: () => () => {}, messages: [] };
|
|
const agents = [
|
|
makeRecord({ id: "a1", description: "one", session: fakeSession as any }),
|
|
makeRecord({ id: "a2", description: "two", session: fakeSession as any }),
|
|
makeRecord({ id: "a3", description: "three", session: fakeSession as any }),
|
|
];
|
|
const h = harness(agents);
|
|
h.press(DOWN); // activate (main, idx 0)
|
|
h.press(DOWN); // a1 (idx 1)
|
|
h.press(DOWN); // a2 (idx 2)
|
|
h.press(ENTER); // open a2
|
|
// a1 finishes and drops out while viewing → a2 shifts from idx 2 to idx 1.
|
|
agents.splice(0, 1);
|
|
await h.closeOverlay();
|
|
// Selection follows a2 ("two") to its new position, not whatever is at idx 2 now.
|
|
expect(h.render().find(l => l.includes("two"))).toContain("●");
|
|
expect(h.render().find(l => l.includes("three"))).toContain("○");
|
|
});
|
|
|
|
it("wires the viewer's steer composer to manager.steer with the agent id", () => {
|
|
const agents = [makeRecord({ id: "live", description: "the one" })];
|
|
const h = harness(agents);
|
|
h.press(DOWN); // activate (main)
|
|
h.press(DOWN); // → the agent
|
|
h.press(ENTER); // open the conversation viewer
|
|
|
|
const viewer = h.overlayComponent();
|
|
expect(viewer).toBeDefined();
|
|
viewer!.handleInput("\r"); // Enter → open composer
|
|
for (const ch of "go left") viewer!.handleInput(ch);
|
|
viewer!.handleInput("\r"); // Enter → send
|
|
|
|
expect(h.manager.steer).toHaveBeenCalledWith("live", "go left");
|
|
});
|
|
|
|
it("does NOT auto-close when the viewed agent finishes (final output stays readable)", () => {
|
|
const agents = [makeRecord({ id: "live", description: "the one" })];
|
|
const h = harness(agents);
|
|
h.press(DOWN); // active (main)
|
|
h.press(DOWN); // → the agent
|
|
h.press(ENTER); // opens overlay
|
|
expect(h.overlayOpened()).toBe(true);
|
|
// The agent finishes, well past the linger window...
|
|
agents[0] = makeRecord({ id: "live", description: "the one", status: "completed", completedAt: Date.now() - 60_000 });
|
|
h.fleet.onAgentFinished("live");
|
|
expect(h.overlayClosed()).toBe(false); // viewer stays open
|
|
expect(h.render().some(l => l.includes("the one"))).toBe(true); // and stays listed while viewed
|
|
});
|
|
|
|
it("lingers a finished agent in the list, then drops it after the window", () => {
|
|
const recent = makeRecord({ id: "r", description: "recent done", status: "completed", completedAt: Date.now() });
|
|
expect(harness([recent]).render().some(l => l.includes("recent done"))).toBe(true);
|
|
const old = makeRecord({ id: "o", description: "old done", status: "completed", completedAt: Date.now() - 60_000 });
|
|
expect(harness([old]).render().some(l => l.includes("old done"))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("FleetList cost display", () => {
|
|
const theme = { fg: (_c: string, s: string) => s, bold: (s: string) => s };
|
|
|
|
function row(showCost: boolean, cost: number, activity?: Map<string, AgentActivity>): string {
|
|
const record = makeRecord({ lifetimeUsage: { input: 13100, output: 0, cacheWrite: 0, cost } });
|
|
const fleet = new FleetList(fakeManager([record]), activity ?? new Map(), () => showCost);
|
|
let factory: any;
|
|
fleet.setUICtx({
|
|
setWidget: (_k: string, c: any) => { factory = c; },
|
|
onTerminalInput: () => () => {},
|
|
getEditorText: () => "",
|
|
notify: () => {},
|
|
custom: (() => new Promise(() => {})) as any,
|
|
} as any);
|
|
fleet.update();
|
|
return factory({ requestRender: () => {}, terminal: { columns: 120, rows: 40 } }, theme).render(120).join("\n");
|
|
}
|
|
|
|
it("appends the cost after the token count when enabled", () => {
|
|
const out = row(true, 0.0042);
|
|
expect(out).toContain("13.1k tokens");
|
|
expect(out).toContain("~$0.0042");
|
|
});
|
|
|
|
it("shows no cost when disabled, and none for an unpriced model", () => {
|
|
expect(row(false, 0.0042)).not.toContain("$");
|
|
expect(row(true, 0)).not.toContain("$");
|
|
});
|
|
|
|
it("reads the record, so the figures do not change when the agent finishes", () => {
|
|
// Spend used to come from the live activity tracker while an agent ran and
|
|
// from its record once the tracker was deleted. The two disagree: only the
|
|
// record carries a nested child's spend (nested-tools folds it into every
|
|
// ancestor), so the number jumped upward at completion.
|
|
// The stale shape on purpose: an activity entry carrying figures of its own
|
|
// is what the old fallback preferred, so a row that still renders the
|
|
// record's numbers proves the tracker is no longer consulted for spend.
|
|
const tracked = new Map<string, AgentActivity>([["a1", {
|
|
activeTools: new Map(), toolUses: 0, responseText: "", turnCount: 1,
|
|
lifetimeUsage: { input: 1, output: 1, cacheWrite: 0, cost: 0.9 },
|
|
} as unknown as AgentActivity]]);
|
|
|
|
expect(row(true, 0.0042, tracked)).toBe(row(true, 0.0042));
|
|
});
|
|
});
|