Expose theme-aware status widget integration
This commit is contained in:
@@ -11,10 +11,9 @@ The backend is selected automatically: Herdr is used when Pi runs in a Herdr-man
|
||||
`subagent()` returns immediately. In Herdr, the sub-agent runs in a new unfocused tab in the current workspace. In tmux, it runs in a right split off the parent Pi pane. A live widget above the input tracks every running sub-agent, and when one finishes, its result is steered into the main session as a notification that triggers a new turn.
|
||||
|
||||
```
|
||||
╭─ Subagents ──────────────────────────── 2 running ─╮
|
||||
│ 00:23 scout active · bash 7m │
|
||||
│ 00:45 scout-2 waiting 2m │
|
||||
╰────────────────────────────────────────────────────╯
|
||||
Subagents 2 running
|
||||
⟳ 00:23 scout active · bash 7m
|
||||
○ 00:45 scout-2 waiting 2m
|
||||
```
|
||||
|
||||
Spawn several in parallel — they run concurrently and steer results back independently as each finishes.
|
||||
@@ -148,7 +147,23 @@ Access is **whitelist-only**. Every sub-agent process is launched with `--no-ext
|
||||
|
||||
Spawns must name a known agent at **every** depth. A top-level session may spawn anything discoverable; a sub-agent may only spawn the agents in its `subagent_agents` list (enforced via `PI_SUBAGENT_ALLOWED`). There is no agentless spawn route, so a child can never escalate to a full-toolset profile by omitting its agent.
|
||||
|
||||
Extensions can register additional tools for sub-agents at runtime via `registerToolExtension(name, path)` on the `__pi_interactive_subagents` process global.
|
||||
Extensions can register additional tools for sub-agents at runtime via `registerToolExtension(name, path)` on the `__pi_interactive_subagents` process global. Hosts that compose widgets can optionally use its reload-safe `statusWidget` surface:
|
||||
|
||||
```typescript
|
||||
const { statusWidget } = globalThis.__pi_interactive_subagents;
|
||||
const onChange = () => rerender();
|
||||
const release = statusWidget.claim(onChange);
|
||||
|
||||
statusWidget.hasContent(); // whether any subagent rows exist
|
||||
statusWidget.getBodyRowCount(); // uncapped number of running agents
|
||||
statusWidget.render(width, maxBodyRows, ctx.ui.theme); // compact unboxed lines; cap includes an overflow row
|
||||
statusWidget.isClaimed(onChange); // true only for the current provider claim
|
||||
release(); // restore the standalone subagent-status widget
|
||||
```
|
||||
|
||||
`render(width, maxBodyRows?, theme?)` accepts Pi's current theme as an optional structural value. The standalone widget receives and uses its own Pi theme automatically. When no theme is passed, a legacy ANSI fallback keeps status colors usable. The header is a compact unboxed `Subagents <count> running` row; body rows remain width-safe, and `maxBodyRows` counts body rows including the overflow summary, not the header. Semantic row colors match Pi's Todo conventions: accent header, muted count, warning active/running, error stalled, dim starting/provider tags/elapsed time, and accent waiting.
|
||||
|
||||
`claim()` replaces any prior claim, and an old release function cannot release a newer claim. Standalone-widget clearing/restoration is deferred to a microtask, so hosts may release and immediately re-claim without a transient fallback widget. `isClaimed(onChange)` checks both callback identity and the current runtime ownership, so hosts can detect invalidation after reload/session rebind. Claims are cleared during session shutdown, including `/reload`, so a fresh host runtime must claim again rather than retaining a stale callback. Calling `render()` directly does not claim or change the standalone widget.
|
||||
|
||||
## Role folders
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Lease the status widget to optional compositors
|
||||
|
||||
Subagents exposes its running-status panel through a structural, process-local interface while retaining ownership of the standalone widget. A host claims a callback-identified lease and must verify it before rendering; runtime replacement invalidates the lease and stale releases cannot affect a newer owner. This avoids a package dependency or shared TUI class identity while preserving running subagents across reload and restoring the standalone panel whenever no valid host owns it.
|
||||
|
||||
The composable surface is `statusWidget.render(width, maxBodyRows?, theme?)`. The optional third argument accepts the small structural `{ fg(color, text) }` theme contract, allowing hosted output to use the current Pi theme without importing a shared Theme class. Standalone rendering passes its factory-provided Pi theme. Calls without a theme use a legacy ANSI fallback. Both hosted and standalone output use a compact unboxed `Subagents <count> running` header followed by capped, width-safe body rows; the cap includes an overflow row but not the header. Status colors remain semantic: active/running warning, stalled error, starting dim, waiting accent, with provider tags and elapsed times dimmed.
|
||||
+451
-110
@@ -55,6 +55,7 @@ import {
|
||||
formatTransitionLine,
|
||||
observeStatus,
|
||||
loadStatusConfig,
|
||||
normalizeStatusName,
|
||||
} from "./status.ts";
|
||||
import {
|
||||
getSubagentActivityFile,
|
||||
@@ -97,6 +98,30 @@ const SubagentParams = Type.Object({
|
||||
|
||||
type SubagentSessionMode = "standalone" | "lineage-only" | "fork";
|
||||
|
||||
/**
|
||||
* Optional host integration for composing the running-subagents widget into a
|
||||
* larger widget. The rendered body is capped by `maxBodyRows`, including the
|
||||
* overflow summary row when one is needed. The unboxed header is not included
|
||||
* in that cap.
|
||||
*/
|
||||
export type StatusWidgetColor = "accent" | "muted" | "warning" | "error" | "dim";
|
||||
|
||||
/**
|
||||
* The small structural theme surface used by the status widget. Keeping this
|
||||
* local avoids coupling hosts to a particular Theme class/module instance.
|
||||
*/
|
||||
export interface StatusWidgetTheme {
|
||||
fg(color: StatusWidgetColor, text: string): string;
|
||||
}
|
||||
|
||||
export interface StatusWidget {
|
||||
hasContent(): boolean;
|
||||
getBodyRowCount(): number;
|
||||
render(width: number, maxBodyRows?: number, theme?: StatusWidgetTheme): string[];
|
||||
claim(onChange: () => void): () => void;
|
||||
isClaimed(onChange: () => void): boolean;
|
||||
}
|
||||
|
||||
interface AgentDefaults {
|
||||
model?: string;
|
||||
tools?: string;
|
||||
@@ -178,11 +203,22 @@ export function registerToolExtension(name: string, extensionPath: string): void
|
||||
}
|
||||
|
||||
// Expose registration on a process-global so project-local extensions loaded
|
||||
// via jiti (separate module instances) can reach this shared map. Set at module
|
||||
// load so it's available before any `session_start` listener runs.
|
||||
(globalThis as any).__pi_interactive_subagents = {
|
||||
registerToolExtension,
|
||||
// via jiti (separate module instances) can reach this shared map. Keep the
|
||||
// object itself stable across /reload so an optional host integration can keep
|
||||
// its reference while this extension is re-imported.
|
||||
const existingInteractiveSubagentsApi = (globalThis as any).__pi_interactive_subagents;
|
||||
const interactiveSubagentsApi = (
|
||||
existingInteractiveSubagentsApi != null
|
||||
&& (typeof existingInteractiveSubagentsApi === "object" || typeof existingInteractiveSubagentsApi === "function")
|
||||
&& !Array.isArray(existingInteractiveSubagentsApi)
|
||||
? existingInteractiveSubagentsApi
|
||||
: {}
|
||||
) as {
|
||||
registerToolExtension: typeof registerToolExtension;
|
||||
statusWidget?: StatusWidget;
|
||||
};
|
||||
(globalThis as any).__pi_interactive_subagents = interactiveSubagentsApi;
|
||||
interactiveSubagentsApi.registerToolExtension = registerToolExtension;
|
||||
|
||||
/**
|
||||
* Map a custom (non-built-in) tool name to the pi-extension file that
|
||||
@@ -450,27 +486,54 @@ function formatUsageSegments(stats: SessionStats): string[] {
|
||||
return segs;
|
||||
}
|
||||
|
||||
/** ANSI colors for widget status icons (raw, since the widget bypasses theme). */
|
||||
const ICON_GREEN = "\x1b[38;2;126;186;103m";
|
||||
const ICON_YELLOW = "\x1b[38;2;214;181;94m";
|
||||
const ICON_RED = "\x1b[38;2;224;108;117m";
|
||||
const ICON_DIM = "\x1b[38;2;128;128;128m";
|
||||
/**
|
||||
* ANSI fallback for hosts that still call render() without passing Pi's theme.
|
||||
* The widget remains useful in older hosts while themed hosts get the same
|
||||
* semantic colors as the rest of Pi's UI.
|
||||
*/
|
||||
const FALLBACK_STATUS_ANSI: Record<StatusWidgetColor, string> = {
|
||||
accent: "\x1b[38;2;77;163;255m",
|
||||
muted: "\x1b[38;2;128;128;128m",
|
||||
warning: "\x1b[38;2;214;181;94m",
|
||||
error: "\x1b[38;2;224;108;117m",
|
||||
dim: "\x1b[38;2;128;128;128m",
|
||||
};
|
||||
const ANSI_RESET = "\x1b[0m";
|
||||
|
||||
/** Map a live status kind to a colored single-char icon for the widget. */
|
||||
function widgetIcon(kind: StatusSnapshot["kind"]): string {
|
||||
const FALLBACK_STATUS_THEME: StatusWidgetTheme = {
|
||||
fg(color, text) {
|
||||
return `${FALLBACK_STATUS_ANSI[color]}${text}${ANSI_RESET}`;
|
||||
},
|
||||
};
|
||||
|
||||
function resolveStatusWidgetTheme(theme?: StatusWidgetTheme): StatusWidgetTheme {
|
||||
return theme && typeof theme.fg === "function" ? theme : FALLBACK_STATUS_THEME;
|
||||
}
|
||||
|
||||
function statusWidgetColor(kind: StatusSnapshot["kind"]): StatusWidgetColor {
|
||||
switch (kind) {
|
||||
case "active":
|
||||
case "running":
|
||||
return `${ICON_YELLOW}⟳${RST}`;
|
||||
return "warning";
|
||||
case "stalled":
|
||||
return `${ICON_RED}⟳${RST}`;
|
||||
return "error";
|
||||
case "waiting":
|
||||
return "accent";
|
||||
case "starting":
|
||||
default:
|
||||
return `${ICON_DIM}○${RST}`;
|
||||
return "dim";
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a live status kind to a themed single-char icon for the widget. */
|
||||
function widgetIcon(
|
||||
kind: StatusSnapshot["kind"],
|
||||
theme: StatusWidgetTheme = FALLBACK_STATUS_THEME,
|
||||
): string {
|
||||
const icon = kind === "active" || kind === "running" || kind === "stalled" ? "⟳" : "○";
|
||||
return theme.fg(statusWidgetColor(kind), icon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait long enough for a freshly created pane to finish shell startup.
|
||||
*
|
||||
@@ -513,23 +576,40 @@ function getArtifactDir(sessionDir: string, sessionId: string): string {
|
||||
|
||||
const statusConfig = loadStatusConfig();
|
||||
|
||||
function formatWidgetRightLabel(snapshot: StatusSnapshot): string {
|
||||
if (snapshot.kind === "starting") return " starting… ";
|
||||
if (snapshot.kind === "running") return ` running ${snapshot.elapsedText} `;
|
||||
function formatWidgetRightLabel(
|
||||
snapshot: StatusSnapshot,
|
||||
theme: StatusWidgetTheme = FALLBACK_STATUS_THEME,
|
||||
): string {
|
||||
const color = statusWidgetColor(snapshot.kind);
|
||||
const status = (text: string) => theme.fg(color, text);
|
||||
const elapsed = (text: string) => theme.fg("dim", text);
|
||||
|
||||
if (snapshot.kind === "starting") return status(" starting… ");
|
||||
if (snapshot.kind === "running") {
|
||||
return status(" running ") + elapsed(snapshot.elapsedText) + status(" ");
|
||||
}
|
||||
if (snapshot.kind === "active") {
|
||||
const label = snapshot.activityLabel ?? snapshot.activeScope;
|
||||
const duration = snapshot.activeDurationText ? ` ${snapshot.activeDurationText}` : "";
|
||||
return label ? ` active · ${label}${duration} ` : " active ";
|
||||
const duration = snapshot.activeDurationText
|
||||
? ` ${elapsed(snapshot.activeDurationText)}`
|
||||
: "";
|
||||
return label
|
||||
? status(" active · ") + status(label) + duration + status(" ")
|
||||
: status(" active ") + duration;
|
||||
}
|
||||
if (snapshot.kind === "waiting") {
|
||||
const duration = snapshot.waitingDurationText ? ` ${snapshot.waitingDurationText}` : "";
|
||||
const detail = snapshot.statusLabel ? ` · ${snapshot.statusLabel}` : "";
|
||||
return ` waiting${duration}${detail} `;
|
||||
const duration = snapshot.waitingDurationText
|
||||
? ` ${elapsed(snapshot.waitingDurationText)}`
|
||||
: "";
|
||||
const detail = snapshot.statusLabel ? status(` · ${snapshot.statusLabel}`) : "";
|
||||
return status(" waiting") + duration + detail + status(" ");
|
||||
}
|
||||
|
||||
const detail = snapshot.statusLabel ? ` · ${snapshot.statusLabel}` : "";
|
||||
const duration = snapshot.snapshotProblemText ? ` ${snapshot.snapshotProblemText}` : "";
|
||||
return ` stalled${detail}${duration} `;
|
||||
const detail = snapshot.statusLabel ? status(` · ${snapshot.statusLabel}`) : "";
|
||||
const duration = snapshot.snapshotProblemText
|
||||
? status(` ${snapshot.snapshotProblemText}`)
|
||||
: "";
|
||||
return status(" stalled") + detail + duration + status(" ");
|
||||
}
|
||||
|
||||
function resolveResultPresentation(
|
||||
@@ -619,15 +699,48 @@ interface RunningSubagent {
|
||||
|
||||
type PendingDelivery = (pi: ExtensionAPI) => void;
|
||||
|
||||
interface StatusWidgetClaim {
|
||||
ownerToken: symbol;
|
||||
runtimeToken: symbol | null;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
type StatusWidgetRenderer = (
|
||||
width: number,
|
||||
maxBodyRows?: number,
|
||||
theme?: StatusWidgetTheme,
|
||||
) => string[];
|
||||
|
||||
const STATUS_WIDGET_MARKER = Symbol.for("pi-subagents/status-widget");
|
||||
const STATUS_WIDGET_CLAIM_MARKER = Symbol.for("pi-subagents/status-widget-claim");
|
||||
|
||||
function isStatusWidgetClaim(value: unknown): value is StatusWidgetClaim {
|
||||
try {
|
||||
if (value == null || typeof value !== "object") return false;
|
||||
const claim = value as Record<PropertyKey, unknown>;
|
||||
return claim[STATUS_WIDGET_CLAIM_MARKER] === true
|
||||
&& typeof claim.ownerToken === "symbol"
|
||||
&& (claim.runtimeToken === null || typeof claim.runtimeToken === "symbol")
|
||||
&& typeof claim.onChange === "function";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface SharedSubagentRuntime {
|
||||
runningSubagents: Map<string, RunningSubagent>;
|
||||
latestCtx: ExtensionContext | null;
|
||||
latestPi: ExtensionAPI | null;
|
||||
widgetInterval: ReturnType<typeof setInterval> | null;
|
||||
statusInterval: ReturnType<typeof setInterval> | null;
|
||||
standaloneWidgetSyncScheduled: boolean;
|
||||
pendingDeliveries: PendingDelivery[];
|
||||
deliveryFlushScheduled: boolean;
|
||||
activeRuntimeToken: symbol | null;
|
||||
registeredRuntimeToken: symbol | null;
|
||||
statusWidgetClaim: StatusWidgetClaim | null;
|
||||
statusWidgetRenderer: StatusWidgetRenderer;
|
||||
statusWidget?: StatusWidget;
|
||||
}
|
||||
|
||||
function createSharedRuntime(): SharedSubagentRuntime {
|
||||
@@ -637,15 +750,38 @@ function createSharedRuntime(): SharedSubagentRuntime {
|
||||
latestPi: null,
|
||||
widgetInterval: null,
|
||||
statusInterval: null,
|
||||
standaloneWidgetSyncScheduled: false,
|
||||
pendingDeliveries: [],
|
||||
deliveryFlushScheduled: false,
|
||||
activeRuntimeToken: null,
|
||||
registeredRuntimeToken: null,
|
||||
statusWidgetClaim: null,
|
||||
statusWidgetRenderer: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
const sharedRuntime = ((globalThis as any)[SHARED_RUNTIME_KEY] ??=
|
||||
createSharedRuntime()) as SharedSubagentRuntime;
|
||||
|
||||
// Runtimes created by older extension versions do not have the optional widget
|
||||
// fields yet. Initialize them in place so a parent /reload can upgrade without
|
||||
// replacing the process-global runtime or its running children.
|
||||
if (!isStatusWidgetClaim(sharedRuntime.statusWidgetClaim)) {
|
||||
sharedRuntime.statusWidgetClaim = null;
|
||||
}
|
||||
if (typeof sharedRuntime.statusWidgetRenderer !== "function") {
|
||||
sharedRuntime.statusWidgetRenderer = () => [];
|
||||
}
|
||||
if (sharedRuntime.registeredRuntimeToken !== null && typeof sharedRuntime.registeredRuntimeToken !== "symbol") {
|
||||
sharedRuntime.registeredRuntimeToken = null;
|
||||
}
|
||||
if (sharedRuntime.activeRuntimeToken !== null && typeof sharedRuntime.activeRuntimeToken !== "symbol") {
|
||||
sharedRuntime.activeRuntimeToken = null;
|
||||
}
|
||||
if (typeof sharedRuntime.standaloneWidgetSyncScheduled !== "boolean") {
|
||||
sharedRuntime.standaloneWidgetSyncScheduled = false;
|
||||
}
|
||||
|
||||
/** All currently running subagents, preserved across /reload. */
|
||||
const runningSubagents = sharedRuntime.runningSubagents;
|
||||
|
||||
@@ -684,15 +820,42 @@ function schedulePendingDeliveryFlush(): void {
|
||||
setImmediate(flushPendingDeliveries);
|
||||
}
|
||||
|
||||
function clearRefreshIntervals(): void {
|
||||
if (sharedRuntime.widgetInterval) {
|
||||
clearInterval(sharedRuntime.widgetInterval);
|
||||
type RefreshIntervalKind = "widget" | "status";
|
||||
|
||||
function getRefreshInterval(kind: RefreshIntervalKind): ReturnType<typeof setInterval> | null {
|
||||
return kind === "widget" ? sharedRuntime.widgetInterval : sharedRuntime.statusInterval;
|
||||
}
|
||||
|
||||
function clearRefreshInterval(kind: RefreshIntervalKind): void {
|
||||
if (kind === "widget") {
|
||||
if (sharedRuntime.widgetInterval) clearInterval(sharedRuntime.widgetInterval);
|
||||
sharedRuntime.widgetInterval = null;
|
||||
return;
|
||||
}
|
||||
if (sharedRuntime.statusInterval) {
|
||||
clearInterval(sharedRuntime.statusInterval);
|
||||
|
||||
if (sharedRuntime.statusInterval) clearInterval(sharedRuntime.statusInterval);
|
||||
sharedRuntime.statusInterval = null;
|
||||
}
|
||||
|
||||
function clearRefreshIntervals(): void {
|
||||
clearRefreshInterval("widget");
|
||||
clearRefreshInterval("status");
|
||||
}
|
||||
|
||||
function ownsRefreshInterval(
|
||||
kind: RefreshIntervalKind,
|
||||
interval: ReturnType<typeof setInterval>,
|
||||
runtimeToken: symbol,
|
||||
): boolean {
|
||||
if (getRefreshInterval(kind) !== interval) {
|
||||
clearInterval(interval);
|
||||
return false;
|
||||
}
|
||||
if (sharedRuntime.activeRuntimeToken !== runtimeToken) {
|
||||
clearRefreshInterval(kind);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatElapsedMMSS(startTime: number): string {
|
||||
@@ -702,117 +865,262 @@ function formatElapsedMMSS(startTime: number): string {
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const ACCENT = "\x1b[38;2;77;163;255m";
|
||||
const RST = "\x1b[0m";
|
||||
|
||||
/**
|
||||
* Build a bordered content line: │left right│
|
||||
* Left content is truncated if needed, right is preserved, padded to fill width.
|
||||
* Build an unboxed content line with a right-aligned status. The helper keeps
|
||||
* its historical name for structural compatibility with existing consumers.
|
||||
* Left and right values may contain ANSI styling, so all fitting decisions use
|
||||
* visible cell width rather than string length.
|
||||
*/
|
||||
function borderLine(left: string, right: string, width: number): string {
|
||||
if (width <= 0) return "";
|
||||
if (width === 1) return `${ACCENT}│${RST}`;
|
||||
|
||||
// width = total visible chars for the whole line including │ and │
|
||||
const contentWidth = Math.max(0, width - 2); // space inside the two │ chars
|
||||
const rightVis = visibleWidth(right);
|
||||
|
||||
// If the status chunk alone is too wide, prefer preserving it in compact form
|
||||
// rather than overflowing the terminal.
|
||||
if (rightVis >= contentWidth) {
|
||||
const truncRight = truncateToWidth(right, contentWidth);
|
||||
const rightPad = Math.max(0, contentWidth - visibleWidth(truncRight));
|
||||
return `${ACCENT}│${RST}${truncRight}${" ".repeat(rightPad)}${ACCENT}│${RST}`;
|
||||
// If the status chunk alone is too wide, preserve it rather than overflowing
|
||||
// the terminal. The left side is intentionally dropped in this case.
|
||||
if (rightVis >= width) {
|
||||
const truncRight = truncateToWidth(right, width);
|
||||
return `${truncRight}${" ".repeat(Math.max(0, width - visibleWidth(truncRight)))}`;
|
||||
}
|
||||
|
||||
const maxLeft = Math.max(0, contentWidth - rightVis);
|
||||
const maxLeft = Math.max(0, width - rightVis);
|
||||
const truncLeft = truncateToWidth(left, maxLeft);
|
||||
const leftVis = visibleWidth(truncLeft);
|
||||
const pad = Math.max(0, contentWidth - leftVis - rightVis);
|
||||
return `${ACCENT}│${RST}${truncLeft}${" ".repeat(pad)}${right}${ACCENT}│${RST}`;
|
||||
const pad = Math.max(0, width - visibleWidth(truncLeft) - rightVis);
|
||||
return `${truncLeft}${" ".repeat(pad)}${right}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the bordered top line: ╭─ Title ──── info ─╮
|
||||
* All chars are accounted for within `width`.
|
||||
*/
|
||||
function borderTop(title: string, info: string, width: number): string {
|
||||
/** Build the compact, unboxed header row for the status panel. */
|
||||
function renderWidgetHeader(count: number, width: number, theme: StatusWidgetTheme): string {
|
||||
if (width <= 0) return "";
|
||||
if (width === 1) return `${ACCENT}╭${RST}`;
|
||||
|
||||
// ╭─ Title ───...─── info ─╮
|
||||
// overhead: ╭─ (2) + space around title (2) + space around info (2) + ─╮ (2) = but we simplify
|
||||
const inner = Math.max(0, width - 2); // inside ╭ and ╮
|
||||
const titlePart = `─ ${title} `;
|
||||
const infoPart = ` ${info} ─`;
|
||||
const fillLen = Math.max(0, inner - titlePart.length - infoPart.length);
|
||||
const fill = "─".repeat(fillLen);
|
||||
const content = `${titlePart}${fill}${infoPart}`.slice(0, inner).padEnd(inner, "─");
|
||||
return `${ACCENT}╭${content}╮${RST}`;
|
||||
const title = theme.fg("accent", "Subagents");
|
||||
const info = theme.fg("muted", `${count} running`);
|
||||
return truncateToWidth(`${title} ${info}`, width);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the bordered bottom line: ╰──────────────────╯
|
||||
*/
|
||||
function borderBottom(width: number): string {
|
||||
if (width <= 0) return "";
|
||||
if (width === 1) return `${ACCENT}╰${RST}`;
|
||||
|
||||
const inner = Math.max(0, width - 2);
|
||||
return `${ACCENT}╰${"─".repeat(inner)}╯${RST}`;
|
||||
}
|
||||
|
||||
function renderSubagentWidgetLines(agents: RunningSubagent[], width: number): string[] {
|
||||
function renderSubagentWidgetLines(
|
||||
agents: RunningSubagent[],
|
||||
width: number,
|
||||
maxBodyRows?: number,
|
||||
theme?: StatusWidgetTheme,
|
||||
): string[] {
|
||||
const count = agents.length;
|
||||
const title = "Subagents";
|
||||
const info = `${count} running`;
|
||||
const renderTheme = resolveStatusWidgetTheme(theme);
|
||||
const bodyLimit = maxBodyRows == null || !Number.isFinite(maxBodyRows)
|
||||
? null
|
||||
: Math.max(0, Math.floor(maxBodyRows));
|
||||
|
||||
const lines: string[] = [borderTop(title, info, width)];
|
||||
// The body limit includes the overflow summary. This lets a host reserve a
|
||||
// fixed number of rows without the summary unexpectedly pushing the
|
||||
// composite widget over its available height.
|
||||
const visibleAgentCount = bodyLimit == null || agents.length <= bodyLimit
|
||||
? agents.length
|
||||
: Math.max(0, bodyLimit - 1);
|
||||
const overflow = agents.length - visibleAgentCount;
|
||||
|
||||
for (const agent of agents) {
|
||||
const elapsed = formatElapsedMMSS(agent.startTime);
|
||||
const agentTag = agent.agent ? ` (${agent.agent})` : "";
|
||||
const lines: string[] = [renderWidgetHeader(count, width, renderTheme)];
|
||||
|
||||
for (const agent of agents.slice(0, visibleAgentCount)) {
|
||||
const elapsed = renderTheme.fg("dim", formatElapsedMMSS(agent.startTime));
|
||||
const snapshot = classifyStatus(agent.statusState, Date.now());
|
||||
const icon = widgetIcon(snapshot.kind);
|
||||
const left = ` ${icon} ${elapsed} ${agent.name}${agentTag} `;
|
||||
const statusColor = statusWidgetColor(snapshot.kind);
|
||||
const icon = widgetIcon(snapshot.kind, renderTheme);
|
||||
const name = renderTheme.fg(statusColor, normalizeStatusName(agent.name));
|
||||
const agentTag = agent.agent
|
||||
? renderTheme.fg("dim", ` (${normalizeStatusName(agent.agent)})`)
|
||||
: "";
|
||||
const left = ` ${icon} ${elapsed} ${name}${agentTag} `;
|
||||
const right = statusConfig.enabled
|
||||
? formatWidgetRightLabel(snapshot)
|
||||
: agent.cli === "claude"
|
||||
? " running… "
|
||||
: " starting… ";
|
||||
? formatWidgetRightLabel(snapshot, renderTheme)
|
||||
: renderTheme.fg(
|
||||
statusColor,
|
||||
agent.cli === "claude" ? " running… " : " starting… ",
|
||||
);
|
||||
|
||||
lines.push(borderLine(left, right, width));
|
||||
}
|
||||
|
||||
lines.push(borderBottom(width));
|
||||
if (overflow > 0 && bodyLimit !== 0) {
|
||||
lines.push(borderLine(renderTheme.fg("dim", `+${overflow} more running.`), "", width));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function updateWidget() {
|
||||
function clearStandaloneWidget(): void {
|
||||
const ctx = sharedRuntime.latestCtx;
|
||||
if (!ctx?.hasUI) return;
|
||||
try {
|
||||
ctx.ui.setWidget("subagent-status", undefined);
|
||||
} catch {
|
||||
// Pi may be tearing down the UI while a host releases its claim.
|
||||
}
|
||||
}
|
||||
|
||||
function setStandaloneWidget(): void {
|
||||
const ctx = sharedRuntime.latestCtx;
|
||||
if (!ctx?.hasUI) return;
|
||||
|
||||
if (runningSubagents.size === 0) {
|
||||
ctx.ui.setWidget("subagent-status", undefined);
|
||||
if (sharedRuntime.widgetInterval) {
|
||||
clearInterval(sharedRuntime.widgetInterval);
|
||||
sharedRuntime.widgetInterval = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ctx.ui.setWidget(
|
||||
"subagent-status",
|
||||
(_tui: any, _theme: any) => {
|
||||
(_tui: any, theme?: StatusWidgetTheme) => {
|
||||
return {
|
||||
invalidate() {},
|
||||
render(width: number) {
|
||||
return renderSubagentWidgetLines(Array.from(runningSubagents.values()), width);
|
||||
return renderSubagentWidgetLines(
|
||||
Array.from(runningSubagents.values()),
|
||||
width,
|
||||
undefined,
|
||||
theme,
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
{ placement: "aboveEditor" },
|
||||
);
|
||||
} catch {
|
||||
// Pi may be tearing down the UI while a claim is being released.
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentStatusWidgetClaim(): StatusWidgetClaim | null {
|
||||
const claim = sharedRuntime.statusWidgetClaim;
|
||||
return isStatusWidgetClaim(claim)
|
||||
&& claim.runtimeToken === sharedRuntime.activeRuntimeToken
|
||||
? claim
|
||||
: null;
|
||||
}
|
||||
|
||||
function synchronizeStandaloneWidget(): void {
|
||||
const claim = sharedRuntime.statusWidgetClaim;
|
||||
if (getCurrentStatusWidgetClaim()) {
|
||||
clearStandaloneWidget();
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop malformed or stale ownership before restoring the fallback widget.
|
||||
if (claim !== null) sharedRuntime.statusWidgetClaim = null;
|
||||
if (runningSubagents.size > 0) setStandaloneWidget();
|
||||
else clearStandaloneWidget();
|
||||
}
|
||||
|
||||
function scheduleStandaloneWidgetSync(): void {
|
||||
if (sharedRuntime.standaloneWidgetSyncScheduled) return;
|
||||
sharedRuntime.standaloneWidgetSyncScheduled = true;
|
||||
queueMicrotask(() => {
|
||||
sharedRuntime.standaloneWidgetSyncScheduled = false;
|
||||
synchronizeStandaloneWidget();
|
||||
});
|
||||
}
|
||||
|
||||
function notifyStatusWidgetClaim(): void {
|
||||
const claim = sharedRuntime.statusWidgetClaim;
|
||||
if (!claim) return;
|
||||
|
||||
const currentClaim = getCurrentStatusWidgetClaim();
|
||||
if (!currentClaim) {
|
||||
if (sharedRuntime.statusWidgetClaim === claim) {
|
||||
sharedRuntime.statusWidgetClaim = null;
|
||||
scheduleStandaloneWidgetSync();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
currentClaim.onChange();
|
||||
} catch {
|
||||
// A host can be destroyed independently of this extension. Drop a claim
|
||||
// whose callback is no longer usable, then restore the standalone widget.
|
||||
if (sharedRuntime.statusWidgetClaim === claim) {
|
||||
sharedRuntime.statusWidgetClaim = null;
|
||||
scheduleStandaloneWidgetSync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrentStatusWidgetClaim(onChange: () => void): boolean {
|
||||
return typeof onChange === "function" && getCurrentStatusWidgetClaim()?.onChange === onChange;
|
||||
}
|
||||
|
||||
function releaseStatusWidgetClaim(ownerToken: symbol): void {
|
||||
const claim = sharedRuntime.statusWidgetClaim;
|
||||
if (!isStatusWidgetClaim(claim) || claim.ownerToken !== ownerToken) return;
|
||||
sharedRuntime.statusWidgetClaim = null;
|
||||
if (runningSubagents.size === 0) clearRefreshIntervals();
|
||||
scheduleStandaloneWidgetSync();
|
||||
}
|
||||
|
||||
function isStatusWidget(value: unknown): value is StatusWidget {
|
||||
try {
|
||||
if (value == null || typeof value !== "object") return false;
|
||||
const candidate = value as Record<PropertyKey, unknown>;
|
||||
return candidate[STATUS_WIDGET_MARKER] === true
|
||||
&& typeof candidate.hasContent === "function"
|
||||
&& typeof candidate.getBodyRowCount === "function"
|
||||
&& typeof candidate.render === "function"
|
||||
&& typeof candidate.claim === "function"
|
||||
&& typeof candidate.isClaimed === "function";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createStatusWidget(): StatusWidget {
|
||||
const widget: StatusWidget = {
|
||||
hasContent: () => runningSubagents.size > 0,
|
||||
getBodyRowCount: () => runningSubagents.size,
|
||||
render: (width, maxBodyRows, theme) => runningSubagents.size === 0
|
||||
? []
|
||||
: sharedRuntime.statusWidgetRenderer(width, maxBodyRows, theme),
|
||||
isClaimed: (onChange) => isCurrentStatusWidgetClaim(onChange),
|
||||
claim(onChange) {
|
||||
if (typeof onChange !== "function") {
|
||||
throw new TypeError("statusWidget.claim requires an onChange callback");
|
||||
}
|
||||
|
||||
const ownerToken = Symbol("pi-subagents/status-widget-claim");
|
||||
const claim: StatusWidgetClaim = {
|
||||
ownerToken,
|
||||
runtimeToken: sharedRuntime.activeRuntimeToken,
|
||||
onChange,
|
||||
};
|
||||
Object.defineProperty(claim, STATUS_WIDGET_CLAIM_MARKER, { value: true });
|
||||
sharedRuntime.statusWidgetClaim = claim;
|
||||
scheduleStandaloneWidgetSync();
|
||||
|
||||
return () => releaseStatusWidgetClaim(ownerToken);
|
||||
},
|
||||
};
|
||||
Object.defineProperty(widget, STATUS_WIDGET_MARKER, { value: true });
|
||||
return widget;
|
||||
}
|
||||
|
||||
// Keep the integration object stable across reload while rebinding its
|
||||
// renderer to this module instance's current status configuration. A widget
|
||||
// from an older/malformed runtime is replaced rather than exposing an
|
||||
// untrusted contract to the host.
|
||||
sharedRuntime.statusWidgetRenderer = (width, maxBodyRows, theme) =>
|
||||
renderSubagentWidgetLines(Array.from(runningSubagents.values()), width, maxBodyRows, theme);
|
||||
const statusWidget = isStatusWidget(sharedRuntime.statusWidget)
|
||||
? sharedRuntime.statusWidget
|
||||
: createStatusWidget();
|
||||
sharedRuntime.statusWidget = statusWidget;
|
||||
interactiveSubagentsApi.statusWidget = statusWidget;
|
||||
|
||||
function updateWidget() {
|
||||
if (runningSubagents.size === 0) {
|
||||
clearStandaloneWidget();
|
||||
clearRefreshIntervals();
|
||||
notifyStatusWidgetClaim();
|
||||
return;
|
||||
}
|
||||
|
||||
if (sharedRuntime.statusWidgetClaim) {
|
||||
clearStandaloneWidget();
|
||||
notifyStatusWidgetClaim();
|
||||
return;
|
||||
}
|
||||
|
||||
setStandaloneWidget();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1101,14 +1409,15 @@ function handleSubagentSteer(
|
||||
}
|
||||
|
||||
function startStatusRefresh() {
|
||||
if (!statusConfig.enabled || sharedRuntime.statusInterval) return;
|
||||
if (!statusConfig.enabled) return;
|
||||
const runtimeToken = sharedRuntime.activeRuntimeToken;
|
||||
if (!runtimeToken) return;
|
||||
if (getRefreshInterval("status")) return;
|
||||
|
||||
sharedRuntime.statusInterval = setInterval(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (!ownsRefreshInterval("status", interval, runtimeToken)) return;
|
||||
if (runningSubagents.size === 0) {
|
||||
if (sharedRuntime.statusInterval) {
|
||||
clearInterval(sharedRuntime.statusInterval);
|
||||
sharedRuntime.statusInterval = null;
|
||||
}
|
||||
clearRefreshInterval("status");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1150,6 +1459,7 @@ function startStatusRefresh() {
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
sharedRuntime.statusInterval = interval;
|
||||
}
|
||||
|
||||
// Resuming a finished session is always autonomous: the relaunched agent runs
|
||||
@@ -1165,6 +1475,8 @@ export const __test__ = {
|
||||
defaultShellReadyDelayMs,
|
||||
getShellReadyDelayMs,
|
||||
renderSubagentWidgetLines,
|
||||
statusWidget,
|
||||
updateWidget,
|
||||
loadAgentDefaults,
|
||||
discoverAgentDefinitions,
|
||||
resolveEffectiveSessionMode,
|
||||
@@ -1197,11 +1509,18 @@ export const __test__ = {
|
||||
};
|
||||
|
||||
function startWidgetRefresh() {
|
||||
if (sharedRuntime.widgetInterval) return;
|
||||
const runtimeToken = sharedRuntime.activeRuntimeToken;
|
||||
if (!runtimeToken) return;
|
||||
if (getRefreshInterval("widget")) return;
|
||||
|
||||
updateWidget(); // immediate first render
|
||||
sharedRuntime.widgetInterval = setInterval(() => {
|
||||
if (runningSubagents.size === 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (!ownsRefreshInterval("widget", interval, runtimeToken)) return;
|
||||
updateWidget();
|
||||
}, 1000);
|
||||
sharedRuntime.widgetInterval = interval;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1706,11 +2025,25 @@ async function watchSubagent(
|
||||
|
||||
export default function subagentsExtension(pi: ExtensionAPI) {
|
||||
const runtimeToken = Symbol("pi-subagents/runtime");
|
||||
// The newest registered extension instance is the only one allowed to bind
|
||||
// a session. This prevents a delayed old session_start from rebinding stale
|
||||
// callbacks after a replacement runtime has already started.
|
||||
sharedRuntime.registeredRuntimeToken = runtimeToken;
|
||||
|
||||
// Bind only after Pi has installed the fresh runtime. During /reload the old
|
||||
// session_shutdown handler clears these references first, so watcher results
|
||||
// cannot enqueue a turn through stale extension APIs.
|
||||
pi.on("session_start", (_event, ctx) => {
|
||||
if (sharedRuntime.registeredRuntimeToken !== runtimeToken) return;
|
||||
|
||||
// A fresh runtime may start before the old runtime emits shutdown. Drop
|
||||
// old UI ownership and intervals before binding the fresh context.
|
||||
if (sharedRuntime.activeRuntimeToken !== runtimeToken) {
|
||||
sharedRuntime.statusWidgetClaim = null;
|
||||
clearRefreshIntervals();
|
||||
scheduleStandaloneWidgetSync();
|
||||
}
|
||||
|
||||
sharedRuntime.activeRuntimeToken = runtimeToken;
|
||||
sharedRuntime.latestPi = pi;
|
||||
sharedRuntime.latestCtx = ctx;
|
||||
@@ -1729,6 +2062,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
||||
// state already rebound to the replacement runtime.
|
||||
if (sharedRuntime.activeRuntimeToken !== runtimeToken) return;
|
||||
|
||||
// The callback belongs to the runtime/UI that is shutting down. A fresh
|
||||
// runtime must explicitly claim again rather than inheriting a stale host
|
||||
// callback from the old session.
|
||||
clearStandaloneWidget();
|
||||
sharedRuntime.statusWidgetClaim = null;
|
||||
if (sharedRuntime.registeredRuntimeToken === runtimeToken) {
|
||||
sharedRuntime.registeredRuntimeToken = null;
|
||||
}
|
||||
sharedRuntime.activeRuntimeToken = null;
|
||||
sharedRuntime.latestPi = null;
|
||||
sharedRuntime.latestCtx = null;
|
||||
|
||||
+390
-26
@@ -141,6 +141,37 @@ async function emitMockEvent(
|
||||
}
|
||||
}
|
||||
|
||||
type SharedRuntimeTestState = {
|
||||
runningSubagents: Map<string, unknown>;
|
||||
latestCtx: unknown;
|
||||
latestPi: unknown;
|
||||
widgetInterval: ReturnType<typeof setInterval> | null;
|
||||
statusInterval: ReturnType<typeof setInterval> | null;
|
||||
standaloneWidgetSyncScheduled: boolean;
|
||||
pendingDeliveries: { length: number };
|
||||
deliveryFlushScheduled: boolean;
|
||||
activeRuntimeToken: symbol | null;
|
||||
registeredRuntimeToken: symbol | null;
|
||||
statusWidgetClaim: unknown;
|
||||
statusWidget?: unknown;
|
||||
};
|
||||
|
||||
function resetSharedRuntimeForTest(shared: SharedRuntimeTestState): void {
|
||||
if (shared.widgetInterval) clearInterval(shared.widgetInterval);
|
||||
if (shared.statusInterval) clearInterval(shared.statusInterval);
|
||||
shared.widgetInterval = null;
|
||||
shared.statusInterval = null;
|
||||
shared.standaloneWidgetSyncScheduled = false;
|
||||
shared.runningSubagents.clear();
|
||||
shared.latestCtx = null;
|
||||
shared.latestPi = null;
|
||||
shared.statusWidgetClaim = null;
|
||||
shared.pendingDeliveries.length = 0;
|
||||
shared.deliveryFlushScheduled = false;
|
||||
shared.activeRuntimeToken = null;
|
||||
shared.registeredRuntimeToken = null;
|
||||
}
|
||||
|
||||
function restoreEnvVar(name: string, value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
@@ -1860,33 +1891,11 @@ describe("commands", () => {
|
||||
|
||||
describe("reload lifecycle", () => {
|
||||
const testApi = (subagentsModule as any).__test__;
|
||||
const shared = testApi.sharedRuntime as {
|
||||
runningSubagents: Map<string, any>;
|
||||
latestCtx: any;
|
||||
latestPi: any;
|
||||
widgetInterval: ReturnType<typeof setInterval> | null;
|
||||
statusInterval: ReturnType<typeof setInterval> | null;
|
||||
pendingDeliveries: Array<(pi: any) => void>;
|
||||
deliveryFlushScheduled: boolean;
|
||||
activeRuntimeToken: symbol | null;
|
||||
};
|
||||
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
|
||||
const ctx = { hasUI: false, ui: { setWidget() {} } };
|
||||
|
||||
function resetSharedRuntime() {
|
||||
if (shared.widgetInterval) clearInterval(shared.widgetInterval);
|
||||
if (shared.statusInterval) clearInterval(shared.statusInterval);
|
||||
shared.widgetInterval = null;
|
||||
shared.statusInterval = null;
|
||||
shared.runningSubagents.clear();
|
||||
shared.latestCtx = null;
|
||||
shared.latestPi = null;
|
||||
shared.pendingDeliveries.length = 0;
|
||||
shared.deliveryFlushScheduled = false;
|
||||
shared.activeRuntimeToken = null;
|
||||
}
|
||||
|
||||
beforeEach(resetSharedRuntime);
|
||||
after(resetSharedRuntime);
|
||||
beforeEach(() => resetSharedRuntimeForTest(shared));
|
||||
after(() => resetSharedRuntimeForTest(shared));
|
||||
|
||||
it("preserves a live subagent and delivers its queued result through the fresh runtime", async () => {
|
||||
const oldRuntime = createMockExtensionApi();
|
||||
@@ -2623,6 +2632,71 @@ describe("subagent startup delay", () => {
|
||||
});
|
||||
});
|
||||
describe("subagents widget rendering", () => {
|
||||
function createThemeSpy() {
|
||||
const calls: Array<{ color: string; text: string }> = [];
|
||||
return {
|
||||
calls,
|
||||
theme: {
|
||||
fg(color: string, text: string) {
|
||||
calls.push({ color, text });
|
||||
return `\x1b[38;5;${calls.length}m${text}\x1b[0m`;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWidgetAgent(
|
||||
id: string,
|
||||
kind: "active" | "running" | "stalled" | "starting" | "waiting",
|
||||
) {
|
||||
const startTime = kind === "starting" ? 1_061_000 - 5_000 : 1_000_000 - 5_000;
|
||||
let statusState = createStatusState({
|
||||
source: kind === "running" ? "claude" : "pi",
|
||||
startTimeMs: startTime,
|
||||
});
|
||||
if (kind === "active" || kind === "waiting") {
|
||||
statusState = observeStatus(
|
||||
statusState,
|
||||
{
|
||||
snapshot: "present",
|
||||
updatedAt: startTime + 1_000,
|
||||
sequence: 1,
|
||||
phase: kind,
|
||||
...(kind === "active"
|
||||
? {
|
||||
active: true,
|
||||
activeScope: "tool",
|
||||
activeSince: startTime + 1_000,
|
||||
activityLabel: "bash",
|
||||
}
|
||||
: { waitingSince: startTime + 1_000 }),
|
||||
},
|
||||
startTime + 1_000,
|
||||
);
|
||||
} else if (kind === "stalled") {
|
||||
statusState = observeStatus(
|
||||
statusState,
|
||||
{ snapshot: "missing" },
|
||||
startTime + 1_000,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
agent: "worker",
|
||||
task: "",
|
||||
surface: "surface",
|
||||
startTime,
|
||||
sessionFile: `${id}.jsonl`,
|
||||
statusState,
|
||||
};
|
||||
}
|
||||
|
||||
function createActiveWidgetAgent(id: string) {
|
||||
return createWidgetAgent(id, "active");
|
||||
}
|
||||
|
||||
it("keeps every rendered line within a very narrow width", () => {
|
||||
const testApi = (subagentsModule as any).__test__;
|
||||
assert.ok(testApi, "expected subagents test helpers to be exported");
|
||||
@@ -2663,13 +2737,33 @@ describe("subagents widget rendering", () => {
|
||||
|
||||
assert.deepEqual(
|
||||
lines.map((line: string) => visibleWidth(line)),
|
||||
[16, 16, 16, 16, 16],
|
||||
[16, 16, 16, 16],
|
||||
);
|
||||
assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line)));
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
|
||||
it("sanitizes whitespace in agent names before rendering rows", () => {
|
||||
const testApi = (subagentsModule as any).__test__;
|
||||
const startTime = Date.now() - 5_000;
|
||||
const lines = testApi.renderSubagentWidgetLines([
|
||||
{
|
||||
id: "unsafe",
|
||||
name: "worker\nwith\ttabs",
|
||||
task: "",
|
||||
surface: "s1",
|
||||
startTime,
|
||||
sessionFile: "sess1",
|
||||
statusState: createStatusState({ source: "pi", startTimeMs: startTime }),
|
||||
},
|
||||
], 80);
|
||||
|
||||
assert.ok(lines.every((line: string) => !/[\r\n]/.test(line)));
|
||||
assert.match(lines[1] ?? "", /worker with tabs/);
|
||||
});
|
||||
|
||||
it("truncates the right-hand status instead of overflowing when it alone is too wide", () => {
|
||||
const testApi = (subagentsModule as any).__test__;
|
||||
assert.ok(testApi, "expected subagents test helpers to be exported");
|
||||
@@ -2707,6 +2801,276 @@ describe("subagents widget rendering", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the standalone widget factory theme for semantic status colors", () => {
|
||||
const testApi = (subagentsModule as any).__test__;
|
||||
const widgetCalls: any[] = [];
|
||||
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
|
||||
shared.latestCtx = {
|
||||
hasUI: true,
|
||||
ui: { setWidget: (...args: any[]) => widgetCalls.push(args) },
|
||||
};
|
||||
shared.runningSubagents.set("active", createActiveWidgetAgent("active"));
|
||||
|
||||
testApi.updateWidget();
|
||||
const { theme, calls } = createThemeSpy();
|
||||
const component = widgetCalls[0][1](undefined, theme);
|
||||
const lines = withMockedNow(1_000_000, () => component.render(80));
|
||||
|
||||
assert.equal(lines.length, 2, "header plus one body row");
|
||||
assert.equal(calls.find((call) => call.text === "Subagents")?.color, "accent");
|
||||
assert.equal(calls.find((call) => call.text === "1 running")?.color, "muted");
|
||||
assert.ok(calls.some((call) => call.color === "warning" && call.text.includes("active")));
|
||||
assert.equal(calls.find((call) => call.text === " (worker)")?.color, "dim");
|
||||
assert.ok(calls.some((call) => call.color === "dim" && call.text === "00:05"));
|
||||
assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line)));
|
||||
});
|
||||
|
||||
it("uses the optional theme passed to hosted rendering", () => {
|
||||
const testApi = (subagentsModule as any).__test__;
|
||||
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
|
||||
shared.runningSubagents.set("active", createActiveWidgetAgent("active"));
|
||||
const { theme, calls } = createThemeSpy();
|
||||
|
||||
const lines = withMockedNow(1_000_000, () => testApi.statusWidget.render(80, undefined, theme));
|
||||
|
||||
assert.equal(lines.length, 2, "header plus one body row");
|
||||
assert.ok(calls.some((call) => call.color === "accent" && call.text === "Subagents"));
|
||||
assert.ok(calls.some((call) => call.color === "muted" && call.text === "1 running"));
|
||||
assert.ok(calls.some((call) => call.color === "warning" && call.text.includes("active")));
|
||||
assert.ok(calls.some((call) => call.color === "dim" && call.text === "00:05"));
|
||||
assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line)));
|
||||
});
|
||||
|
||||
it("maps every status kind to a semantic row color", () => {
|
||||
const testApi = (subagentsModule as any).__test__;
|
||||
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
|
||||
for (const [id, kind] of [
|
||||
["active", "active"],
|
||||
["running", "running"],
|
||||
["stalled", "stalled"],
|
||||
["starting", "starting"],
|
||||
["waiting", "waiting"],
|
||||
] as const) {
|
||||
shared.runningSubagents.set(id, createWidgetAgent(id, kind));
|
||||
}
|
||||
const { theme, calls } = createThemeSpy();
|
||||
|
||||
withMockedNow(1_061_000, () => testApi.statusWidget.render(120, undefined, theme));
|
||||
|
||||
const expected = {
|
||||
active: "warning",
|
||||
running: "warning",
|
||||
stalled: "error",
|
||||
starting: "dim",
|
||||
waiting: "accent",
|
||||
} as const;
|
||||
for (const [id, kind] of [
|
||||
["active", "active"],
|
||||
["running", "running"],
|
||||
["stalled", "stalled"],
|
||||
["starting", "starting"],
|
||||
["waiting", "waiting"],
|
||||
] as const) {
|
||||
assert.ok(
|
||||
calls.some((call) => call.text === id && call.color === expected[kind]),
|
||||
`${id} should use ${expected[kind]}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("status widget host integration", () => {
|
||||
const testApi = (subagentsModule as any).__test__;
|
||||
const statusWidget = (globalThis as any).__pi_interactive_subagents.statusWidget;
|
||||
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
|
||||
|
||||
function addRunning(id: string) {
|
||||
const startTime = Date.now();
|
||||
shared.runningSubagents.set(id, {
|
||||
id,
|
||||
name: id,
|
||||
task: "",
|
||||
surface: "surface",
|
||||
startTime,
|
||||
sessionFile: `${id}.jsonl`,
|
||||
statusState: createStatusState({ source: "pi", startTimeMs: startTime }),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => resetSharedRuntimeForTest(shared));
|
||||
after(() => resetSharedRuntimeForTest(shared));
|
||||
|
||||
it("falls back to the standalone widget when unclaimed", () => {
|
||||
const widgetCalls: any[] = [];
|
||||
shared.latestCtx = {
|
||||
hasUI: true,
|
||||
ui: { setWidget: (...args: any[]) => widgetCalls.push(args) },
|
||||
};
|
||||
addRunning("worker");
|
||||
|
||||
testApi.updateWidget();
|
||||
|
||||
assert.equal(widgetCalls.length, 1);
|
||||
assert.equal(widgetCalls[0][0], "subagent-status");
|
||||
assert.equal(typeof widgetCalls[0][1], "function");
|
||||
});
|
||||
|
||||
it("suppresses, notifies, and restores the standalone widget around a claim", async () => {
|
||||
const widgetCalls: any[] = [];
|
||||
shared.latestCtx = {
|
||||
hasUI: true,
|
||||
ui: { setWidget: (...args: any[]) => widgetCalls.push(args) },
|
||||
};
|
||||
addRunning("worker");
|
||||
testApi.updateWidget();
|
||||
|
||||
let changes = 0;
|
||||
const onChange = () => changes++;
|
||||
const release = statusWidget.claim(onChange);
|
||||
assert.equal(statusWidget.isClaimed(onChange), true);
|
||||
assert.equal(statusWidget.isClaimed(() => {}), false);
|
||||
assert.equal(typeof widgetCalls.at(-1)[1], "function", "claim sync is deferred");
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(widgetCalls.at(-1)[1], undefined);
|
||||
|
||||
testApi.updateWidget();
|
||||
assert.equal(changes, 1);
|
||||
|
||||
release();
|
||||
assert.equal(statusWidget.isClaimed(onChange), false);
|
||||
assert.equal(widgetCalls.at(-1)[1], undefined, "release sync is deferred");
|
||||
await Promise.resolve();
|
||||
assert.equal(typeof widgetCalls.at(-1)[1], "function");
|
||||
release();
|
||||
assert.equal(widgetCalls.length, 4, "release is idempotent");
|
||||
});
|
||||
|
||||
it("does not restore standalone UI between an immediate release and re-claim", async () => {
|
||||
const widgetCalls: any[] = [];
|
||||
shared.latestCtx = {
|
||||
hasUI: true,
|
||||
ui: { setWidget: (...args: any[]) => widgetCalls.push(args) },
|
||||
};
|
||||
addRunning("worker");
|
||||
testApi.updateWidget();
|
||||
|
||||
const firstChange = () => {};
|
||||
const releaseFirst = statusWidget.claim(firstChange);
|
||||
releaseFirst();
|
||||
const secondChange = () => {};
|
||||
const releaseSecond = statusWidget.claim(secondChange);
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(statusWidget.isClaimed(firstChange), false);
|
||||
assert.equal(statusWidget.isClaimed(secondChange), true);
|
||||
assert.equal(widgetCalls.at(-1)[1], undefined);
|
||||
releaseSecond();
|
||||
await Promise.resolve();
|
||||
assert.equal(typeof widgetCalls.at(-1)[1], "function");
|
||||
});
|
||||
|
||||
it("caps body rows with an honest overflow row", () => {
|
||||
addRunning("worker");
|
||||
addRunning("scout");
|
||||
addRunning("reviewer");
|
||||
|
||||
const lines = statusWidget.render(40, 2);
|
||||
assert.equal(lines.length, 3, "header + one agent + overflow");
|
||||
assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line)));
|
||||
assert.match(lines[2], /\+2 more running\./);
|
||||
assert.equal(statusWidget.getBodyRowCount(), 3);
|
||||
assert.equal(statusWidget.hasContent(), true);
|
||||
});
|
||||
|
||||
it("keeps capped output within narrow widths", () => {
|
||||
addRunning("worker");
|
||||
addRunning("scout");
|
||||
|
||||
for (const width of [0, 1, 2, 4, 8]) {
|
||||
for (const line of statusWidget.render(width, 1)) {
|
||||
assert.ok(
|
||||
visibleWidth(line) <= width,
|
||||
`expected line width <= ${width}, got ${visibleWidth(line)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("drops old claims during reload and allows a fresh runtime to claim", async () => {
|
||||
const oldRuntime = createMockExtensionApi();
|
||||
(subagentsModule as any).default(oldRuntime.api);
|
||||
const oldCtx = {
|
||||
hasUI: true,
|
||||
ui: { setWidget() {} },
|
||||
};
|
||||
await emitMockEvent(oldRuntime, "session_start", { reason: "startup" }, oldCtx);
|
||||
addRunning("worker");
|
||||
|
||||
let oldChanges = 0;
|
||||
const oldOnChange = () => oldChanges++;
|
||||
const oldRelease = statusWidget.claim(oldOnChange);
|
||||
|
||||
// Bind the replacement before the old runtime emits shutdown. The old
|
||||
// shutdown must not undo the fresh binding or retain its old callback.
|
||||
const freshRuntime = createMockExtensionApi();
|
||||
(subagentsModule as any).default(freshRuntime.api);
|
||||
await emitMockEvent(freshRuntime, "session_start", { reason: "reload" }, oldCtx);
|
||||
// A delayed old start must also be ignored once the fresh runtime is bound.
|
||||
await emitMockEvent(oldRuntime, "session_start", { reason: "reload" }, oldCtx);
|
||||
assert.equal(shared.latestPi, freshRuntime.api);
|
||||
assert.equal(statusWidget.isClaimed(oldOnChange), false);
|
||||
await emitMockEvent(oldRuntime, "session_shutdown", { reason: "reload" }, oldCtx);
|
||||
testApi.updateWidget();
|
||||
|
||||
assert.equal(oldChanges, 0);
|
||||
assert.equal(shared.statusWidgetClaim, null);
|
||||
assert.equal(shared.runningSubagents.size, 1);
|
||||
|
||||
let freshChanges = 0;
|
||||
const freshRelease = statusWidget.claim(() => freshChanges++);
|
||||
oldRelease();
|
||||
testApi.updateWidget();
|
||||
assert.equal(freshChanges, 1, "a stale release cannot affect the fresh claim");
|
||||
freshRelease();
|
||||
});
|
||||
|
||||
it("replaces malformed and old widget contracts on a duplicate load", async () => {
|
||||
const globalApi = (globalThis as any).__pi_interactive_subagents;
|
||||
(globalThis as any).__pi_interactive_subagents = "old-api";
|
||||
shared.statusWidgetClaim = { id: 1, onChange: () => {} };
|
||||
await import(`../pi-extension/subagents/index.ts?malformed-global=${Date.now()}`);
|
||||
const repairedApi = (globalThis as any).__pi_interactive_subagents;
|
||||
assert.equal(typeof repairedApi.registerToolExtension, "function");
|
||||
assert.equal(repairedApi.statusWidget, globalApi.statusWidget);
|
||||
|
||||
shared.statusWidgetClaim = { id: 1, onChange: () => {} };
|
||||
await import(`../pi-extension/subagents/index.ts?old-widget-claim=${Date.now()}`);
|
||||
assert.equal(shared.statusWidgetClaim, null);
|
||||
|
||||
const oldContract = {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 1,
|
||||
render: () => [],
|
||||
claim: () => () => {},
|
||||
};
|
||||
shared.statusWidget = oldContract;
|
||||
(globalThis as any).__pi_interactive_subagents.statusWidget = oldContract;
|
||||
|
||||
await import(`../pi-extension/subagents/index.ts?old-widget-contract=${Date.now()}`);
|
||||
|
||||
const currentApi = (globalThis as any).__pi_interactive_subagents;
|
||||
const current = currentApi.statusWidget;
|
||||
assert.equal(currentApi, repairedApi);
|
||||
assert.notEqual(current, oldContract);
|
||||
assert.equal(typeof current.hasContent, "function");
|
||||
assert.equal(typeof current.getBodyRowCount, "function");
|
||||
assert.equal(typeof current.render, "function");
|
||||
assert.equal(typeof current.claim, "function");
|
||||
assert.equal(typeof current.isClaimed, "function");
|
||||
assert.equal(shared.statusWidget, current);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subagent display helpers", () => {
|
||||
|
||||
Reference in New Issue
Block a user