diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c5595a893..182c4ce02 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index fd85bd748..1c8413605 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -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; @@ -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(); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index e69c1ca9a..8e064d8ee 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -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(); diff --git a/packages/coding-agent/src/modes/session-observer-registry.ts b/packages/coding-agent/src/modes/session-observer-registry.ts index a303501f9..e94ffa9d9 100644 --- a/packages/coding-agent/src/modes/session-observer-registry.ts +++ b/packages/coding-agent/src/modes/session-observer-registry.ts @@ -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 = { started: "active", completed: "completed", @@ -33,20 +36,20 @@ const STATUS_MAP: Record = { export class SessionObserverRegistry { #sessions = new Map(); - #listeners = new Set<() => void>(); + #listeners = new Set<(kind: SessionObserverChangeKind) => void>(); #eventBusUnsubscribers: Array<() => void> = []; #sortOrderById = new Map(); #parentSortOrderById = new Map(); #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"); }), ); } diff --git a/packages/coding-agent/test/agent-hub-activate.test.ts b/packages/coding-agent/test/agent-hub-activate.test.ts index 8221e77da..4cc4033c7 100644 --- a/packages/coding-agent/test/agent-hub-activate.test.ts +++ b/packages/coding-agent/test/agent-hub-activate.test.ts @@ -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(); + } + }); +}); diff --git a/packages/coding-agent/test/agent-hub-ordering.test.ts b/packages/coding-agent/test/agent-hub-ordering.test.ts index e6cbcc2da..1c34634ad 100644 --- a/packages/coding-agent/test/agent-hub-ordering.test.ts +++ b/packages/coding-agent/test/agent-hub-ordering.test.ts @@ -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(); + 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", () => { diff --git a/packages/coding-agent/test/subagent-hud-render.test.ts b/packages/coding-agent/test/subagent-hud-render.test.ts index 9de16ecae..4f1929b30 100644 --- a/packages/coding-agent/test/subagent-hud-render.test.ts +++ b/packages/coding-agent/test/subagent-hud-render.test.ts @@ -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 & { 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); + }); });