42d81f189f
- Redesigned agent hub entries as two-line cards, separating identity and status from task descriptions. - Added explicit model and thinking level badges to agent status information. - Updated agent hub rendering to support multi-line entries and adaptive vertical scrolling based on row height. - Simplified status display by using glyphs instead of redundant status labels. - Adjusted rendering logic to prioritize displaying agent task summaries on an independent indented line.
169 lines
5.4 KiB
TypeScript
169 lines
5.4 KiB
TypeScript
/**
|
||
* Regression: the agent hub row order must be stable while the hub is open.
|
||
*
|
||
* The hub is sorted by lastActivity on first open, but after that keyboard
|
||
* selection must not jump around as agents heartbeat or update activity. New
|
||
* agents that appear while the hub is open are appended at the end.
|
||
*/
|
||
import { afterEach, beforeAll, describe, expect, it, setSystemTime, vi } from "bun:test";
|
||
import { IrcBus } from "@oh-my-pi/pi-coding-agent/irc/bus";
|
||
import { AgentHubOverlayComponent } from "@oh-my-pi/pi-coding-agent/modes/components/agent-hub";
|
||
import { SessionObserverRegistry } from "@oh-my-pi/pi-coding-agent/modes/session-observer-registry";
|
||
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
|
||
import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||
import { visibleWidth } from "@oh-my-pi/pi-tui/utils";
|
||
|
||
interface GeometryStub {
|
||
setRows(n: number): void;
|
||
restore(): void;
|
||
}
|
||
|
||
function stubStdoutGeometry(cols: number): GeometryStub {
|
||
const rowsDesc = Object.getOwnPropertyDescriptor(process.stdout, "rows");
|
||
const colsDesc = Object.getOwnPropertyDescriptor(process.stdout, "columns");
|
||
let rows = 24;
|
||
Object.defineProperty(process.stdout, "rows", { configurable: true, get: () => rows, set: () => {} });
|
||
Object.defineProperty(process.stdout, "columns", { configurable: true, get: () => cols, set: () => {} });
|
||
const restoreOne = (key: "rows" | "columns", desc: PropertyDescriptor | undefined) => {
|
||
if (desc) Object.defineProperty(process.stdout, key, desc);
|
||
else Object.defineProperty(process.stdout, key, { configurable: true, value: undefined, writable: true });
|
||
};
|
||
return {
|
||
setRows(n: number) {
|
||
rows = n;
|
||
},
|
||
restore() {
|
||
restoreOne("rows", rowsDesc);
|
||
restoreOne("columns", colsDesc);
|
||
},
|
||
};
|
||
}
|
||
|
||
function makeHub(agents: AgentRegistry) {
|
||
return new AgentHubOverlayComponent({
|
||
observers: new SessionObserverRegistry(),
|
||
hubKeys: [],
|
||
onDone: () => {},
|
||
requestRender: () => {},
|
||
registry: agents,
|
||
irc: new IrcBus(agents),
|
||
focusAgent: async () => {},
|
||
});
|
||
}
|
||
|
||
function renderedAgentIds(hub: AgentHubOverlayComponent): string[] {
|
||
// Entry first lines are ` <cursor> <status-glyph> <id> …`; task lines are
|
||
// indented deeper and chrome lines never carry the cursor slot.
|
||
const ids: string[] = [];
|
||
for (const raw of hub.render(120)) {
|
||
const match = /^ (?:❯| ) (\S+) (\S+)/u.exec(Bun.stripANSI(raw));
|
||
if (match) ids.push(match[2]!);
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
describe("Agent hub row ordering", () => {
|
||
let geometry: GeometryStub | undefined;
|
||
|
||
beforeAll(async () => {
|
||
await initTheme();
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
setSystemTime();
|
||
vi.restoreAllMocks();
|
||
geometry?.restore();
|
||
geometry = undefined;
|
||
AgentRegistry.resetGlobalForTests();
|
||
});
|
||
|
||
it("freezes the initial lastActivity order while the hub is open", () => {
|
||
vi.useFakeTimers();
|
||
let hub: AgentHubOverlayComponent | undefined;
|
||
try {
|
||
geometry = stubStdoutGeometry(120);
|
||
const agents = new AgentRegistry();
|
||
setSystemTime(1000);
|
||
const sessionA = {} as AgentSession;
|
||
agents.register({ id: "A", displayName: "Alpha", kind: "sub", session: sessionA });
|
||
|
||
setSystemTime(2000);
|
||
const sessionB = {} as AgentSession;
|
||
agents.register({ id: "B", displayName: "Beta", kind: "sub", session: sessionB });
|
||
|
||
setSystemTime(3000);
|
||
const sessionC = {} as AgentSession;
|
||
agents.register({ id: "C", displayName: "Gamma", kind: "sub", session: sessionC });
|
||
|
||
hub = makeHub(agents);
|
||
expect(renderedAgentIds(hub)).toEqual(["C", "B", "A"]);
|
||
|
||
// Bump A's lastActivity far ahead of the others. The hub is already open,
|
||
// so the captured order must not change.
|
||
setSystemTime(4000);
|
||
agents.setActivity("A", "still running");
|
||
|
||
// Registering a new agent schedules a coalesced row refresh; the
|
||
// existing rows must stay put once the scheduled refresh runs.
|
||
setSystemTime(5000);
|
||
const sessionD = {} as AgentSession;
|
||
agents.register({ id: "D", displayName: "Delta", kind: "sub", session: sessionD });
|
||
|
||
expect(renderedAgentIds(hub)).toEqual(["C", "B", "A"]);
|
||
vi.advanceTimersByTime(100);
|
||
expect(renderedAgentIds(hub)).toEqual(["C", "B", "A", "D"]);
|
||
} finally {
|
||
hub?.dispose();
|
||
vi.useRealTimers();
|
||
setSystemTime();
|
||
}
|
||
});
|
||
|
||
it("truncates lines and sanitizes newlines to prevent terminal wrapping", () => {
|
||
geometry = stubStdoutGeometry(80);
|
||
const agents = new AgentRegistry();
|
||
const sessionA = {} as AgentSession;
|
||
agents.register({
|
||
id: "RevAgentStream",
|
||
displayName: "Agent runtime + compaction reviewer",
|
||
kind: "sub",
|
||
session: sessionA,
|
||
});
|
||
|
||
const observers = new SessionObserverRegistry();
|
||
vi.spyOn(observers, "getSessions").mockReturnValue([
|
||
{
|
||
id: "RevAgentStream",
|
||
kind: "subagent",
|
||
label: "Subagent",
|
||
status: "active",
|
||
description: "Complete the assignment below, thoroughly:\n- check performance\n- check leaks",
|
||
lastUpdate: Date.now(),
|
||
},
|
||
]);
|
||
|
||
const hub = new AgentHubOverlayComponent({
|
||
observers,
|
||
hubKeys: [],
|
||
onDone: () => {},
|
||
requestRender: () => {},
|
||
registry: agents,
|
||
irc: new IrcBus(agents),
|
||
focusAgent: async () => {},
|
||
});
|
||
|
||
const lines = hub.render(80);
|
||
for (const line of lines) {
|
||
const cleanLine = Bun.stripANSI(line);
|
||
expect(cleanLine.includes("\n")).toBe(false);
|
||
expect(cleanLine.includes("\r")).toBe(false);
|
||
const width = visibleWidth(line);
|
||
expect(width).toBeLessThanOrEqual(78);
|
||
}
|
||
|
||
hub.dispose();
|
||
});
|
||
});
|