feat(coding-agent): added anchored HUD for running subagents
- Added an anchored Subagents HUD renderer that formats active subagent sessions as `Id: description` rows. - Integrated a new interactive-mode container and observer-driven render path so the HUD updates with session events and clears when no subagents are active. - Exported `formatTaskId` for shared HUD formatting and added tests covering active-only filtering, fallback task/progress text, and line truncation.
This commit is contained in:
@@ -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 <active|account id|email>`, 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
|
||||
|
||||
@@ -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<void> {
|
||||
this.todoPhases = this.session.getTodoPhases();
|
||||
this.#syncTodoAutoClearTimer();
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface InteractiveModeContext {
|
||||
pendingMessagesContainer: Container;
|
||||
statusContainer: Container;
|
||||
todoContainer: Container;
|
||||
subagentContainer: Container;
|
||||
btwContainer: Container;
|
||||
omfgContainer: Container;
|
||||
errorBannerContainer: Container;
|
||||
|
||||
@@ -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(".");
|
||||
|
||||
@@ -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<ObservableSession> & { id: string }): ObservableSession {
|
||||
return {
|
||||
kind: "subagent",
|
||||
label: overrides.id,
|
||||
status: "active",
|
||||
lastUpdate: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeProgress(overrides: Partial<AgentProgress> & { 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user