diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 84a743e43..f6df79af3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -206,6 +206,9 @@ - Fixed remote or LAN local-engine endpoints being ignored during model discovery: the llama.cpp and Ollama probes used timeouts tuned for loopback, so a host reached over the network could exceed them and return no models, while changing `OLLAMA_BASE_URL`/`OLLAMA_HOST` could keep reusing a fresh cache from the previous endpoint. Non-loopback hosts now get a generous discovery timeout, and Ollama cache rows are scoped to the normalized endpoint ([#7087](https://github.com/can1357/oh-my-pi/issues/7087)). - Fixed `omp install` failing extension validation for pi extensions that import `createEditTool` or `createWriteTool` (e.g. gentle-pi) — the legacy `@oh-my-pi/pi-coding-agent` shim exported the read/bash/grep/find/ls tool factories but omitted the edit and write ones, so a named import threw Bun's static "Export named X not found" error. Added `createEditTool`/`createEditToolDefinition` and `createWriteTool`/`createWriteToolDefinition` to match the upstream pi surface ([#7094](https://github.com/can1357/oh-my-pi/issues/7094)). - Fixed Python eval's loopback tool bridge being routed through macOS system HTTP proxies, which caused `parallel()` tool reads to fail with `ConnectionRefusedError` after a local proxy stopped. +### Changed + +- Reworked the Ctrl+S Agent Hub onto the fullscreen modal lifecycle shared by Settings and Model Hub, with stable agent ordering, bounded rendering for large rosters, responsive persisted-session discovery, and consistent keyboard and mouse navigation. ## [17.2.0] - 2026-07-30 diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index a5a706c6e..9ec081cba 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -4,8 +4,8 @@ * One overlay, two views: * - Table view: every registered agent except Main (Main IS the ambient * chat), live from the global AgentRegistry — status, unread irc count, - * current/last task, last activity. Select with j/k, Enter opens a chat, - * `r` revives a parked agent, `x` aborts + releases one. + * current/last task, last activity. Navigate with keys, wheel, hover, and + * click; `r` revives a parked agent, `x` aborts + releases one. * - Chat view: per-agent transcript (incremental session-file tail, absorbed * from the old session observer overlay) plus an input line. Submitting * revives a parked agent, then prompts/steers it; the message lands in the @@ -14,7 +14,18 @@ * Replaces the old SessionObserverOverlayComponent (ctrl+s observer). */ import { type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import { Container, Ellipsis, matchesKey, type OverlayHandle, padding, type TUI, visibleWidth } from "@oh-my-pi/pi-tui"; +import { + Container, + Ellipsis, + matchesKey, + type OverlayHandle, + padding, + routeSelectListMouse, + routeSgrMouseInput, + type SelectListMouseTarget, + type TUI, + visibleWidth, +} from "@oh-my-pi/pi-tui"; import { formatAge, getProjectDir, logger } from "@oh-my-pi/pi-utils"; import type { KeyId } from "../../config/keybindings"; import type { MessageRenderer } from "../../extensibility/extensions/types"; @@ -162,11 +173,12 @@ export interface AgentHubDeps { focusAgent?: (id: string) => Promise; /** Current main session file; used to seed parked historical subagents after restart. */ sessionFile?: string | null; + /** Collab guest: route actions/transcripts to the host instead of local sessions. */ remote?: AgentHubRemote; } -export class AgentHubOverlayComponent extends Container { +export class AgentHubOverlayComponent extends Container implements SelectListMouseTarget { #registry: AgentRegistry; #observers: SessionObserverRegistry; #irc: IrcBus; @@ -178,12 +190,16 @@ export class AgentHubOverlayComponent extends Container { #ageTimer: NodeJS.Timeout | undefined; #dataChangeTimer?: NodeJS.Timeout; #remote: AgentHubRemote | undefined; + #disposed = false; /** Resolves after persisted historical subagents have been registered and rows refreshed. */ readonly persistedSubagentsReady: Promise; // Table state #rows: AgentRef[] = []; #selectedRow = 0; + #hoveredRow: number | null = null; + /** Per-render screen-line to agent-row map, shared by click and hover routing. */ + #hitRows: Array = []; #notice: string | undefined; /** Captured row order from the first refresh; keeps the hub stable while open. */ #rowOrder: Map | undefined; @@ -238,13 +254,15 @@ export class AgentHubOverlayComponent extends Container { this.persistedSubagentsReady = this.#remote ? Promise.resolve() : registerPersistedSubagents(this.#registry, deps.sessionFile) + .then(() => { + if (!this.#disposed) this.#refreshRows(); + }) .catch((error: unknown) => { logger.warn("Failed to register persisted subagents", { error }); }) - .then(() => { - this.#refreshRows(); - }) - .finally(() => this.#requestRender()); + .finally(() => { + if (!this.#disposed) this.#requestRender(); + }); this.#refreshRows(); } @@ -259,6 +277,8 @@ export class AgentHubOverlayComponent extends Container { /** Tear down every subscription and timer. Called by the overlay owner on close. */ dispose(): void { + if (this.#disposed) return; + this.#disposed = true; for (const unsubscribe of this.#unsubscribers.splice(0)) unsubscribe(); if (this.#ageTimer) { clearInterval(this.#ageTimer); @@ -272,10 +292,21 @@ export class AgentHubOverlayComponent extends Container { } override render(width: number): readonly string[] { - return this.#renderTable(width).map(line => clampHubLine(line, width)); + const frame = this.#renderTable(width).map(line => clampHubLine(line, width)); + const termHeight = this.#ui.terminal?.rows || process.stdout.rows || 40; + if (frame.length <= termHeight) return frame; + + // A tiny terminal can leave less room than the fixed chrome needs. Keep + // the header and footer visible rather than allowing the fullscreen modal + // to spill into the terminal's scrollback. + const footerLines = Math.min(3, frame.length); + const bodyEnd = Math.max(0, termHeight - footerLines); + return [...frame.slice(0, bodyEnd), ...frame.slice(-footerLines)].slice(0, termHeight); } handleInput(keyData: string): void { + if (routeSgrMouseInput(keyData, event => routeSelectListMouse(this, event, event.row))) return; + // The hub/observe keys always close the overlay (toggle semantics) for (const key of this.#hubKeys) { if (matchesKey(keyData, key)) { @@ -369,7 +400,8 @@ export class AgentHubOverlayComponent extends Container { const selectedId = this.#rows[this.#selectedRow]?.id; const refs = this.#registry.list().filter(ref => ref.id !== MAIN_AGENT_ID); - if (!this.#rowOrder) { + const rowOrder = this.#rowOrder; + if (!rowOrder) { // First refresh (usually the constructor): order by status, then recency. this.#rows = refs.sort( (a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || b.lastActivity - a.lastActivity, @@ -382,14 +414,10 @@ export class AgentHubOverlayComponent extends Container { this.#rows = refs.sort((a, b) => { const statusDiff = STATUS_ORDER[a.status] - STATUS_ORDER[b.status]; if (statusDiff !== 0) return statusDiff; - const aOrder = this.#rowOrder!.get(a.id) ?? Number.MAX_SAFE_INTEGER; - const bOrder = this.#rowOrder!.get(b.id) ?? Number.MAX_SAFE_INTEGER; - return aOrder - bOrder; + return (rowOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (rowOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER); }); for (const ref of this.#rows) { - if (!this.#rowOrder.has(ref.id)) { - this.#rowOrder.set(ref.id, this.#rowOrder.size); - } + if (!rowOrder.has(ref.id)) rowOrder.set(ref.id, rowOrder.size); } } @@ -406,7 +434,9 @@ export class AgentHubOverlayComponent extends Container { // ======================================================================== #renderTable(width: number): string[] { + this.#hitRows.length = 0; const lines: string[] = []; + const termHeight = this.#ui.terminal?.rows || process.stdout.rows || 40; lines.push(...new DynamicBorder().render(width)); const counts = this.#statusSummary(); lines.push(` ${theme.fg("accent", "Agent Hub")}${counts ? theme.fg("dim", `${theme.sep.dot}${counts}`) : ""}`); @@ -415,44 +445,77 @@ export class AgentHubOverlayComponent extends Container { if (this.#rows.length === 0) { lines.push(` ${theme.fg("dim", "no subagents yet — task spawns appear here")}`); } else { - const termHeight = process.stdout.rows || 40; - // Chrome: 2 borders + title + notice? + blank + hints + border - const budget = Math.max(4, termHeight - 7 - (this.#notice ? 1 : 0)); - const entries = this.#rows.map((ref, i) => this.#renderEntry(ref, i === this.#selectedRow, width)); - // Entries are 1-2 lines tall; grow a window around the selection until - // the line budget is spent, so the selected entry stays centered. - let start = this.#selectedRow; - let end = this.#selectedRow + 1; - let used = entries[start]?.length ?? 0; - for (let grew = true; grew; ) { - grew = false; - if (end < entries.length && used + entries[end].length <= budget) { - used += entries[end].length; - end++; - grew = true; + // Reserve both possible overflow markers plus the fixed table chrome. + // Rendering only this window keeps a large persisted registry cheap. + const chrome = 3 + 3 + (this.#notice ? 1 : 0); + const budget = Math.max(0, termHeight - chrome - 2); + if (budget > 0) { + const rendered: Array = []; + const entryAt = (index: number): string[] => { + const cached = rendered[index]; + if (cached) return cached; + const entry = this.#renderEntry( + this.#rows[index], + index === this.#selectedRow, + width, + index === this.#hoveredRow, + ); + rendered[index] = entry; + return entry; + }; + const appendEntry = (index: number, entry = entryAt(index)): void => { + for (const line of entry) { + this.#hitRows[lines.length] = index; + lines.push(line); + } + }; + + let start = this.#selectedRow; + let end = this.#selectedRow + 1; + let used = entryAt(this.#selectedRow).length; + if (used > budget) { + appendEntry(this.#selectedRow, entryAt(this.#selectedRow).slice(0, budget)); + } else { + // Grow a window around the selection until the line budget is + // spent, so selection remains visible without rendering every row. + for (let grew = true; grew; ) { + grew = false; + if (end < this.#rows.length) { + const next = entryAt(end); + if (used + next.length <= budget) { + used += next.length; + end++; + grew = true; + } + } + if (start > 0) { + const previous = entryAt(start - 1); + if (used + previous.length <= budget) { + start--; + used += previous.length; + grew = true; + } + } + } + if (start > 0) { + lines.push(` ${theme.fg("dim", `… ${start} more`)}`); + } + for (let i = start; i < end; i++) { + appendEntry(i); + } + if (end < this.#rows.length) { + lines.push(` ${theme.fg("dim", `… ${this.#rows.length - end} more`)}`); + } } - if (start > 0 && used + entries[start - 1].length <= budget) { - start--; - used += entries[start].length; - grew = true; - } - } - if (start > 0) { - lines.push(` ${theme.fg("dim", `… ${start} more`)}`); - } - for (let i = start; i < end; i++) { - lines.push(...entries[i]); - } - if (end < this.#rows.length) { - lines.push(` ${theme.fg("dim", `… ${this.#rows.length - end} more`)}`); } } if (this.#notice) { lines.push(` ${theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width - 2)))}`); } + while (lines.length < termHeight - 3) lines.push(""); lines.push(""); - lines.push(` ${theme.fg("dim", "j/k:select Enter:open r:revive x:kill Esc/←←:close")}`); + lines.push(` ${theme.fg("dim", "j/k/wheel:select Enter/click:open r:revive x:kill Esc/←←:close")}`); lines.push(...new DynamicBorder().render(width)); return lines; } @@ -476,7 +539,7 @@ export class AgentHubOverlayComponent extends Container { * left, metadata right-aligned (inlined when the terminal is too narrow) — * plus an indented dim task line when the agent's work is known. */ - #renderEntry(ref: AgentRef, selected: boolean, width: number): string[] { + #renderEntry(ref: AgentRef, selected: boolean, width: number, hovered = false): string[] { const max = Math.max(1, width - 2); const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " "; const fields: string[] = [`${cursor} ${statusGlyph(ref.status)} ${theme.bold(replaceTabs(ref.id))}`]; @@ -514,7 +577,40 @@ export class AgentHubOverlayComponent extends Container { if (task) { entry.push(` ${theme.fg("muted", sanitizeLine(task, Math.max(10, max - 5)))}`); } - return entry; + if (!hovered) return entry; + return entry.map(row => { + const rowWidth = visibleWidth(row); + return theme.bg("selectedBg", rowWidth < max ? row + padding(max - rowWidth) : row); + }); + } + + handleWheel(delta: -1 | 1): void { + this.#hoveredRow = null; + if (this.#rows.length > 0) { + this.#selectedRow = Math.max(0, Math.min(this.#selectedRow + delta, this.#rows.length - 1)); + } + this.#requestRender(); + } + + hitTest(line: number): number | undefined { + return this.#hitRows[line]; + } + + setHoverIndex(index: number | null): void { + if (index === this.#hoveredRow) return; + this.#hoveredRow = index; + this.#requestRender(); + } + + clickItem(index: number): void { + this.#hoveredRow = index; + if (index === this.#selectedRow) { + const selected = this.#rows[index]; + if (selected) this.#activateAgent(selected); + return; + } + this.#selectedRow = index; + this.#requestRender(); } #handleTableInput(keyData: string): void { @@ -532,6 +628,7 @@ export class AgentHubOverlayComponent extends Container { } return; } + this.#hoveredRow = null; if (matchesKey(keyData, "j") || matchesSelectDown(keyData)) { if (this.#rows.length > 0) { this.#selectedRow = Math.min(this.#selectedRow + 1, this.#rows.length - 1); diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index c1875a9af..3bc944171 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -110,6 +110,23 @@ const MANUAL_LOGIN_PROMPT = "Paste the authorization code (or full redirect URL) export class SelectorController { constructor(private ctx: InteractiveModeContext) {} + /** + * Mount a primary fullscreen menu through the one polished modal path shared + * by Settings, Model Hub, and Agent Hub. + */ + #showFullscreenMenu(component: Component): OverlayHandle { + const handle = this.ctx.ui.showOverlay(component, { + anchor: "bottom-center", + width: "100%", + maxHeight: "100%", + margin: 0, + fullscreen: true, + }); + this.ctx.ui.setFocus(component); + this.ctx.ui.requestRender(); + return handle; + } + #defaultRoleMutationTail = Promise.resolve(); async #acquireDefaultRoleMutation(): Promise<() => void> { @@ -242,15 +259,7 @@ export class SelectorController { }, }, ); - overlayHandle = this.ctx.ui.showOverlay(selector, { - anchor: "bottom-center", - width: "100%", - maxHeight: "100%", - margin: 0, - fullscreen: true, - }); - this.ctx.ui.setFocus(selector); - this.ctx.ui.requestRender(); + overlayHandle = this.#showFullscreenMenu(selector); }); } @@ -1023,15 +1032,7 @@ export class SelectorController { initialProviderId: hubOptions.initialProviderId, }, ); - overlayHandle = this.ctx.ui.showOverlay(hub, { - anchor: "bottom-center", - width: "100%", - maxHeight: "100%", - margin: 0, - fullscreen: true, - }); - this.ctx.ui.setFocus(hub); - this.ctx.ui.requestRender(); + overlayHandle = this.#showFullscreenMenu(hub); } /** /login round-trip for a locked provider; reopen the hub on that provider only after a successful login. */ @@ -2000,26 +2001,19 @@ export class SelectorController { ...this.ctx.keybindings.getKeys("app.agents.hub"), ...this.ctx.keybindings.getKeys("app.session.observe"), ]; - let hub: AgentHubOverlayComponent | undefined; + let overlayHandle: OverlayHandle | undefined; + let closed = false; - // Render the hub inline in the editor slot — the same anchored region - // every other selector (model, session, tree, the `ask` tool) uses — - // rather than a floating overlay. A non-fullscreen overlay composited over - // a live transcript strands a stale copy in native scrollback every time a - // running subagent's progress grows the frame and scrolls the window; the - // hub is opened mid-run, so those copies stacked into a wall of duplicate - // "Agent Hub" frames bleeding the task tree behind them. As an editor-slot - // component it rides the normal append-only commit path: the transcript - // commits above it exactly once and the hub repaints in place. const done = () => { - hub?.dispose(); - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(this.ctx.editor); - this.ctx.ui.setFocus(this.ctx.editor); + if (closed) return; + closed = true; + hub.dispose(); + overlayHandle?.hide(); + this.focusActiveEditorArea(); this.ctx.ui.requestRender(); }; - hub = new AgentHubOverlayComponent({ + const hub = new AgentHubOverlayComponent({ observers, hubKeys, expandKeys: this.ctx.keybindings.getKeys("app.tools.expand"), @@ -2038,30 +2032,24 @@ export class SelectorController { }); const showReadyHub = () => { - // The double-← gesture passes requireContent so it stays inert when - // neither live nor persisted subagents are available. Persisted rows now - // load asynchronously, so defer the gate until that scan has refreshed the - // hub instead of treating the initial empty table as authoritative. + if (closed) return; + // The double-← gesture stays inert when neither live nor persisted + // subagents are available, so wait for discovery before making the gate. if (options?.requireContent && hub.isEmpty) { - hub.dispose(); + done(); return; } - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(hub); - this.ctx.ui.setFocus(hub); - // When the hub was raised by the editor's double-← gesture, prime its own - // close detector so the *next* single ← dismisses it — the two taps that - // opened it were consumed by the editor's detector (issue #4780). + // Prime the detector before the first frame when the editor's double-← + // gesture opened the hub, so the next single ← dismisses it. if (options?.armCloseTap) hub.armCloseTap(); - this.ctx.ui.requestRender(); + overlayHandle = this.#showFullscreenMenu(hub); }; if (options?.requireContent && hub.isEmpty) { void hub.persistedSubagentsReady.then(showReadyHub); - return; + } else { + showReadyHub(); } - - showReadyHub(); } } diff --git a/packages/coding-agent/src/registry/persisted-agents.ts b/packages/coding-agent/src/registry/persisted-agents.ts index 4804919bc..3aaadada2 100644 --- a/packages/coding-agent/src/registry/persisted-agents.ts +++ b/packages/coding-agent/src/registry/persisted-agents.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import { readLines } from "@oh-my-pi/pi-utils"; import { ADVISOR_TRANSCRIPT_FILENAME, isAdvisorTranscriptName } from "../advisor/transcript-recorder"; -import { SessionManager } from "../session/session-manager"; import { persistedVibeChildIds } from "../vibe/runtime"; import { type AgentRegistry, MAIN_AGENT_ID } from "./agent-registry"; @@ -10,18 +10,26 @@ import { type AgentRegistry, MAIN_AGENT_ID } from "./agent-registry"; * workers are revived through the Vibe registry's own journal, so the generic * persisted-subagent scan must not register them as plain `sub` refs. */ +const VIBE_LIFECYCLE_MARKER = Buffer.from('"vibe-session-lifecycle"'); + async function readPersistedVibeChildIds(sessionFile: string): Promise> { - let sessionManager: SessionManager; + const ids = new Set(); try { - sessionManager = await SessionManager.open(sessionFile, undefined, undefined, { suppressBreadcrumb: true }); + for await (const bytes of readLines(Bun.file(sessionFile).stream())) { + const line = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (line.indexOf(VIBE_LIFECYCLE_MARKER) === -1) continue; + try { + const entry: unknown = JSON.parse(line.toString("utf8")); + for (const id of persistedVibeChildIds([entry])) ids.add(id); + } catch { + // Match lenient session loading: one malformed line must not hide + // valid lifecycle entries later in the transcript. + } + } + return ids; } catch { return new Set(); } - try { - return persistedVibeChildIds(sessionManager.getEntries()); - } finally { - await sessionManager.close(); - } } /** Register persisted subagent and advisor transcripts as parked registry refs. */ diff --git a/packages/coding-agent/src/vibe/runtime.ts b/packages/coding-agent/src/vibe/runtime.ts index e77c9ef8a..eb1983042 100644 --- a/packages/coding-agent/src/vibe/runtime.ts +++ b/packages/coding-agent/src/vibe/runtime.ts @@ -25,7 +25,6 @@ import { MCPManager } from "../mcp/manager"; import vibeTurnResultTemplate from "../prompts/tools/vibe-turn-result.md" with { type: "text" }; import { AgentLifecycleManager } from "../registry/agent-lifecycle"; import { type AgentRef, AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry"; -import type { SessionEntry } from "../session/session-entries"; import { SessionManager, SessionPersistenceIndeterminateError } from "../session/session-manager"; import { getBundledAgent } from "../task/agents"; import { type ExecutorOptions, runSubagentFollowUpTurn, runSubprocess } from "../task/executor"; @@ -321,11 +320,12 @@ function parseLifecycleEvent(value: unknown): VibeLifecycleEvent | undefined { return undefined; } -/** Child ids claimed by any valid Vibe spawn event, independent of current parent scope. */ -export function persistedVibeChildIds(entries: Iterable): Set { +/** Child ids claimed by valid Vibe spawn records from untrusted persisted JSON. */ +export function persistedVibeChildIds(entries: Iterable): Set { const ids = new Set(); - for (const entry of entries) { - if (entry.type !== "custom" || entry.customType !== VIBE_LIFECYCLE_CUSTOM_TYPE) continue; + for (const value of entries) { + const entry = objectRecord(value); + if (entry?.type !== "custom" || entry.customType !== VIBE_LIFECYCLE_CUSTOM_TYPE) continue; const event = parseLifecycleEvent(entry.data); if ( event?.action === "spawn" && diff --git a/packages/coding-agent/test/agent-hub-activate.test.ts b/packages/coding-agent/test/agent-hub-activate.test.ts index 0175d9c19..294b78891 100644 --- a/packages/coding-agent/test/agent-hub-activate.test.ts +++ b/packages/coding-agent/test/agent-hub-activate.test.ts @@ -116,6 +116,62 @@ describe("Agent hub Enter activation", () => { expect(agents.get("Worker")?.sessionFile).toBe(workerSessionFile); hub.dispose(); }); + it("avoids multi-second event-loop stalls while discovering agents from a large session", async () => { + using tempDir = TempDir.createSync("@omp-agent-hub-responsive-"); + const sessionFile = path.join(tempDir.path(), "main.jsonl"); + const header = JSON.stringify({ + type: "session", + version: 3, + id: "responsive-test", + timestamp: new Date().toISOString(), + cwd: tempDir.path(), + }); + const payload = "x".repeat(250_000); + const message = JSON.stringify({ + type: "message", + id: "large-message", + parentId: null, + timestamp: new Date().toISOString(), + message: { + role: "user", + content: [{ type: "text", text: payload }], + timestamp: Date.now(), + }, + }); + await Bun.write(sessionFile, `${header}\n${Array.from({ length: 600 }, () => message).join("\n")}\n`); + Bun.gc(true); + + const agents = new AgentRegistry(); + const hub = new AgentHubOverlayComponent({ + observers: new SessionObserverRegistry(), + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + focusAgent: async () => {}, + sessionFile, + }); + + let maxLagMs = 0; + let previous = performance.now(); + const timer = setInterval(() => { + const now = performance.now(); + maxLagMs = Math.max(maxLagMs, now - previous - 2); + previous = now; + }, 2); + try { + await hub.persistedSubagentsReady; + await Bun.sleep(10); + } finally { + clearInterval(timer); + hub.dispose(); + } + + // Guard the original seconds-long freeze without treating shared-runner + // scheduling jitter as an Agent Hub regression. + expect(maxLagMs).toBeLessThan(500); + }); it("does not generically revive active or tombstoned Vibe children copied by a post-exit fork", async () => { using tempDir = TempDir.createSync("@omp-agent-hub-vibe-fork-"); @@ -191,21 +247,22 @@ describe("Agent hub Enter activation", () => { const editor = {}; let capturedHub: AgentHubOverlayComponent | undefined; - let editorRestoredCount = 0; const focusedIds: string[] = []; const focusResolved = Promise.withResolvers(); const editorFocused = Promise.withResolvers(); const focusTargets: unknown[] = []; const editorContainer = { + children: [editor], clear: () => {}, - addChild: (child: unknown) => { - if (child === editor) editorRestoredCount++; - else capturedHub = child as AgentHubOverlayComponent; - }, + addChild: () => {}, }; const ctx = { keybindings: { getKeys: () => [] }, ui: { + showOverlay: (component: AgentHubOverlayComponent) => { + capturedHub = component; + return { hide: () => {} }; + }, setFocus: (target: unknown) => { focusTargets.push(target); if (target === editor) editorFocused.resolve(); @@ -235,7 +292,6 @@ describe("Agent hub Enter activation", () => { await editorFocused.promise; expect(focusedIds).toEqual([AGENT_ID]); - expect(editorRestoredCount).toBe(1); expect(focusTargets.at(-1)).toBe(editor); capturedHub!.dispose(); }); @@ -252,12 +308,19 @@ describe("Agent hub double-← gating", () => { function setup(agents: AgentRegistry, sessionFile: string | null = null) { let shown: AgentHubOverlayComponent | undefined; + let overlayOptions: Record | undefined; const shownReady = Promise.withResolvers(); const editor = {}; const focusTargets: unknown[] = []; const ctx = { keybindings: { getKeys: () => [] }, ui: { + showOverlay: (component: AgentHubOverlayComponent, options: Record) => { + shown = component; + overlayOptions = options; + shownReady.resolve(component); + return { hide: () => {} }; + }, setFocus: (target: unknown) => { focusTargets.push(target); }, @@ -265,13 +328,9 @@ describe("Agent hub double-← gating", () => { }, editor, editorContainer: { + children: [editor], clear: () => {}, - addChild: (child: unknown) => { - if (child !== editor) { - shown = child as AgentHubOverlayComponent; - shownReady.resolve(shown); - } - }, + addChild: () => {}, }, collabGuest: { agentRegistry: agents, hubRemote: undefined }, focusAgentSession: async () => {}, @@ -285,6 +344,7 @@ describe("Agent hub double-← gating", () => { editor, shown: () => shown, shownReady: shownReady.promise, + overlayOptions: () => overlayOptions, focusTargets, }; } @@ -347,14 +407,23 @@ describe("Agent hub double-← gating", () => { shownHub!.dispose(); }); - it("the explicit hub key opens the empty roster even with no subagents", () => { + it("the explicit hub opens fullscreen before persisted subagents load", async () => { + using tempDir = TempDir.createSync("@omp-agent-hub-explicit-"); + const sessionFile = path.join(tempDir.path(), "main.jsonl"); + await Bun.write(sessionFile, ""); + await Bun.write(path.join(tempDir.path(), "main", "Worker.jsonl"), ""); const agents = new AgentRegistry(); - const { controller, shown } = setup(agents); + const { controller, shown, overlayOptions } = setup(agents, sessionFile); controller.showAgentHub(new SessionObserverRegistry()); - expect(shown()).toBeDefined(); - shown()!.dispose(); + const hub = shown(); + expect(hub).toBeDefined(); + expect(overlayOptions()).toMatchObject({ width: "100%", maxHeight: "100%", margin: 0, fullscreen: true }); + expect(agents.get("Worker")).toBeUndefined(); + await hub!.persistedSubagentsReady; + expect(agents.get("Worker")?.status).toBe("parked"); + hub!.dispose(); }); it("armCloseTap lets a single ← dismiss the hub the opening ←← raised", () => { diff --git a/packages/coding-agent/test/agent-hub-ordering.test.ts b/packages/coding-agent/test/agent-hub-ordering.test.ts index 2588dd3eb..8be901af1 100644 --- a/packages/coding-agent/test/agent-hub-ordering.test.ts +++ b/packages/coding-agent/test/agent-hub-ordering.test.ts @@ -7,7 +7,7 @@ */ 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 { type AgentHubDeps, 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"; @@ -40,7 +40,7 @@ function stubStdoutGeometry(cols: number): GeometryStub { }; } -function makeHub(agents: AgentRegistry) { +function makeHub(agents: AgentRegistry, overrides: Partial = {}) { return new AgentHubOverlayComponent({ observers: new SessionObserverRegistry(), hubKeys: [], @@ -49,18 +49,40 @@ function makeHub(agents: AgentRegistry) { registry: agents, irc: new IrcBus(agents), focusAgent: async () => {}, + ...overrides, }); } -function renderedAgentIds(hub: AgentHubOverlayComponent): string[] { +interface RenderedAgentRow { + id: string; + selected: boolean; +} + +function renderedAgentRows(hub: AgentHubOverlayComponent): RenderedAgentRow[] { // Entry first lines are ` …`; task lines are // indented deeper and chrome lines never carry the cursor slot. - const ids: string[] = []; + const rows: RenderedAgentRow[] = []; for (const raw of hub.render(120)) { - const match = /^ (?:❯| ) (\S+) (\S+)/u.exec(Bun.stripANSI(raw)); - if (match) ids.push(match[2]!); + const match = /^ (❯| ) (\S+) (\S+)/u.exec(Bun.stripANSI(raw)); + if (match) rows.push({ id: match[3]!, selected: match[1] === "❯" }); } - return ids; + return rows; +} + +function renderedAgentIds(hub: AgentHubOverlayComponent): string[] { + return renderedAgentRows(hub).map(row => row.id); +} + +function selectedAgentId(hub: AgentHubOverlayComponent): string | undefined { + return renderedAgentRows(hub).find(row => row.selected)?.id; +} + +function leftClick(row1Based: number): string { + return `\x1b[<0;4;${row1Based}M`; +} + +function wheel(direction: "up" | "down"): string { + return `\x1b[<${direction === "down" ? 65 : 64};4;4M`; } describe("Agent hub row ordering", () => { @@ -144,15 +166,7 @@ describe("Agent hub row ordering", () => { }, ]); - const hub = new AgentHubOverlayComponent({ - observers, - hubKeys: [], - onDone: () => {}, - requestRender: () => {}, - registry: agents, - irc: new IrcBus(agents), - focusAgent: async () => {}, - }); + const hub = makeHub(agents, { observers }); const lines = hub.render(80); for (const line of lines) { @@ -165,6 +179,73 @@ describe("Agent hub row ordering", () => { hub.dispose(); }); + it("fits the fullscreen table to short terminals and windows large registries", () => { + geometry = stubStdoutGeometry(80); + geometry.setRows(10); + const agents = new AgentRegistry(); + for (let i = 0; i < 50; i++) { + agents.register({ + id: `Agent${i}`, + displayName: `Agent ${i}`, + kind: "sub", + session: {} as AgentSession, + }); + } + + const observers = new SessionObserverRegistry(); + const sessions = vi.spyOn(observers, "getSessions").mockReturnValue([]); + const hub = makeHub(agents, { observers }); + + try { + const lines = hub.render(80); + expect(lines.length).toBe(10); + expect(sessions.mock.calls.length).toBeLessThan(agents.list().length); + expect(Bun.stripANSI(lines.join("\n"))).toContain("…"); + } finally { + hub.dispose(); + } + }); + it("matches fullscreen menu mouse selection, wheel, and activation", async () => { + geometry = stubStdoutGeometry(120); + const agents = new AgentRegistry(); + setSystemTime(1_000); + agents.register({ id: "Alpha", displayName: "Alpha", kind: "sub", session: {} as AgentSession }); + setSystemTime(2_000); + agents.register({ id: "Beta", displayName: "Beta", kind: "sub", session: {} as AgentSession }); + setSystemTime(3_000); + agents.register({ id: "Gamma", displayName: "Gamma", kind: "sub", session: {} as AgentSession }); + + const focused: string[] = []; + const done = vi.fn(); + const hub = makeHub(agents, { + onDone: done, + focusAgent: async id => { + focused.push(id); + }, + }); + + try { + expect(selectedAgentId(hub)).toBe("Gamma"); + hub.handleInput(wheel("down")); + expect(selectedAgentId(hub)).toBe("Beta"); + + const frame = hub.render(120); + const alphaRow = frame.findIndex(line => /^ {3}\S+ Alpha/u.test(Bun.stripANSI(line))); + expect(alphaRow).toBeGreaterThanOrEqual(0); + hub.handleInput(leftClick(alphaRow + 1)); + expect(selectedAgentId(hub)).toBe("Alpha"); + expect(focused).toEqual([]); + + const selectedFrame = hub.render(120); + const selectedAlphaRow = selectedFrame.findIndex(line => /^ ❯ \S+ Alpha/u.test(Bun.stripANSI(line))); + hub.handleInput(leftClick(selectedAlphaRow + 1)); + await Promise.resolve(); + expect(focused).toEqual(["Alpha"]); + expect(done).toHaveBeenCalledTimes(1); + } finally { + hub.dispose(); + } + }); it("flags a fallback badge for observer-only rows with no live session", () => { geometry = stubStdoutGeometry(120); @@ -188,15 +269,7 @@ describe("Agent hub row ordering", () => { }, ]); - const hub = new AgentHubOverlayComponent({ - observers, - hubKeys: [], - onDone: () => {}, - requestRender: () => {}, - registry: agents, - irc: new IrcBus(agents), - focusAgent: async () => {}, - }); + const hub = makeHub(agents, { observers }); try { expect(Bun.stripANSI(hub.render(120).join("\n"))).toContain("fallback → openai/gpt-4o"); @@ -230,15 +303,7 @@ describe("Agent hub row ordering", () => { }, ]); - const hub = new AgentHubOverlayComponent({ - observers, - hubKeys: [], - onDone: () => {}, - requestRender: () => {}, - registry: agents, - irc: new IrcBus(agents), - focusAgent: async () => {}, - }); + const hub = makeHub(agents, { observers }); try { expect(Bun.stripANSI(hub.render(120).join("\n"))).toContain("fallback → fireworks/kimi-k2");