feat(coding-agent): throttled and constrained agent hud updates
- Added throttling and debouncing to HUD data rendering and observer UI synchronization to coalesce update bursts. - Constrained the subagent HUD display to a maximum of 8 rows with a truncation notice for hidden sessions. - Enhanced the session observer registry to categorize update types, enabling more granular UI reconciliation. - Verified render coalescing and display truncation behavior with comprehensive integration tests using fake timers.
This commit is contained in:
@@ -2,6 +2,15 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Limited subagent HUD display to 8 items with a summary for additional running subagents
|
||||
- Improved TUI performance by coalescing agent registry and observer UI updates
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed high-subagent sessions overwhelming the TUI by bounding the running-subagent HUD and coalescing subagent progress repaint bursts.
|
||||
|
||||
## [16.3.9] - 2026-07-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -34,6 +34,7 @@ import { DynamicBorder } from "./dynamic-border";
|
||||
|
||||
/** Refresh cadence for the relative-time column */
|
||||
const AGE_TICK_MS = 5_000;
|
||||
const DATA_CHANGE_RENDER_COALESCE_MS = 100;
|
||||
/** Double-tap window for the table's left-left "close hub" gesture. */
|
||||
const LEFT_TAP_WINDOW_MS = 500;
|
||||
|
||||
@@ -198,6 +199,7 @@ export class AgentHubOverlayComponent extends Container {
|
||||
#hubKeys: KeyId[];
|
||||
#unsubscribers: Array<() => void> = [];
|
||||
#ageTimer: NodeJS.Timeout | undefined;
|
||||
#dataChangeTimer?: NodeJS.Timeout;
|
||||
#remote: AgentHubRemote | undefined;
|
||||
/** Resolves after persisted historical subagents have been registered and rows refreshed. */
|
||||
readonly persistedSubagentsReady: Promise<void>;
|
||||
@@ -251,8 +253,8 @@ export class AgentHubOverlayComponent extends Container {
|
||||
this.#expandKeys = deps.expandKeys ?? ["ctrl+o"];
|
||||
this.#focusAgent = deps.focusAgent;
|
||||
|
||||
this.#unsubscribers.push(this.#registry.onChange(() => this.#onDataChange()));
|
||||
this.#unsubscribers.push(this.#observers.onChange(() => this.#onDataChange()));
|
||||
this.#unsubscribers.push(this.#registry.onChange(() => this.#scheduleDataChange()));
|
||||
this.#unsubscribers.push(this.#observers.onChange(() => this.#scheduleDataChange()));
|
||||
this.#ageTimer = setInterval(() => this.#requestRender(), AGE_TICK_MS);
|
||||
this.#ageTimer.unref?.();
|
||||
|
||||
@@ -285,6 +287,10 @@ export class AgentHubOverlayComponent extends Container {
|
||||
clearInterval(this.#ageTimer);
|
||||
this.#ageTimer = undefined;
|
||||
}
|
||||
if (this.#dataChangeTimer) {
|
||||
clearTimeout(this.#dataChangeTimer);
|
||||
this.#dataChangeTimer = undefined;
|
||||
}
|
||||
this.#closeTranscriptOverlay();
|
||||
}
|
||||
|
||||
@@ -355,6 +361,15 @@ export class AgentHubOverlayComponent extends Container {
|
||||
// Live data plumbing
|
||||
// ========================================================================
|
||||
|
||||
#scheduleDataChange(): void {
|
||||
if (this.#dataChangeTimer) return;
|
||||
this.#dataChangeTimer = setTimeout(() => {
|
||||
this.#dataChangeTimer = undefined;
|
||||
this.#onDataChange();
|
||||
}, DATA_CHANGE_RENDER_COALESCE_MS);
|
||||
this.#dataChangeTimer.unref?.();
|
||||
}
|
||||
|
||||
#onDataChange(): void {
|
||||
this.#refreshRows();
|
||||
this.#requestRender();
|
||||
|
||||
@@ -161,8 +161,11 @@ import {
|
||||
} from "./loop-limit";
|
||||
import { OAuthManualInputManager } from "./oauth-manual-input";
|
||||
import { countRunningSubagentBadgeAgents, getRunningSubagentBadgeRegistry } from "./running-subagent-badge";
|
||||
import type { ObservableSession } from "./session-observer-registry";
|
||||
import { SessionObserverRegistry } from "./session-observer-registry";
|
||||
import {
|
||||
type ObservableSession,
|
||||
type SessionObserverChangeKind,
|
||||
SessionObserverRegistry,
|
||||
} from "./session-observer-registry";
|
||||
import { createSessionTeardown, type SessionTeardown } from "./session-teardown";
|
||||
import { runProviderSetupWizard } from "./setup-wizard/lazy";
|
||||
import { interruptHint } from "./shared";
|
||||
@@ -343,9 +346,12 @@ class AnchoredLiveContainer extends Container implements NativeScrollbackLiveReg
|
||||
* before it auto-clears, mirroring the todo HUD's auto-clear timer. */
|
||||
const MODEL_CYCLE_TRACK_CLEAR_MS = 4000;
|
||||
|
||||
const SUBAGENT_HUD_VISIBLE_LIMIT = 8;
|
||||
const SUBAGENT_OBSERVER_UI_COALESCE_MS = 100;
|
||||
|
||||
/**
|
||||
* Build the anchored subagent HUD block: a bold accent "Subagents" header plus
|
||||
* one tree row per running agent in the same `Id: description` shape the
|
||||
* a bounded set of running-agent rows in the same `Id: description` shape the
|
||||
* inline task rows use (muted task preview when no description was given).
|
||||
* Layout mirrors the Todos HUD exactly: unindented header, then
|
||||
* `renderTreeList` rows (dim connectors) shifted right by one space.
|
||||
@@ -361,9 +367,11 @@ export function renderSubagentHudLines(sessions: ObservableSession[], columns: n
|
||||
if (running.length === 0) return [];
|
||||
|
||||
const dot = theme.styledSymbol("status.done", "accent");
|
||||
const visible = running.slice(0, SUBAGENT_HUD_VISIBLE_LIMIT);
|
||||
const hiddenCount = running.length - visible.length;
|
||||
const rows = renderTreeList(
|
||||
{
|
||||
items: running,
|
||||
items: visible,
|
||||
expanded: true,
|
||||
renderItem: session => {
|
||||
const displayId = formatTaskId(session.id);
|
||||
@@ -385,6 +393,9 @@ export function renderSubagentHudLines(sessions: ObservableSession[], columns: n
|
||||
},
|
||||
theme,
|
||||
);
|
||||
if (hiddenCount > 0) {
|
||||
rows.push(theme.fg("dim", `… ${hiddenCount} more running — open Agent Hub for full list`));
|
||||
}
|
||||
return ["", theme.bold(theme.fg("accent", "Subagents")), ...rows.map(line => ` ${line}`)];
|
||||
}
|
||||
|
||||
@@ -588,6 +599,8 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
#observerRegistry: SessionObserverRegistry;
|
||||
#eventBus?: EventBus;
|
||||
#eventBusUnsubscribers: Array<() => void> = [];
|
||||
#observerUiSyncTimer?: NodeJS.Timeout;
|
||||
#observerUiSyncNeedsTodoReconcile = false;
|
||||
#agentRegistryUnsubscribe?: () => void;
|
||||
#agentRegistrySubscriptionTarget?: AgentRegistry;
|
||||
#mcpStatusOrder: string[] = [];
|
||||
@@ -917,17 +930,8 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
}
|
||||
this.#observerRegistry.setMainSession(this.sessionManager.getSessionFile() ?? undefined);
|
||||
this.syncRunningSubagentBadge();
|
||||
this.#observerRegistry.onChange(() => {
|
||||
this.syncRunningSubagentBadge();
|
||||
// Auto-checkmark todos whose matching subagent just succeeded, then
|
||||
// re-render so the running override (the static "live" glyph when a
|
||||
// subagent is doing the work for a still-pending todo) updates as
|
||||
// subagents start, finish, or fail.
|
||||
this.#reconcileTodosWithSubagents();
|
||||
this.#syncTodoAutoClearTimer();
|
||||
this.#renderTodoList();
|
||||
this.#renderSubagentList();
|
||||
this.ui.requestRender();
|
||||
this.#observerRegistry.onChange(kind => {
|
||||
this.#scheduleObserverUiSync(kind);
|
||||
});
|
||||
|
||||
// Load initial todos
|
||||
@@ -1521,19 +1525,18 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
}
|
||||
|
||||
/** Refresh the running-subagents status badge from the active local or collab registry. */
|
||||
syncRunningSubagentBadge(): void {
|
||||
syncRunningSubagentBadge(options: { requestRender?: boolean } = {}): void {
|
||||
const registry = getRunningSubagentBadgeRegistry(this.collabGuest);
|
||||
if (this.#agentRegistrySubscriptionTarget !== registry) {
|
||||
this.#agentRegistryUnsubscribe?.();
|
||||
this.#agentRegistrySubscriptionTarget = registry;
|
||||
this.#agentRegistryUnsubscribe = registry.onChange(() => {
|
||||
this.syncRunningSubagentBadge();
|
||||
this.ui.requestRender();
|
||||
});
|
||||
}
|
||||
const count = countRunningSubagentBadgeAgents(registry);
|
||||
this.statusLine.setSubagentCount(count);
|
||||
this.ui.requestRender();
|
||||
if (options.requestRender !== false) this.ui.requestRender();
|
||||
}
|
||||
|
||||
rebuildChatFromMessages(): void {
|
||||
@@ -1754,6 +1757,38 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
return active ?? nonEmpty[nonEmpty.length - 1];
|
||||
}
|
||||
|
||||
#scheduleObserverUiSync(kind: SessionObserverChangeKind): void {
|
||||
if (kind !== "progress") {
|
||||
this.#observerUiSyncNeedsTodoReconcile = true;
|
||||
}
|
||||
if (this.#observerUiSyncTimer) return;
|
||||
this.#observerUiSyncTimer = setTimeout(() => {
|
||||
this.#observerUiSyncTimer = undefined;
|
||||
this.#flushObserverUiSync();
|
||||
}, SUBAGENT_OBSERVER_UI_COALESCE_MS);
|
||||
this.#observerUiSyncTimer.unref?.();
|
||||
}
|
||||
|
||||
#flushObserverUiSync(): void {
|
||||
this.syncRunningSubagentBadge({ requestRender: false });
|
||||
if (this.#observerUiSyncNeedsTodoReconcile) {
|
||||
this.#observerUiSyncNeedsTodoReconcile = false;
|
||||
this.#reconcileTodosWithSubagents();
|
||||
}
|
||||
this.#syncTodoAutoClearTimer();
|
||||
this.#renderTodoList();
|
||||
this.#renderSubagentList();
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
#cancelObserverUiSyncTimer(): void {
|
||||
if (this.#observerUiSyncTimer) {
|
||||
clearTimeout(this.#observerUiSyncTimer);
|
||||
this.#observerUiSyncTimer = undefined;
|
||||
}
|
||||
this.#observerUiSyncNeedsTodoReconcile = false;
|
||||
}
|
||||
|
||||
#renderTodoList(): void {
|
||||
this.todoContainer.clear();
|
||||
const phases = this.todoPhases.filter(phase => phase.tasks.length > 0);
|
||||
@@ -3306,6 +3341,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
}
|
||||
this.#cleanupMicAnimation();
|
||||
this.#cancelTodoAutoClearTimer();
|
||||
this.#cancelObserverUiSyncTimer();
|
||||
this.#cancelGoalContinuation();
|
||||
if (this.#sttController) {
|
||||
this.#sttController.dispose();
|
||||
|
||||
@@ -24,6 +24,9 @@ export interface ObservableSession {
|
||||
progress?: AgentProgress;
|
||||
}
|
||||
|
||||
/** Coarse source of an observer change; callers use it to separate lifecycle work from high-frequency progress. */
|
||||
export type SessionObserverChangeKind = "main" | "reset" | "lifecycle" | "progress";
|
||||
|
||||
const STATUS_MAP: Record<string, ObservableSession["status"]> = {
|
||||
started: "active",
|
||||
completed: "completed",
|
||||
@@ -33,20 +36,20 @@ const STATUS_MAP: Record<string, ObservableSession["status"]> = {
|
||||
|
||||
export class SessionObserverRegistry {
|
||||
#sessions = new Map<string, ObservableSession>();
|
||||
#listeners = new Set<() => void>();
|
||||
#listeners = new Set<(kind: SessionObserverChangeKind) => void>();
|
||||
#eventBusUnsubscribers: Array<() => void> = [];
|
||||
#sortOrderById = new Map<string, number>();
|
||||
#parentSortOrderById = new Map<string, number>();
|
||||
#nextSortOrder = 0;
|
||||
|
||||
/** Add a change listener. Returns unsubscribe function. */
|
||||
onChange(cb: () => void): () => void {
|
||||
onChange(cb: (kind: SessionObserverChangeKind) => void): () => void {
|
||||
this.#listeners.add(cb);
|
||||
return () => this.#listeners.delete(cb);
|
||||
}
|
||||
|
||||
#notifyListeners(): void {
|
||||
for (const cb of this.#listeners) cb();
|
||||
#notifyListeners(kind: SessionObserverChangeKind): void {
|
||||
for (const cb of this.#listeners) cb(kind);
|
||||
}
|
||||
|
||||
#ensureSortOrder(id: string): number {
|
||||
@@ -85,7 +88,7 @@ export class SessionObserverRegistry {
|
||||
sessionFile: sessionFile ?? existing?.sessionFile,
|
||||
lastUpdate: Date.now(),
|
||||
});
|
||||
this.#notifyListeners();
|
||||
this.#notifyListeners("main");
|
||||
}
|
||||
|
||||
getSessions(): ObservableSession[] {
|
||||
@@ -121,7 +124,7 @@ export class SessionObserverRegistry {
|
||||
this.#sortOrderById.clear();
|
||||
this.#parentSortOrderById.clear();
|
||||
this.#nextSortOrder = 0;
|
||||
this.#notifyListeners();
|
||||
this.#notifyListeners("reset");
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
@@ -171,7 +174,7 @@ export class SessionObserverRegistry {
|
||||
lastUpdate: Date.now(),
|
||||
});
|
||||
}
|
||||
this.#notifyListeners();
|
||||
this.#notifyListeners("lifecycle");
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -208,7 +211,7 @@ export class SessionObserverRegistry {
|
||||
progress,
|
||||
});
|
||||
}
|
||||
this.#notifyListeners();
|
||||
this.#notifyListeners("progress");
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* `focusAgent` dep (session focus proxy) and closes the hub on success; a
|
||||
* focus failure keeps the hub open and surfaces the error as a notice.
|
||||
*/
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as path from "node:path";
|
||||
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { IrcBus } from "@oh-my-pi/pi-coding-agent/irc/bus";
|
||||
@@ -286,3 +286,65 @@ describe("Agent hub double-← gating", () => {
|
||||
shown()!.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agent hub data refresh coalescing", () => {
|
||||
beforeAll(() => {
|
||||
initTheme();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
AgentRegistry.resetGlobalForTests();
|
||||
});
|
||||
|
||||
it("coalesces a synchronous registry burst into one render and refreshes rows", async () => {
|
||||
vi.useFakeTimers();
|
||||
const agents = new AgentRegistry();
|
||||
const observers = new SessionObserverRegistry();
|
||||
const requestRender = vi.fn();
|
||||
const hub = new AgentHubOverlayComponent({
|
||||
observers,
|
||||
hubKeys: [],
|
||||
onDone: () => {},
|
||||
requestRender,
|
||||
registry: agents,
|
||||
irc: new IrcBus(agents),
|
||||
focusAgent: async () => {},
|
||||
});
|
||||
|
||||
try {
|
||||
await hub.persistedSubagentsReady;
|
||||
requestRender.mockClear();
|
||||
|
||||
for (const id of ["BurstA", "BurstB", "BurstC"]) {
|
||||
agents.register({
|
||||
id,
|
||||
displayName: id,
|
||||
kind: "sub",
|
||||
parentId: "Main",
|
||||
session: { subscribe: () => () => {} } as unknown as AgentSession,
|
||||
sessionFile: null,
|
||||
status: "running",
|
||||
});
|
||||
}
|
||||
|
||||
expect(requestRender).not.toHaveBeenCalled();
|
||||
expect(Bun.stripANSI(hub.render(120).join("\n"))).not.toContain("BurstA");
|
||||
|
||||
vi.advanceTimersByTime(99);
|
||||
expect(requestRender).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(requestRender).toHaveBeenCalledTimes(1);
|
||||
|
||||
const rendered = Bun.stripANSI(hub.render(120).join("\n"));
|
||||
expect(rendered).toContain("BurstA");
|
||||
expect(rendered).toContain("BurstB");
|
||||
expect(rendered).toContain("BurstC");
|
||||
} finally {
|
||||
hub.dispose();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 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, vi } from "bun:test";
|
||||
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";
|
||||
@@ -72,6 +72,8 @@ describe("Agent hub row ordering", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
setSystemTime();
|
||||
vi.restoreAllMocks();
|
||||
geometry?.restore();
|
||||
geometry = undefined;
|
||||
@@ -79,42 +81,45 @@ describe("Agent hub row ordering", () => {
|
||||
});
|
||||
|
||||
it("freezes the initial lastActivity order while the hub is open", () => {
|
||||
geometry = stubStdoutGeometry(120);
|
||||
const now = vi.spyOn(Date, "now");
|
||||
const agents = new AgentRegistry();
|
||||
const sessions = new Map<string, AgentSession>();
|
||||
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 });
|
||||
|
||||
now.mockReturnValue(1000);
|
||||
const sessionA = {} as AgentSession;
|
||||
sessions.set("A", sessionA);
|
||||
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 });
|
||||
|
||||
now.mockReturnValue(2000);
|
||||
const sessionB = {} as AgentSession;
|
||||
sessions.set("B", sessionB);
|
||||
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 });
|
||||
|
||||
now.mockReturnValue(3000);
|
||||
const sessionC = {} as AgentSession;
|
||||
sessions.set("C", sessionC);
|
||||
agents.register({ id: "C", displayName: "Gamma", kind: "sub", session: sessionC });
|
||||
hub = makeHub(agents);
|
||||
expect(renderedAgentIds(hub)).toEqual(["C", "B", "A"]);
|
||||
|
||||
const 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");
|
||||
|
||||
// Bump A's lastActivity far ahead of the others. The hub is already open,
|
||||
// so the captured order must not change.
|
||||
now.mockReturnValue(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 });
|
||||
|
||||
// Force a refresh by registering a new agent; the existing rows must stay put.
|
||||
now.mockReturnValue(5000);
|
||||
const sessionD = {} as AgentSession;
|
||||
agents.register({ id: "D", displayName: "Delta", kind: "sub", session: sessionD });
|
||||
|
||||
expect(renderedAgentIds(hub)).toEqual(["C", "B", "A", "D"]);
|
||||
|
||||
hub.dispose();
|
||||
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", () => {
|
||||
|
||||
@@ -5,13 +5,20 @@
|
||||
* block self-clears. Sync task spawns and eval `agent()` spawns are excluded:
|
||||
* their progress is already rendered inline (tool block / eval cell).
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from "bun:test";
|
||||
import { renderSubagentHudLines } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as path from "node:path";
|
||||
import { Agent } from "@oh-my-pi/pi-agent-core";
|
||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { InteractiveMode, renderSubagentHudLines } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode";
|
||||
import {
|
||||
type ObservableSession,
|
||||
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 { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import {
|
||||
type AgentProgress,
|
||||
type SubagentLifecyclePayload,
|
||||
@@ -20,6 +27,7 @@ import {
|
||||
TASK_SUBAGENT_PROGRESS_CHANNEL,
|
||||
} from "@oh-my-pi/pi-coding-agent/task";
|
||||
import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
function makeSession(overrides: Partial<ObservableSession> & { id: string }): ObservableSession {
|
||||
return {
|
||||
@@ -200,4 +208,99 @@ describe("subagent HUD lines", () => {
|
||||
|
||||
expect(activeIds()).toEqual(["SelectorSurfaces", "BlastRadius", "VariantsSurvey"]);
|
||||
});
|
||||
|
||||
it("renders the first eight active detached subagents and summarizes the rest", () => {
|
||||
const active = Array.from({ length: 10 }, (_, index) =>
|
||||
makeSession({
|
||||
id: `Worker${index}`,
|
||||
description: `job ${index}`,
|
||||
}),
|
||||
);
|
||||
|
||||
const out = render(active, 120);
|
||||
|
||||
for (const session of active.slice(0, 8)) {
|
||||
expect(out).toContain(`${session.id}: ${session.description}`);
|
||||
}
|
||||
for (const session of active.slice(8)) {
|
||||
expect(out).not.toContain(`${session.id}: ${session.description}`);
|
||||
}
|
||||
expect(out).toContain("2 more running");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InteractiveMode subagent observer UI sync", () => {
|
||||
let tempDir: TempDir;
|
||||
let authStorage: AuthStorage;
|
||||
let session: AgentSession;
|
||||
let mode: InteractiveMode;
|
||||
let eventBus: EventBus;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initTheme();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
resetSettingsForTest();
|
||||
tempDir = TempDir.createSync("@pi-subagent-observer-");
|
||||
await Settings.init({
|
||||
inMemory: true,
|
||||
cwd: tempDir.path(),
|
||||
overrides: { "startup.quiet": true },
|
||||
});
|
||||
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
const model = modelRegistry.find("anthropic", "claude-sonnet-4-5");
|
||||
if (!model) throw new Error("Expected claude-sonnet-4-5 to exist in registry");
|
||||
|
||||
eventBus = new EventBus();
|
||||
session = new AgentSession({
|
||||
agent: new Agent({
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: ["Test"],
|
||||
tools: [],
|
||||
messages: [],
|
||||
},
|
||||
}),
|
||||
sessionManager: SessionManager.create(tempDir.path(), tempDir.path()),
|
||||
settings: Settings.isolated({ "startup.quiet": true }),
|
||||
modelRegistry,
|
||||
});
|
||||
mode = new InteractiveMode(session, "test", undefined, undefined, undefined, undefined, eventBus);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
mode?.stop();
|
||||
await session?.dispose();
|
||||
authStorage?.close();
|
||||
tempDir?.removeSync();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
resetSettingsForTest();
|
||||
});
|
||||
|
||||
it("coalesces a burst of progress observer changes into one HUD rebuild and render request", async () => {
|
||||
await mode.init({ suppressWelcomeIntro: true });
|
||||
const requestRender = vi.spyOn(mode.ui, "requestRender").mockImplementation(() => {});
|
||||
const rebuildHud = vi.spyOn(mode.subagentContainer, "clear");
|
||||
vi.useFakeTimers();
|
||||
|
||||
for (let index = 0; index < 6; index++) {
|
||||
eventBus.emit(
|
||||
TASK_SUBAGENT_PROGRESS_CHANNEL,
|
||||
makeProgressPayload(`BurstAgent${index}`, index, `Burst job ${index}`, true),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.resolve();
|
||||
vi.runAllTimers();
|
||||
await Promise.resolve();
|
||||
|
||||
const hud = Bun.stripANSI(mode.subagentContainer.render(120).join("\n"));
|
||||
expect(hud).toContain("BurstAgent0: Burst job 0");
|
||||
expect(hud).toContain("BurstAgent5: Burst job 5");
|
||||
expect(rebuildHud).toHaveBeenCalledTimes(1);
|
||||
expect(requestRender).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user