diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ad20be261ae..58a45d6c2eb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -405,6 +405,9 @@ - Fixed PageUp/PageDown in the model browser wrapping past the list edges instead of clamping - Fixed the hover highlight sticking to the last hovered model row when the pointer moved into the provider sidebar +### Changed + +- Changed Agent Hub to default to running-agent visibility, archive parked and aborted agents, and expose live runtime metadata with structured activity ([#5251](https://github.com/can1357/oh-my-pi/pull/5251) by [@wolfiesch](https://github.com/wolfiesch)). ## [16.4.6] - 2026-07-12 diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index 3ac0f28c780..137aee5ea7b 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -15,8 +15,9 @@ */ 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 { formatAge, getProjectDir, logger } from "@oh-my-pi/pi-utils"; +import { formatAge, formatDuration, formatNumber, getProjectDir, logger, sanitizeText } from "@oh-my-pi/pi-utils"; import type { KeyId } from "../../config/keybindings"; +import { formatModelStringWithRouting } from "../../config/model-resolver"; import type { MessageRenderer } from "../../extensibility/extensions/types"; import { IrcBus } from "../../irc/bus"; import { AgentLifecycleManager } from "../../registry/agent-lifecycle"; @@ -30,6 +31,7 @@ import { theme } from "../theme/theme"; import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers"; import { AgentTranscriptViewer } from "./agent-transcript-viewer"; import { DynamicBorder } from "./dynamic-border"; +import { formatContextUsage } from "./status-line/context-thresholds"; /** Refresh cadence for the relative-time column */ const AGE_TICK_MS = 5_000; @@ -44,7 +46,7 @@ function contentWidth(): number { /** Sanitize a line for TUI display: replace tabs, then truncate to viewport width. */ function sanitizeLine(text: string, maxWidth?: number): string { - const singleLine = replaceTabs(text).replace(/[\r\n]+/g, " "); + const singleLine = replaceTabs(sanitizeText(text)).replace(/[\r\n]+/g, " "); return truncateToWidth(singleLine, maxWidth ?? contentWidth()); } @@ -53,6 +55,20 @@ function clampHubLine(line: string, width: number): string { } const STATUS_ORDER: Record = { running: 0, idle: 1, parked: 2, aborted: 3 }; +type HubTab = "active" | "archive"; + +interface AgentRuntimeView { + model?: string; + thinkingLevel?: string; + lspEnabled?: boolean; + advisorActive?: boolean; + turns?: number; + tokens?: number; + contextTokens?: number; + contextWindow?: number; + toolCount?: number; + cost?: number; +} /** Status glyph, colored per theme status conventions. The title-line counts spell out the words. */ function statusGlyph(status: AgentStatus): string { @@ -164,13 +180,26 @@ export class AgentHubOverlayComponent extends Container { /** Resolves after persisted historical subagents have been registered and rows refreshed. */ readonly persistedSubagentsReady: Promise; - // Table state + // Dashboard state + #allRows: AgentRef[] = []; #rows: AgentRef[] = []; #selectedRow = 0; + #selectedByTab: Partial> = {}; + #tab: HubTab = "active"; + #showIdle = false; #notice: string | undefined; - /** Captured row order from the first refresh; keeps the hub stable while open. */ + /** Captured row order from the first refresh; keeps each status group stable while open. */ #rowOrder: Map | undefined; - /** Double-tap window state for the table's left-left "close hub" gesture. */ + #liveMetricsCache: + | { + id: string; + revision: number; + metrics: Pick< + AgentRuntimeView, + "turns" | "tokens" | "contextTokens" | "contextWindow" | "toolCount" | "cost" + >; + } + | undefined; #lastLeftTap = 0; // Transcript-viewer launch deps (passed through to AgentTranscriptViewer). @@ -237,7 +266,7 @@ export class AgentHubOverlayComponent extends Container { * those included must wait for {@link persistedSubagentsReady} first. */ get isEmpty(): boolean { - return this.#rows.length === 0; + return this.#allRows.length === 0; } /** Tear down every subscription and timer. Called by the overlay owner on close. */ @@ -349,35 +378,52 @@ export class AgentHubOverlayComponent extends Container { } #refreshRows(): void { - const selectedId = this.#rows[this.#selectedRow]?.id; + const selectedId = this.#selectedByTab[this.#tab] ?? this.#rows[this.#selectedRow]?.id; const refs = this.#registry.list().filter(ref => ref.id !== MAIN_AGENT_ID); if (!this.#rowOrder) { - // First refresh (usually the constructor): order by status, then recency. - this.#rows = refs.sort( + const initiallySorted = [...refs].sort( (a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || b.lastActivity - a.lastActivity, ); - this.#rowOrder = new Map(this.#rows.map((ref, i) => [ref.id, i])); - } else { - // After the hub is open, freeze the relative order so keyboard selection - // does not jump around as agents heartbeat or update activity. New agents - // are appended at the end and then stay put. - 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; - }); - for (const ref of this.#rows) { - if (!this.#rowOrder.has(ref.id)) { - this.#rowOrder.set(ref.id, this.#rowOrder.size); - } - } + this.#rowOrder = new Map(initiallySorted.map((ref, i) => [ref.id, i])); + } + for (const ref of refs) { + if (!this.#rowOrder.has(ref.id)) this.#rowOrder.set(ref.id, this.#rowOrder.size); } + this.#allRows = refs; + this.#rows = this.#rowsForTab(this.#tab); const keptIndex = selectedId ? this.#rows.findIndex(ref => ref.id === selectedId) : -1; this.#selectedRow = keptIndex >= 0 ? keptIndex : Math.min(this.#selectedRow, Math.max(0, this.#rows.length - 1)); + const current = this.#rows[this.#selectedRow]; + if (current) this.#selectedByTab[this.#tab] = current.id; + } + + #rowsForTab(tab: HubTab): AgentRef[] { + const refs = + tab === "active" + ? this.#allRows.filter( + ref => + ref.kind !== "advisor" && (ref.status === "running" || (this.#showIdle && ref.status === "idle")), + ) + : this.#allRows.filter( + ref => ref.kind === "advisor" || ref.status === "parked" || ref.status === "aborted", + ); + return refs.sort((a, b) => { + const groupA = this.#rowGroup(a, tab); + const groupB = this.#rowGroup(b, tab); + if (groupA !== groupB) return groupA - groupB; + return ( + (this.#rowOrder?.get(a.id) ?? Number.MAX_SAFE_INTEGER) - + (this.#rowOrder?.get(b.id) ?? Number.MAX_SAFE_INTEGER) + ); + }); + } + + #rowGroup(ref: AgentRef, tab: HubTab): number { + if (tab === "active") return ref.status === "running" ? 0 : 1; + if (ref.kind === "advisor") return 2; + return ref.status === "parked" ? 0 : 1; } #observableFor(id: string): ObservableSession | undefined { @@ -390,67 +436,212 @@ export class AgentHubOverlayComponent extends Container { #renderTable(width: number): string[] { const lines: string[] = []; + const counts = this.#counts(); 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}`) : ""}`); + lines.push( + ` ${theme.fg("accent", `Agent Hub · ${counts.running} running · ${counts.idle} idle · ${counts.archived} archived`)}`, + ); + lines.push( + ` ${this.#tab === "active" ? theme.bold("Active") : "Active"} (${counts.running + counts.idle}) ${this.#tab === "archive" ? theme.bold("Archive") : "Archive"} (${counts.archived})`, + ); lines.push(...new DynamicBorder().render(width)); - 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; - } - 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`)}`); + const listLines = this.#renderList(width >= 96 ? Math.floor((width - 3) * 0.44) : width); + const inspectorLines = this.#renderInspector(this.#rows[this.#selectedRow], width >= 96 ? width : width - 2); + if (width >= 96) { + const leftWidth = Math.floor((width - 3) * 0.44); + const rightWidth = width - leftWidth - 3; + const bodyHeight = Math.max(listLines.length, inspectorLines.length); + const separator = theme.fg("dim", ` ${theme.boxRound.vertical} `); + for (let i = 0; i < bodyHeight; i++) { + const left = truncateToWidth(listLines[i] ?? "", leftWidth); + const leftPadded = left + padding(Math.max(0, leftWidth - visibleWidth(left))); + lines.push(leftPadded + separator + truncateToWidth(inspectorLines[i] ?? "", rightWidth)); } + } else { + lines.push(...listLines); + const availableInspectorLines = Math.max( + 0, + Math.min(9, (process.stdout.rows || 40) - lines.length - (this.#notice ? 4 : 3)), + ); + if (availableInspectorLines > 0) lines.push(...inspectorLines.slice(0, availableInspectorLines)); } if (this.#notice) { lines.push(` ${theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width - 2)))}`); } lines.push(""); - lines.push(` ${theme.fg("dim", "j/k:select Enter:open r:revive x:kill Esc/←←:close")}`); + const footer = + this.#tab === "active" + ? "j/k:select Enter:focus t:transcript i:idle Tab:switch Esc/←←:close" + : "j/k:select Enter:transcript t:transcript r:revive x:kill Tab:switch Esc/←←:close"; + lines.push(` ${theme.fg("dim", footer)}`); lines.push(...new DynamicBorder().render(width)); return lines; } - #statusSummary(): string { - const counts: Record = { running: 0, idle: 0, parked: 0, aborted: 0 }; - for (const ref of this.#rows) { - counts[ref.status]++; + #counts(): { running: number; idle: number; archived: number } { + let running = 0; + let idle = 0; + let archived = 0; + for (const ref of this.#allRows) { + if (ref.kind === "advisor" || ref.status === "parked" || ref.status === "aborted") archived++; + else if (ref.status === "running") running++; + else if (ref.status === "idle") idle++; + } + return { running, idle, archived }; + } + + #renderList(width: number): string[] { + const lines: string[] = []; + const runningCount = this.#allRows.filter(ref => ref.status === "running" && ref.kind !== "advisor").length; + const idleCount = this.#allRows.filter(ref => ref.status === "idle" && ref.kind !== "advisor").length; + if (this.#tab === "active" && runningCount === 0) lines.push(` ${theme.fg("dim", "No agents running.")}`); + + const termHeight = process.stdout.rows || 40; + const maxVisible = Math.max(3, termHeight - 10 - (this.#notice ? 1 : 0)); + let start = 0; + if (this.#rows.length > maxVisible) { + start = Math.min(Math.max(0, this.#selectedRow - Math.floor(maxVisible / 2)), this.#rows.length - maxVisible); + } + const end = Math.min(start + maxVisible, this.#rows.length); + for (let i = start; i < end; i++) lines.push(...this.#renderEntry(this.#rows[i], i === this.#selectedRow, width)); + if (end < this.#rows.length) lines.push(` ${theme.fg("dim", `… ${this.#rows.length - end} more`)}`); + if (this.#tab === "active" && idleCount > 0) { + lines.push(` ${theme.fg("dim", `${this.#showIdle ? "▾" : "▸"} ${idleCount} idle agents`)}`); + } + if (this.#tab === "archive" && this.#rows.length === 0) lines.push(` ${theme.fg("dim", "No archived agents.")}`); + return lines; + } + + #runtimeView(ref: AgentRef): AgentRuntimeView { + const progress = this.#observableFor(ref.id)?.progress; + const view: AgentRuntimeView = { + model: progress?.resolvedModel, + thinkingLevel: progress?.thinkingLevel, + lspEnabled: progress?.lspEnabled, + advisorActive: progress?.advisorActive, + turns: progress?.requests, + tokens: progress?.tokens, + contextTokens: progress?.contextTokens, + contextWindow: progress?.contextWindow, + toolCount: progress?.toolCount, + cost: progress?.cost, + }; + const session = ref.session; + if (!session) return view; + try { + if (session.model) view.model = formatModelStringWithRouting(session.model); + } catch {} + try { + const thinkingLevel = session.thinkingLevel; + if (thinkingLevel !== undefined) view.thinkingLevel = thinkingLevel; + } catch {} + try { + const toolNames = session.getActiveToolNames?.(); + if (toolNames !== undefined) { + view.lspEnabled = toolNames.includes("lsp"); + } + } catch {} + try { + const advisorActive = session.isAdvisorActive?.(); + if (advisorActive !== undefined) { + view.advisorActive = advisorActive; + } + } catch {} + + let revision = -1; + try { + revision = session.contextUsageRevision; + } catch {} + if (this.#liveMetricsCache?.id === ref.id && this.#liveMetricsCache.revision === revision) { + Object.assign(view, this.#liveMetricsCache.metrics); + return view; + } + const metrics: AgentRuntimeView = {}; + try { + const stats = session.getSessionStats?.(); + if (stats) { + metrics.turns = stats.assistantMessages; + metrics.tokens = stats.tokens.input + stats.tokens.output + stats.tokens.cacheWrite; + metrics.toolCount = stats.toolCalls; + metrics.cost = stats.cost; + } + } catch {} + try { + const context = session.getContextUsage?.(); + if (context) { + metrics.contextTokens = context.tokens; + metrics.contextWindow = context.contextWindow; + } + } catch {} + this.#liveMetricsCache = { id: ref.id, revision, metrics }; + Object.assign(view, metrics); + return view; + } + + #renderInspector(ref: AgentRef | undefined, width: number): string[] { + if (!ref) return [` ${theme.fg("dim", "No agent selected.")}`]; + const observed = this.#observableFor(ref.id); + const progress = observed?.progress; + const runtime = this.#runtimeView(ref); + const unknown = "unknown"; + const capability = (value: boolean | undefined): string => (value === undefined ? unknown : value ? "on" : "off"); + const age = formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000))); + const parent = ref.parentId ? `${ref.kind}/of ${sanitizeLine(ref.parentId)}` : ref.kind; + const lines = [ + ` ${theme.bold(sanitizeLine(ref.id))} · ${ref.status} · ${parent} · unread ${this.#irc.unreadCount(ref.id)} · ${age}`, + ]; + const task = observed?.description ?? progress?.assignment ?? progress?.task; + lines.push(` Task: ${task ? sanitizeLine(task, TRUNCATE_LENGTHS.TITLE) : unknown}`); + lines.push(` Model ${sanitizeLine(runtime.model ?? unknown)} · Reasoning ${runtime.thinkingLevel ?? unknown}`); + lines.push(` Advisor ${capability(runtime.advisorActive)} · LSP ${capability(runtime.lspEnabled)}`); + const context = + runtime.contextTokens === undefined + ? unknown + : formatContextUsage( + runtime.contextWindow && runtime.contextWindow > 0 + ? (runtime.contextTokens / runtime.contextWindow) * 100 + : undefined, + runtime.contextWindow ?? 0, + runtime.contextTokens, + ); + lines.push( + ` Turns ${runtime.turns === undefined ? unknown : formatNumber(runtime.turns)} · Tokens ${runtime.tokens === undefined ? unknown : formatNumber(runtime.tokens)} · Context ${context}`, + ); + lines.push( + ` Tools ${runtime.toolCount === undefined ? unknown : formatNumber(runtime.toolCount)} · Duration ${progress ? formatDuration(progress.durationMs) : unknown} · Cost ${runtime.cost === undefined ? unknown : `$${runtime.cost.toFixed(2)}`}`, + ); + if (!progress) { + lines.push(` ${theme.fg("dim", "No structured progress yet.")}`); + if (this.#tab === "archive") lines.push(` ${theme.fg("dim", "Open transcript for persisted details.")}`); + return lines.map(line => truncateToWidth(line, Math.max(1, width))); } - const parts: string[] = []; - for (const status of ["running", "idle", "parked", "aborted"] as const) { - const count = counts[status]; - if (count > 0) parts.push(`${count} ${status}`); + + let activity = progress.lastIntent; + if (progress.currentTool) { + const elapsed = progress.currentToolStartMs + ? ` · ${formatDuration(Math.max(0, Date.now() - progress.currentToolStartMs))}` + : ""; + activity = `${progress.currentTool}${progress.currentToolArgs ? ` ${progress.currentToolArgs}` : ""}${elapsed}`; } - return parts.join(theme.sep.dot); + if (progress.retryState) { + activity = `retry ${progress.retryState.attempt}/${progress.retryState.maxAttempts} in ${formatDuration(progress.retryState.delayMs)}: ${progress.retryState.errorMessage}`; + } else if (progress.retryFailure) { + activity = `retry failed at ${progress.retryFailure.attempt}: ${progress.retryFailure.errorMessage}`; + } + lines.push(` Activity: ${sanitizeLine(activity ?? unknown, TRUNCATE_LENGTHS.TITLE)}`); + const recent = progress.recentTools.slice(-3).reverse(); + if (recent.length === 0) { + lines.push(" Recent tools: none"); + } else { + lines.push(" Recent tools:"); + for (const tool of recent) { + const detail = `${tool.tool}${tool.args ? ` ${tool.args}` : ""} · ${formatAge(Math.max(1, Math.round((Date.now() - tool.endMs) / 1000)))}`; + lines.push(` ${sanitizeLine(detail, TRUNCATE_LENGTHS.TITLE)}`); + } + } + return lines.map(line => truncateToWidth(line, Math.max(1, width))); } /** @@ -515,33 +706,48 @@ export class AgentHubOverlayComponent extends Container { } return; } + if (matchesKey(keyData, "tab") || matchesKey(keyData, "shift+tab") || keyData === "\t") { + this.#selectedByTab[this.#tab] = this.#rows[this.#selectedRow]?.id; + this.#tab = this.#tab === "active" ? "archive" : "active"; + this.#selectedRow = 0; + this.#refreshRows(); + this.#notice = undefined; + this.#requestRender(); + return; + } + if (this.#tab === "active" && keyData === "i") { + this.#selectedByTab.active = this.#rows[this.#selectedRow]?.id; + this.#showIdle = !this.#showIdle; + this.#refreshRows(); + this.#requestRender(); + return; + } if (matchesKey(keyData, "j") || matchesSelectDown(keyData)) { - if (this.#rows.length > 0) { - this.#selectedRow = Math.min(this.#selectedRow + 1, this.#rows.length - 1); - } + if (this.#rows.length > 0) this.#selectedRow = Math.min(this.#selectedRow + 1, this.#rows.length - 1); + this.#selectedByTab[this.#tab] = this.#rows[this.#selectedRow]?.id; this.#requestRender(); return; } if (matchesKey(keyData, "k") || matchesSelectUp(keyData)) { - if (this.#rows.length > 0) { - this.#selectedRow = Math.max(this.#selectedRow - 1, 0); - } + if (this.#rows.length > 0) this.#selectedRow = Math.max(this.#selectedRow - 1, 0); + this.#selectedByTab[this.#tab] = this.#rows[this.#selectedRow]?.id; this.#requestRender(); return; } + const selected = this.#rows[this.#selectedRow]; if (matchesKey(keyData, "enter") || keyData === "\r" || keyData === "\n") { - const selected = this.#rows[this.#selectedRow]; if (selected) this.#activateAgent(selected); return; } - if (keyData === "r") { - this.#reviveSelected(); + if (keyData === "t") { + if (selected) this.openChat(selected.id); return; } - if (keyData === "x") { - this.#killSelected(); + if (this.#tab === "archive" && keyData === "r") { + this.#reviveSelected(); return; } + if (this.#tab === "archive" && keyData === "x") this.#killSelected(); } /** @@ -552,16 +758,13 @@ export class AgentHubOverlayComponent extends Container { */ #activateAgent(ref: AgentRef): void { this.#notice = undefined; - const focusAgent = this.#focusAgent; - // Advisor refs are read-only transcripts with no live/ revivable session; - // open the in-hub chat view (file-backed) instead of trying to focus one. - if (ref.kind === "advisor" || this.#remote || !focusAgent) { + if (this.#tab === "archive" || this.#remote || !this.#focusAgent) { this.openChat(ref.id); return; } void (async () => { try { - await focusAgent(ref.id); // ensureLive inside revives parked agents; no parking, no session files + await this.#focusAgent!(ref.id); this.#onDone(); } catch (error) { this.#notice = error instanceof Error ? error.message : String(error); diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 12917c5ee2b..1c72e6c9379 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -1501,6 +1501,11 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor { popLoopPhase(); } } + if (event.type === "thinking_level_changed") { + progress.thinkingLevel = event.thinkingLevel; + scheduleProgress(true); + return; + } if (event.type === "retry_fallback_applied") { progress.resolvedModel = event.to; scheduleProgress(true); @@ -2374,6 +2379,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise/`, optionally suffixed with `:` when the level was set explicitly. Undefined when the model could not be resolved. */ resolvedModel?: string; + /** Current reasoning level, when known from the live session. */ + thinkingLevel?: ThinkingLevel; + /** Whether the session exposes the LSP tool. Undefined for older or cold-restored snapshots. */ + lspEnabled?: boolean; + /** Whether an advisor is active for the session. Undefined for older or cold-restored snapshots. */ + advisorActive?: boolean; /** Data extracted by registered subprocess tool handlers (keyed by tool name) */ extractedToolData?: Record; /** diff --git a/packages/coding-agent/test/agent-hub-activate.test.ts b/packages/coding-agent/test/agent-hub-activate.test.ts index 52fec059d8e..cbbc8314bbf 100644 --- a/packages/coding-agent/test/agent-hub-activate.test.ts +++ b/packages/coding-agent/test/agent-hub-activate.test.ts @@ -12,6 +12,7 @@ import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/ 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 type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { AgentLifecycleManager } from "@oh-my-pi/pi-coding-agent/registry/agent-lifecycle"; 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 { TempDir } from "@oh-my-pi/pi-utils"; @@ -89,6 +90,95 @@ describe("Agent hub Enter activation", () => { hub.dispose(); }); + it("t opens the active transcript without changing focus", () => { + const focusedIds: string[] = []; + const agents = new AgentRegistry(); + agents.register({ + id: AGENT_ID, + displayName: AGENT_ID, + kind: "sub", + session: { subscribe: () => () => {} } as unknown as AgentSession, + status: "running", + }); + let overlayCalls = 0; + const hub = new AgentHubOverlayComponent({ + observers: new SessionObserverRegistry(), + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + focusAgent: async id => { + focusedIds.push(id); + }, + ui: { + showOverlay: () => { + overlayCalls++; + return { hide: () => {} }; + }, + setFocus: () => {}, + requestRender: () => {}, + requestComponentRender: () => {}, + } as never, + }); + + hub.handleInput("t"); + expect(overlayCalls).toBe(1); + expect(focusedIds).toEqual([]); + hub.dispose(); + }); + + it("archive Enter opens a transcript and r is the only revive action", async () => { + const agents = new AgentRegistry(); + agents.register({ + id: AGENT_ID, + displayName: AGENT_ID, + kind: "sub", + session: null, + sessionFile: "/tmp/worker.jsonl", + status: "parked", + }); + const focusedIds: string[] = []; + const revivedIds: string[] = []; + let overlayCalls = 0; + const hub = new AgentHubOverlayComponent({ + observers: new SessionObserverRegistry(), + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + focusAgent: async id => { + focusedIds.push(id); + }, + lifecycle: { + ensureLive: async (id: string) => { + revivedIds.push(id); + return {} as AgentSession; + }, + } as unknown as AgentLifecycleManager, + ui: { + showOverlay: () => { + overlayCalls++; + return { hide: () => {} }; + }, + setFocus: () => {}, + requestRender: () => {}, + requestComponentRender: () => {}, + } as never, + }); + + hub.handleInput("\t"); + hub.handleInput("\r"); + expect(overlayCalls).toBe(1); + expect(focusedIds).toEqual([]); + expect(revivedIds).toEqual([]); + hub.handleInput("r"); + await Promise.resolve(); + expect(revivedIds).toEqual([AGENT_ID]); + hub.dispose(); + }); + it("lists persisted subagent session files after restart", async () => { using tempDir = TempDir.createSync("@omp-agent-hub-persisted-"); const sessionFile = path.join(tempDir.path(), "main.jsonl"); @@ -108,6 +198,7 @@ describe("Agent hub Enter activation", () => { }); await hub.persistedSubagentsReady; + hub.handleInput("\t"); const rendered = Bun.stripANSI(hub.render(120).join("\n")); expect(rendered).toContain("Worker"); expect(rendered).toContain("parked"); diff --git a/packages/coding-agent/test/agent-hub-dashboard.test.ts b/packages/coding-agent/test/agent-hub-dashboard.test.ts new file mode 100644 index 00000000000..70ce1eb4111 --- /dev/null +++ b/packages/coding-agent/test/agent-hub-dashboard.test.ts @@ -0,0 +1,254 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; +import { type Api, Effort, type Model } from "@oh-my-pi/pi-ai"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; +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 type { AgentProgress } from "@oh-my-pi/pi-coding-agent/task/types"; + +function rendered(hub: AgentHubOverlayComponent): string { + return Bun.stripANSI(hub.render(120).join("\n")); +} + +function register( + registry: AgentRegistry, + id: string, + status: "running" | "idle" | "parked" | "aborted", + kind: "sub" | "advisor" = "sub", + session?: AgentSession, +): void { + registry.register({ + id, + displayName: id, + kind, + status, + session: session ?? (status === "running" || status === "idle" ? ({} as AgentSession) : null), + }); +} + +function progress(overrides: Partial = {}): AgentProgress { + return { + index: 0, + id: "run-a", + agent: "task", + agentSource: "bundled", + status: "running", + task: "Inspect the runtime", + recentTools: [], + recentOutput: [], + toolCount: 0, + requests: 0, + tokens: 0, + cost: 0, + durationMs: 0, + ...overrides, + }; +} + +function makeHub( + registry: AgentRegistry, + observers = new SessionObserverRegistry(), + overrides: Partial[0]> = {}, +): AgentHubOverlayComponent { + return new AgentHubOverlayComponent({ + observers, + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry, + irc: new IrcBus(registry), + focusAgent: async () => {}, + ...overrides, + }); +} + +describe("Agent Hub dashboard", () => { + beforeAll(async () => { + await initTheme(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("separates running work, collapsed idle agents, and archived history", () => { + const registry = new AgentRegistry(); + register(registry, "run-a", "running"); + register(registry, "run-b", "running"); + register(registry, "idle-a", "idle"); + register(registry, "idle-b", "idle"); + register(registry, "park-a", "parked"); + register(registry, "park-b", "parked"); + register(registry, "park-c", "parked"); + register(registry, "abort-a", "aborted"); + register(registry, "advisor-a", "parked", "advisor"); + const hub = makeHub(registry); + + let output = rendered(hub); + expect(output).toContain("Agent Hub · 2 running · 2 idle · 5 archived"); + expect(output).toContain("▸ 2 idle agents"); + expect(output).toContain("run-a"); + expect(output).toContain("run-b"); + for (const hidden of ["idle-a", "idle-b", "park-a", "abort-a", "advisor-a"]) expect(output).not.toContain(hidden); + + hub.handleInput("j"); + hub.handleInput("i"); + output = rendered(hub); + expect(output).toContain("▾ 2 idle agents"); + expect(output).toContain("idle-a"); + expect(output.indexOf("run-a")).toBeLessThan(output.indexOf("idle-a")); + const selectedBeforeTab = output.match(/(run-[ab] · running · sub)/)?.[1]; + expect(selectedBeforeTab).toBeDefined(); + + hub.handleInput("\t"); + output = rendered(hub); + for (const archived of ["park-a", "park-b", "park-c", "abort-a", "advisor-a"]) expect(output).toContain(archived); + for (const active of ["run-a", "run-b", "idle-a", "idle-b"]) expect(output).not.toContain(active); + + hub.handleInput("\t"); + output = rendered(hub); + expect(output).toContain(selectedBeforeTab as string); + hub.dispose(); + }); + + it("moves status transitions between tabs and clamps selection", () => { + vi.useFakeTimers(); + const registry = new AgentRegistry(); + register(registry, "run-a", "running"); + register(registry, "run-b", "running"); + register(registry, "park-a", "parked"); + const hub = makeHub(registry); + hub.handleInput("j"); + registry.setStatus("run-b", "idle"); + vi.advanceTimersByTime(100); + let output = rendered(hub); + expect(output).not.toContain("run-b"); + expect(output).toContain("run-a"); + + registry.setStatus("park-a", "running"); + vi.advanceTimersByTime(100); + output = rendered(hub); + expect(output).toContain("park-a"); + hub.handleInput("\t"); + expect(rendered(hub)).not.toContain("park-a"); + hub.dispose(); + }); + + it("renders structured progress with retry precedence and caps recent tools", () => { + const registry = new AgentRegistry(); + register(registry, "run-a", "running"); + const observers = new SessionObserverRegistry(); + vi.spyOn(observers, "getSessions").mockReturnValue([ + { + id: "run-a", + kind: "subagent", + label: "Run A", + status: "active", + lastUpdate: Date.now(), + progress: progress({ + resolvedModel: "openai-codex/gpt-5.6-sol", + thinkingLevel: Effort.High, + lspEnabled: true, + advisorActive: true, + requests: 7, + tokens: 1234, + contextTokens: 32000, + contextWindow: 128000, + toolCount: 9, + cost: 1.25, + durationMs: 65000, + currentTool: "bash", + currentToolArgs: "bun test", + lastIntent: "old intent", + retryState: { + attempt: 2, + maxAttempts: 4, + delayMs: 5000, + errorMessage: "rate limited", + startedAtMs: Date.now(), + }, + recentTools: ["one", "two", "three", "four"].map((tool, index) => ({ + tool, + args: `arg-${index}`, + endMs: Date.now() - index * 1000, + })), + recentOutput: ["RAW SECRET OUTPUT"], + }), + }, + ]); + const hub = makeHub(registry, observers); + const output = rendered(hub); + expect(output).toContain("Model openai-codex/gpt-5.6-sol · Reasoning high"); + expect(output).toContain("Advisor on · LSP on"); + expect(output).toContain("Turns 7 · Tokens 1.2K · Context 25.0%/128K"); + expect(output).toContain("Tools 9 · Duration 1m5s · Cost $1.25"); + expect(output).toContain("retry 2/4 in 5.0s: rate limited"); + expect(output).not.toContain("bash bun test"); + expect(output).not.toContain("one arg-0"); + expect(output).toContain("four arg-3"); + expect(output).not.toContain("RAW SECRET OUTPUT"); + hub.dispose(); + }); + + it("prefers live session metadata and metrics over stale progress", () => { + const currentModel: Model = buildModel({ + provider: "live", + id: "current", + name: "current", + api: "openai-completions", + baseUrl: "https://live.example.test", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + }); + const session = { + model: currentModel, + thinkingLevel: "medium", + contextUsageRevision: 1, + getActiveToolNames: () => ["lsp", "yield"], + isAdvisorActive: () => false, + getSessionStats: () => ({ + assistantMessages: 4, + toolCalls: 6, + cost: 0.5, + tokens: { input: 100, output: 20, cacheWrite: 30 }, + }), + getContextUsage: () => ({ tokens: 50000, contextWindow: 200000, percent: 25 }), + } as unknown as AgentSession; + const registry = new AgentRegistry(); + register(registry, "run-a", "running", "sub", session); + const observers = new SessionObserverRegistry(); + vi.spyOn(observers, "getSessions").mockReturnValue([ + { + id: "run-a", + kind: "subagent", + label: "Run A", + status: "active", + lastUpdate: Date.now(), + progress: progress({ + task: "Keep this task", + resolvedModel: "stale/model", + thinkingLevel: Effort.Low, + lspEnabled: false, + advisorActive: true, + requests: 99, + tokens: 99, + }), + }, + ]); + const hub = makeHub(registry, observers); + const output = rendered(hub); + expect(output).toContain("Task: Keep this task"); + expect(output).toContain("Model live/current · Reasoning medium"); + expect(output).toContain("Advisor off · LSP on"); + expect(output).toContain("Turns 4 · Tokens 150 · Context 25.0%/200K"); + expect(output).toContain("Tools 6"); + hub.dispose(); + }); +}); diff --git a/packages/coding-agent/test/agent-hub-ordering.test.ts b/packages/coding-agent/test/agent-hub-ordering.test.ts index c246af4c085..9a545361d0d 100644 --- a/packages/coding-agent/test/agent-hub-ordering.test.ts +++ b/packages/coding-agent/test/agent-hub-ordering.test.ts @@ -121,7 +121,7 @@ describe("Agent hub row ordering", () => { } }); - it("truncates lines and sanitizes newlines to prevent terminal wrapping", () => { + it("truncates lines and sanitizes controls to prevent terminal corruption", () => { geometry = stubStdoutGeometry(80); const agents = new AgentRegistry(); const sessionA = {} as AgentSession; @@ -139,7 +139,7 @@ describe("Agent hub row ordering", () => { kind: "subagent", label: "Subagent", status: "active", - description: "Complete the assignment below, thoroughly:\n- check performance\n- check leaks", + description: "Complete the assignment\tbelow, thoroughly:\n- check performance\u0007\n- check leaks", lastUpdate: Date.now(), }, ]); @@ -159,6 +159,8 @@ describe("Agent hub row ordering", () => { const cleanLine = Bun.stripANSI(line); expect(cleanLine.includes("\n")).toBe(false); expect(cleanLine.includes("\r")).toBe(false); + expect(cleanLine.includes("\t")).toBe(false); + expect(cleanLine.includes("\u0007")).toBe(false); const width = visibleWidth(line); expect(width).toBeLessThanOrEqual(78); } diff --git a/packages/coding-agent/test/issue-2750-subagent-runtime-fallback.test.ts b/packages/coding-agent/test/issue-2750-subagent-runtime-fallback.test.ts index 7ee964399d0..4ceb1b075de 100644 --- a/packages/coding-agent/test/issue-2750-subagent-runtime-fallback.test.ts +++ b/packages/coding-agent/test/issue-2750-subagent-runtime-fallback.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import type { Api, Model } from "@oh-my-pi/pi-ai"; +import { type Api, Effort, type Model } from "@oh-my-pi/pi-ai"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import * as sdkModule from "@oh-my-pi/pi-coding-agent/sdk"; @@ -22,15 +22,19 @@ function model(provider: string, id: string): Model { }); } -function createYieldingSession(): AgentSession { +function createYieldingSession( + options: { advisorActive?: boolean; initialThinkingLevel?: Effort; thinkingLevel?: Effort } = {}, +): AgentSession { const listeners: Array<(event: { type: string; [key: string]: unknown }) => void> = []; const session = { agent: { state: { systemPrompt: ["test"] } }, state: { messages: [] }, + thinkingLevel: options.initialThinkingLevel, extensionRunner: undefined, sessionManager: { appendSessionInit: () => {} }, getActiveToolNames: () => ["yield"], getEnabledToolNames: () => ["yield"], + isAdvisorActive: () => options.advisorActive ?? false, setActiveToolsByName: async () => {}, subscribe: (listener: (event: { type: string; [key: string]: unknown }) => void) => { listeners.push(listener); @@ -38,6 +42,12 @@ function createYieldingSession(): AgentSession { }, prompt: async () => { for (const listener of listeners) { + if (options.thinkingLevel) { + listener({ + type: "thinking_level_changed", + thinkingLevel: options.thinkingLevel, + }); + } listener({ type: "retry_fallback_applied", from: "primary/bad-runtime-model", @@ -188,4 +198,60 @@ describe("subagent runtime model resolution", () => { expect(childModelPatternAuthFallback).toBe("openai-codex/gpt-5.5"); expect(childModelPatternFallbackRole).toBe("subagent:issue-4421"); }); + + it("publishes runtime capabilities and reasoning changes in progress snapshots", async () => { + const primary = model("primary", "runtime-model"); + const snapshots: Array<{ + thinkingLevel?: string; + lspEnabled?: boolean; + advisorActive?: boolean; + }> = []; + vi.spyOn(sdkModule, "createAgentSession").mockImplementation( + async () => + ({ + session: createYieldingSession({ + advisorActive: true, + initialThinkingLevel: Effort.High, + thinkingLevel: Effort.Medium, + }), + extensionsResult: {}, + setToolUIContext: () => {}, + }) as never, + ); + + await runSubprocess({ + cwd: "/tmp", + agent: { name: "task", description: "test", systemPrompt: "test", source: "bundled" }, + task: "work", + index: 0, + id: "runtime-capabilities", + modelOverride: "primary/runtime-model", + thinkingLevel: Effort.High, + settings: Settings.isolated(), + modelRegistry: { + refresh: async () => {}, + getAvailable: () => [primary], + getApiKey: async () => "test-key", + } as never, + enableLsp: false, + onProgress: progress => { + snapshots.push({ + thinkingLevel: progress.thinkingLevel, + lspEnabled: progress.lspEnabled, + advisorActive: progress.advisorActive, + }); + }, + }); + + expect(snapshots).toContainEqual({ + thinkingLevel: "high", + lspEnabled: false, + advisorActive: true, + }); + expect(snapshots).toContainEqual({ + thinkingLevel: "medium", + lspEnabled: false, + advisorActive: true, + }); + }); });