diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1750a2b55..9f5a2671e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added an anchored Subagents HUD above the editor (next to the Todos block) listing every running subagent as `Id: description`; rows appear on spawn and the block clears itself when the last subagent finishes - Added `/reset-usage` command to spend a saved Codex rate-limit reset by running `/reset-usage `, with a TUI selector flow for interactive reset redemption - Added `snapcompact.systemPrompt` enum modes `none`, `agents-md`, and `all`, so users can disable system-prompt imaging, image only loaded AGENTS.md/context-file instruction sections, or image the full system prompt - Added `agents-md` snapcompact mode that rasterizes AGENTS.md-style context sections from the system prompt into frames and appends them to the first user message diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 29734cad4..c61a3a2a7 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -86,8 +86,10 @@ import { BUILTIN_SLASH_COMMAND_RESERVED_NAMES } from "../slash-commands/builtin- import { formatDuration } from "../slash-commands/helpers/format"; import { STTController, type SttState } from "../stt"; import { discoverTitleSystemPromptFile, resolvePromptInput } from "../system-prompt"; +import { formatTaskId } from "../task/render"; import type { LspStartupServerInfo } from "../tools"; import { normalizeLocalScheme } from "../tools/path-utils"; +import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../tools/render-utils"; import { setAutoQaConsentHandler } from "../tools/report-tool-issue"; import { type ResolveToolDetails, runResolveInvocation } from "../tools/resolve"; import { formatPhaseDisplayName, selectStickyTodoWindow, todoMatchesAnyDescription } from "../tools/todo"; @@ -132,6 +134,7 @@ import { parseLoopLimitArgs, } from "./loop-limit"; import { OAuthManualInputManager } from "./oauth-manual-input"; +import type { ObservableSession } from "./session-observer-registry"; import { SessionObserverRegistry } from "./session-observer-registry"; import { runProviderSetupWizard } from "./setup-wizard/lazy"; import { interruptHint } from "./shared"; @@ -277,6 +280,41 @@ class StatusContainer extends Container implements NativeScrollbackLiveRegion { } } +/** + * Build the anchored subagent HUD block: a bold accent "Subagents" header plus + * one hooked row per running agent in the same `Id: description` shape the + * inline task rows use (muted task preview when no description was given). + * Returns an empty array when nothing is running so the container can clear. + */ +export function renderSubagentHudLines(sessions: ObservableSession[], columns: number): string[] { + const running = sessions.filter(session => session.kind === "subagent" && session.status === "active"); + if (running.length === 0) return []; + + const indent = " "; + const hook = theme.tree.hook; + const dot = theme.styledSymbol("status.done", "accent"); + const lines = ["", indent + theme.bold(theme.fg("accent", "Subagents"))]; + running.forEach((session, index) => { + const prefix = `${indent}${index === 0 ? hook : " "} `; + const displayId = formatTaskId(session.id); + let line = `${prefix}${dot} ${theme.fg("accent", theme.bold(displayId))}`; + const description = session.description?.trim() || session.progress?.description?.trim(); + if (description) { + const budget = Math.max(TRUNCATE_LENGTHS.SHORT, columns - visibleWidth(prefix) - visibleWidth(displayId) - 6); + line += `${theme.fg("accent", ":")} ${theme.fg("accent", truncateToWidth(replaceTabs(description), budget))}`; + } else { + // No spawn description: fall back to a muted task preview, same as + // the inline task rows when a row has no label. + const taskPreview = session.progress?.task?.trim(); + if (taskPreview) { + line += ` ${theme.fg("muted", truncateToWidth(replaceTabs(taskPreview), TRUNCATE_LENGTHS.SHORT))}`; + } + } + lines.push(line); + }); + return lines; +} + export class InteractiveMode implements InteractiveModeContext { session: AgentSession; sessionManager: SessionManager; @@ -291,6 +329,7 @@ export class InteractiveMode implements InteractiveModeContext { pendingMessagesContainer: Container; statusContainer: Container; todoContainer: Container; + subagentContainer: Container; btwContainer: Container; omfgContainer: Container; errorBannerContainer: Container; @@ -440,6 +479,7 @@ export class InteractiveMode implements InteractiveModeContext { this.pendingMessagesContainer = new Container(); this.statusContainer = new StatusContainer(); this.todoContainer = new Container(); + this.subagentContainer = new Container(); this.btwContainer = new Container(); this.omfgContainer = new Container(); this.errorBannerContainer = new Container(); @@ -606,6 +646,7 @@ export class InteractiveMode implements InteractiveModeContext { this.ui.addChild(this.pendingMessagesContainer); this.ui.addChild(this.statusContainer); this.ui.addChild(this.todoContainer); + this.ui.addChild(this.subagentContainer); this.ui.addChild(this.btwContainer); this.ui.addChild(this.omfgContainer); this.ui.addChild(this.errorBannerContainer); @@ -632,6 +673,7 @@ export class InteractiveMode implements InteractiveModeContext { this.#reconcileTodosWithSubagents(); this.#syncTodoAutoClearTimer(); this.#renderTodoList(); + this.#renderSubagentList(); this.ui.requestRender(); }); @@ -1282,6 +1324,19 @@ export class InteractiveMode implements InteractiveModeContext { this.todoContainer.addChild(new Text(lines.join("\n"), 1, 0)); } + /** + * Anchored HUD of in-flight subagents, mirroring the Todos block above the + * editor. Driven entirely by observer-registry change events, so rows appear + * on spawn and the whole block clears itself once the last subagent leaves + * the "active" state. + */ + #renderSubagentList(): void { + this.subagentContainer.clear(); + const lines = renderSubagentHudLines(this.#observerRegistry.getSessions(), this.ui.terminal.columns); + if (lines.length === 0) return; + this.subagentContainer.addChild(new Text(lines.join("\n"), 1, 0)); + } + async #loadTodoList(): Promise { this.todoPhases = this.session.getTodoPhases(); this.#syncTodoAutoClearTimer(); diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index be059f851..ac580702c 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -81,6 +81,7 @@ export interface InteractiveModeContext { pendingMessagesContainer: Container; statusContainer: Container; todoContainer: Container; + subagentContainer: Container; btwContainer: Container; omfgContainer: Container; errorBannerContainer: Container; diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index ce51d83cd..57b08b789 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -165,7 +165,7 @@ function formatJsonScalar(value: unknown, _theme: Theme): string { return ""; } -function formatTaskId(id: string): string { +export function formatTaskId(id: string): string { // Ids are name-based (e.g. "Anna", "Anna-2"); a "." separates nesting levels // (e.g. "Anna.Bob"). Render the hierarchy with a ">" breadcrumb. const segments = id.split("."); diff --git a/packages/coding-agent/test/subagent-hud-render.test.ts b/packages/coding-agent/test/subagent-hud-render.test.ts new file mode 100644 index 000000000..9ccebb1be --- /dev/null +++ b/packages/coding-agent/test/subagent-hud-render.test.ts @@ -0,0 +1,93 @@ +/** + * Contract: the anchored subagent HUD (rendered above the editor, next to the + * Todos block) lists exactly the running subagents as `Id: description` rows + * and yields no output once nothing is running, so the block self-clears. + */ +import { beforeAll, describe, expect, it } from "bun:test"; +import { renderSubagentHudLines } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode"; +import type { ObservableSession } 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 { AgentProgress } from "@oh-my-pi/pi-coding-agent/task"; + +function makeSession(overrides: Partial & { id: string }): ObservableSession { + return { + kind: "subagent", + label: overrides.id, + status: "active", + lastUpdate: Date.now(), + ...overrides, + }; +} + +function makeProgress(overrides: Partial & { id: string }): AgentProgress { + return { + index: 0, + agent: "task", + agentSource: "bundled", + status: "running", + task: "", + recentTools: [], + recentOutput: [], + toolCount: 0, + requests: 0, + tokens: 0, + cost: 0, + durationMs: 0, + ...overrides, + }; +} + +function render(sessions: ObservableSession[], columns = 120): string { + return Bun.stripANSI(renderSubagentHudLines(sessions, columns).join("\n")); +} + +describe("subagent HUD lines", () => { + beforeAll(async () => { + await initTheme(); + }); + + it("renders running subagents as Id: description under a Subagents header", () => { + const out = render([ + makeSession({ id: "AuthLoader", description: "Refactoring the auth flow" }), + makeSession({ id: "SchemaMigrator", description: "Migrating the users table" }), + ]); + expect(out).toContain("Subagents"); + expect(out).toContain("AuthLoader: Refactoring the auth flow"); + expect(out).toContain("SchemaMigrator: Migrating the users table"); + }); + + it("only shows active subagents and clears once everything finished", () => { + const finishedStates = ["completed", "failed", "aborted"] as const; + const sessions: ObservableSession[] = [ + { id: "main", kind: "main", label: "Main Session", status: "active", lastUpdate: Date.now() }, + ...finishedStates.map(status => makeSession({ id: `Done-${status}`, status, description: "old work" })), + ]; + expect(renderSubagentHudLines(sessions, 120)).toEqual([]); + + const out = render([...sessions, makeSession({ id: "StillRunning", description: "live work" })]); + expect(out).toContain("StillRunning: live work"); + expect(out).not.toContain("Done-"); + expect(out).not.toContain("Main Session"); + }); + + it("falls back to the description and task carried by progress snapshots", () => { + const fromProgressDesc = render([ + makeSession({ id: "Worker", progress: makeProgress({ id: "Worker", description: "From progress" }) }), + ]); + expect(fromProgressDesc).toContain("Worker: From progress"); + + const fromTask = render([ + makeSession({ id: "Worker", progress: makeProgress({ id: "Worker", task: "Investigate flaky CI on macOS" }) }), + ]); + expect(fromTask).toContain("Worker Investigate flaky CI on macOS"); + }); + + it("renders nested ids as a breadcrumb and truncates long descriptions to the viewport", () => { + const out = render([makeSession({ id: "Anna.Bob", description: `start ${"x".repeat(300)} end` })], 60); + expect(out).toContain("Anna>Bob:"); + expect(out).not.toContain("end"); + for (const line of out.split("\n")) { + expect(Bun.stringWidth(line)).toBeLessThanOrEqual(60); + } + }); +});