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.
|
`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 ─╮
|
Subagents 2 running
|
||||||
│ 00:23 scout active · bash 7m │
|
⟳ 00:23 scout active · bash 7m
|
||||||
│ 00:45 scout-2 waiting 2m │
|
○ 00:45 scout-2 waiting 2m
|
||||||
╰────────────────────────────────────────────────────╯
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Spawn several in parallel — they run concurrently and steer results back independently as each finishes.
|
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.
|
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
|
## 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.
|
||||||
+452
-111
@@ -55,6 +55,7 @@ import {
|
|||||||
formatTransitionLine,
|
formatTransitionLine,
|
||||||
observeStatus,
|
observeStatus,
|
||||||
loadStatusConfig,
|
loadStatusConfig,
|
||||||
|
normalizeStatusName,
|
||||||
} from "./status.ts";
|
} from "./status.ts";
|
||||||
import {
|
import {
|
||||||
getSubagentActivityFile,
|
getSubagentActivityFile,
|
||||||
@@ -97,6 +98,30 @@ const SubagentParams = Type.Object({
|
|||||||
|
|
||||||
type SubagentSessionMode = "standalone" | "lineage-only" | "fork";
|
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 {
|
interface AgentDefaults {
|
||||||
model?: string;
|
model?: string;
|
||||||
tools?: 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
|
// Expose registration on a process-global so project-local extensions loaded
|
||||||
// via jiti (separate module instances) can reach this shared map. Set at module
|
// via jiti (separate module instances) can reach this shared map. Keep the
|
||||||
// load so it's available before any `session_start` listener runs.
|
// object itself stable across /reload so an optional host integration can keep
|
||||||
(globalThis as any).__pi_interactive_subagents = {
|
// its reference while this extension is re-imported.
|
||||||
registerToolExtension,
|
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
|
* 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;
|
return segs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ANSI colors for widget status icons (raw, since the widget bypasses theme). */
|
/**
|
||||||
const ICON_GREEN = "\x1b[38;2;126;186;103m";
|
* ANSI fallback for hosts that still call render() without passing Pi's theme.
|
||||||
const ICON_YELLOW = "\x1b[38;2;214;181;94m";
|
* The widget remains useful in older hosts while themed hosts get the same
|
||||||
const ICON_RED = "\x1b[38;2;224;108;117m";
|
* semantic colors as the rest of Pi's UI.
|
||||||
const ICON_DIM = "\x1b[38;2;128;128;128m";
|
*/
|
||||||
|
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. */
|
const FALLBACK_STATUS_THEME: StatusWidgetTheme = {
|
||||||
function widgetIcon(kind: StatusSnapshot["kind"]): string {
|
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) {
|
switch (kind) {
|
||||||
case "active":
|
case "active":
|
||||||
case "running":
|
case "running":
|
||||||
return `${ICON_YELLOW}⟳${RST}`;
|
return "warning";
|
||||||
case "stalled":
|
case "stalled":
|
||||||
return `${ICON_RED}⟳${RST}`;
|
return "error";
|
||||||
case "waiting":
|
case "waiting":
|
||||||
|
return "accent";
|
||||||
case "starting":
|
case "starting":
|
||||||
default:
|
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.
|
* 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();
|
const statusConfig = loadStatusConfig();
|
||||||
|
|
||||||
function formatWidgetRightLabel(snapshot: StatusSnapshot): string {
|
function formatWidgetRightLabel(
|
||||||
if (snapshot.kind === "starting") return " starting… ";
|
snapshot: StatusSnapshot,
|
||||||
if (snapshot.kind === "running") return ` running ${snapshot.elapsedText} `;
|
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") {
|
if (snapshot.kind === "active") {
|
||||||
const label = snapshot.activityLabel ?? snapshot.activeScope;
|
const label = snapshot.activityLabel ?? snapshot.activeScope;
|
||||||
const duration = snapshot.activeDurationText ? ` ${snapshot.activeDurationText}` : "";
|
const duration = snapshot.activeDurationText
|
||||||
return label ? ` active · ${label}${duration} ` : " active ";
|
? ` ${elapsed(snapshot.activeDurationText)}`
|
||||||
|
: "";
|
||||||
|
return label
|
||||||
|
? status(" active · ") + status(label) + duration + status(" ")
|
||||||
|
: status(" active ") + duration;
|
||||||
}
|
}
|
||||||
if (snapshot.kind === "waiting") {
|
if (snapshot.kind === "waiting") {
|
||||||
const duration = snapshot.waitingDurationText ? ` ${snapshot.waitingDurationText}` : "";
|
const duration = snapshot.waitingDurationText
|
||||||
const detail = snapshot.statusLabel ? ` · ${snapshot.statusLabel}` : "";
|
? ` ${elapsed(snapshot.waitingDurationText)}`
|
||||||
return ` waiting${duration}${detail} `;
|
: "";
|
||||||
|
const detail = snapshot.statusLabel ? status(` · ${snapshot.statusLabel}`) : "";
|
||||||
|
return status(" waiting") + duration + detail + status(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
const detail = snapshot.statusLabel ? ` · ${snapshot.statusLabel}` : "";
|
const detail = snapshot.statusLabel ? status(` · ${snapshot.statusLabel}`) : "";
|
||||||
const duration = snapshot.snapshotProblemText ? ` ${snapshot.snapshotProblemText}` : "";
|
const duration = snapshot.snapshotProblemText
|
||||||
return ` stalled${detail}${duration} `;
|
? status(` ${snapshot.snapshotProblemText}`)
|
||||||
|
: "";
|
||||||
|
return status(" stalled") + detail + duration + status(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveResultPresentation(
|
function resolveResultPresentation(
|
||||||
@@ -619,15 +699,48 @@ interface RunningSubagent {
|
|||||||
|
|
||||||
type PendingDelivery = (pi: ExtensionAPI) => void;
|
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 {
|
interface SharedSubagentRuntime {
|
||||||
runningSubagents: Map<string, RunningSubagent>;
|
runningSubagents: Map<string, RunningSubagent>;
|
||||||
latestCtx: ExtensionContext | null;
|
latestCtx: ExtensionContext | null;
|
||||||
latestPi: ExtensionAPI | null;
|
latestPi: ExtensionAPI | null;
|
||||||
widgetInterval: ReturnType<typeof setInterval> | null;
|
widgetInterval: ReturnType<typeof setInterval> | null;
|
||||||
statusInterval: ReturnType<typeof setInterval> | null;
|
statusInterval: ReturnType<typeof setInterval> | null;
|
||||||
|
standaloneWidgetSyncScheduled: boolean;
|
||||||
pendingDeliveries: PendingDelivery[];
|
pendingDeliveries: PendingDelivery[];
|
||||||
deliveryFlushScheduled: boolean;
|
deliveryFlushScheduled: boolean;
|
||||||
activeRuntimeToken: symbol | null;
|
activeRuntimeToken: symbol | null;
|
||||||
|
registeredRuntimeToken: symbol | null;
|
||||||
|
statusWidgetClaim: StatusWidgetClaim | null;
|
||||||
|
statusWidgetRenderer: StatusWidgetRenderer;
|
||||||
|
statusWidget?: StatusWidget;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSharedRuntime(): SharedSubagentRuntime {
|
function createSharedRuntime(): SharedSubagentRuntime {
|
||||||
@@ -637,15 +750,38 @@ function createSharedRuntime(): SharedSubagentRuntime {
|
|||||||
latestPi: null,
|
latestPi: null,
|
||||||
widgetInterval: null,
|
widgetInterval: null,
|
||||||
statusInterval: null,
|
statusInterval: null,
|
||||||
|
standaloneWidgetSyncScheduled: false,
|
||||||
pendingDeliveries: [],
|
pendingDeliveries: [],
|
||||||
deliveryFlushScheduled: false,
|
deliveryFlushScheduled: false,
|
||||||
activeRuntimeToken: null,
|
activeRuntimeToken: null,
|
||||||
|
registeredRuntimeToken: null,
|
||||||
|
statusWidgetClaim: null,
|
||||||
|
statusWidgetRenderer: () => [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const sharedRuntime = ((globalThis as any)[SHARED_RUNTIME_KEY] ??=
|
const sharedRuntime = ((globalThis as any)[SHARED_RUNTIME_KEY] ??=
|
||||||
createSharedRuntime()) as SharedSubagentRuntime;
|
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. */
|
/** All currently running subagents, preserved across /reload. */
|
||||||
const runningSubagents = sharedRuntime.runningSubagents;
|
const runningSubagents = sharedRuntime.runningSubagents;
|
||||||
|
|
||||||
@@ -684,15 +820,42 @@ function schedulePendingDeliveryFlush(): void {
|
|||||||
setImmediate(flushPendingDeliveries);
|
setImmediate(flushPendingDeliveries);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearRefreshIntervals(): void {
|
type RefreshIntervalKind = "widget" | "status";
|
||||||
if (sharedRuntime.widgetInterval) {
|
|
||||||
clearInterval(sharedRuntime.widgetInterval);
|
function getRefreshInterval(kind: RefreshIntervalKind): ReturnType<typeof setInterval> | null {
|
||||||
sharedRuntime.widgetInterval = null;
|
return kind === "widget" ? sharedRuntime.widgetInterval : sharedRuntime.statusInterval;
|
||||||
}
|
}
|
||||||
if (sharedRuntime.statusInterval) {
|
|
||||||
clearInterval(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);
|
||||||
sharedRuntime.statusInterval = null;
|
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 {
|
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")}`;
|
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│
|
* Build an unboxed content line with a right-aligned status. The helper keeps
|
||||||
* Left content is truncated if needed, right is preserved, padded to fill width.
|
* 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 {
|
function borderLine(left: string, right: string, width: number): string {
|
||||||
if (width <= 0) return "";
|
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);
|
const rightVis = visibleWidth(right);
|
||||||
|
// If the status chunk alone is too wide, preserve it rather than overflowing
|
||||||
// If the status chunk alone is too wide, prefer preserving it in compact form
|
// the terminal. The left side is intentionally dropped in this case.
|
||||||
// rather than overflowing the terminal.
|
if (rightVis >= width) {
|
||||||
if (rightVis >= contentWidth) {
|
const truncRight = truncateToWidth(right, width);
|
||||||
const truncRight = truncateToWidth(right, contentWidth);
|
return `${truncRight}${" ".repeat(Math.max(0, width - visibleWidth(truncRight)))}`;
|
||||||
const rightPad = Math.max(0, contentWidth - visibleWidth(truncRight));
|
|
||||||
return `${ACCENT}│${RST}${truncRight}${" ".repeat(rightPad)}${ACCENT}│${RST}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxLeft = Math.max(0, contentWidth - rightVis);
|
const maxLeft = Math.max(0, width - rightVis);
|
||||||
const truncLeft = truncateToWidth(left, maxLeft);
|
const truncLeft = truncateToWidth(left, maxLeft);
|
||||||
const leftVis = visibleWidth(truncLeft);
|
const pad = Math.max(0, width - visibleWidth(truncLeft) - rightVis);
|
||||||
const pad = Math.max(0, contentWidth - leftVis - rightVis);
|
return `${truncLeft}${" ".repeat(pad)}${right}`;
|
||||||
return `${ACCENT}│${RST}${truncLeft}${" ".repeat(pad)}${right}${ACCENT}│${RST}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Build the compact, unboxed header row for the status panel. */
|
||||||
* Build the bordered top line: ╭─ Title ──── info ─╮
|
function renderWidgetHeader(count: number, width: number, theme: StatusWidgetTheme): string {
|
||||||
* All chars are accounted for within `width`.
|
|
||||||
*/
|
|
||||||
function borderTop(title: string, info: string, width: number): string {
|
|
||||||
if (width <= 0) return "";
|
if (width <= 0) return "";
|
||||||
if (width === 1) return `${ACCENT}╭${RST}`;
|
const title = theme.fg("accent", "Subagents");
|
||||||
|
const info = theme.fg("muted", `${count} running`);
|
||||||
// ╭─ Title ───...─── info ─╮
|
return truncateToWidth(`${title} ${info}`, width);
|
||||||
// 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}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function renderSubagentWidgetLines(
|
||||||
* Build the bordered bottom line: ╰──────────────────╯
|
agents: RunningSubagent[],
|
||||||
*/
|
width: number,
|
||||||
function borderBottom(width: number): string {
|
maxBodyRows?: number,
|
||||||
if (width <= 0) return "";
|
theme?: StatusWidgetTheme,
|
||||||
if (width === 1) return `${ACCENT}╰${RST}`;
|
): string[] {
|
||||||
|
|
||||||
const inner = Math.max(0, width - 2);
|
|
||||||
return `${ACCENT}╰${"─".repeat(inner)}╯${RST}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSubagentWidgetLines(agents: RunningSubagent[], width: number): string[] {
|
|
||||||
const count = agents.length;
|
const count = agents.length;
|
||||||
const title = "Subagents";
|
const renderTheme = resolveStatusWidgetTheme(theme);
|
||||||
const info = `${count} running`;
|
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 lines: string[] = [renderWidgetHeader(count, width, renderTheme)];
|
||||||
const elapsed = formatElapsedMMSS(agent.startTime);
|
|
||||||
const agentTag = agent.agent ? ` (${agent.agent})` : "";
|
for (const agent of agents.slice(0, visibleAgentCount)) {
|
||||||
|
const elapsed = renderTheme.fg("dim", formatElapsedMMSS(agent.startTime));
|
||||||
const snapshot = classifyStatus(agent.statusState, Date.now());
|
const snapshot = classifyStatus(agent.statusState, Date.now());
|
||||||
const icon = widgetIcon(snapshot.kind);
|
const statusColor = statusWidgetColor(snapshot.kind);
|
||||||
const left = ` ${icon} ${elapsed} ${agent.name}${agentTag} `;
|
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
|
const right = statusConfig.enabled
|
||||||
? formatWidgetRightLabel(snapshot)
|
? formatWidgetRightLabel(snapshot, renderTheme)
|
||||||
: agent.cli === "claude"
|
: renderTheme.fg(
|
||||||
? " running… "
|
statusColor,
|
||||||
: " starting… ";
|
agent.cli === "claude" ? " running… " : " starting… ",
|
||||||
|
);
|
||||||
|
|
||||||
lines.push(borderLine(left, right, width));
|
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;
|
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;
|
const ctx = sharedRuntime.latestCtx;
|
||||||
if (!ctx?.hasUI) return;
|
if (!ctx?.hasUI) return;
|
||||||
|
|
||||||
if (runningSubagents.size === 0) {
|
try {
|
||||||
ctx.ui.setWidget("subagent-status", undefined);
|
|
||||||
if (sharedRuntime.widgetInterval) {
|
|
||||||
clearInterval(sharedRuntime.widgetInterval);
|
|
||||||
sharedRuntime.widgetInterval = null;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.ui.setWidget(
|
ctx.ui.setWidget(
|
||||||
"subagent-status",
|
"subagent-status",
|
||||||
(_tui: any, _theme: any) => {
|
(_tui: any, theme?: StatusWidgetTheme) => {
|
||||||
return {
|
return {
|
||||||
invalidate() {},
|
invalidate() {},
|
||||||
render(width: number) {
|
render(width: number) {
|
||||||
return renderSubagentWidgetLines(Array.from(runningSubagents.values()), width);
|
return renderSubagentWidgetLines(
|
||||||
|
Array.from(runningSubagents.values()),
|
||||||
|
width,
|
||||||
|
undefined,
|
||||||
|
theme,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
{ placement: "aboveEditor" },
|
{ 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() {
|
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 (runningSubagents.size === 0) {
|
||||||
if (sharedRuntime.statusInterval) {
|
clearRefreshInterval("status");
|
||||||
clearInterval(sharedRuntime.statusInterval);
|
|
||||||
sharedRuntime.statusInterval = null;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1150,6 +1459,7 @@ function startStatusRefresh() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
sharedRuntime.statusInterval = interval;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resuming a finished session is always autonomous: the relaunched agent runs
|
// Resuming a finished session is always autonomous: the relaunched agent runs
|
||||||
@@ -1165,6 +1475,8 @@ export const __test__ = {
|
|||||||
defaultShellReadyDelayMs,
|
defaultShellReadyDelayMs,
|
||||||
getShellReadyDelayMs,
|
getShellReadyDelayMs,
|
||||||
renderSubagentWidgetLines,
|
renderSubagentWidgetLines,
|
||||||
|
statusWidget,
|
||||||
|
updateWidget,
|
||||||
loadAgentDefaults,
|
loadAgentDefaults,
|
||||||
discoverAgentDefinitions,
|
discoverAgentDefinitions,
|
||||||
resolveEffectiveSessionMode,
|
resolveEffectiveSessionMode,
|
||||||
@@ -1197,11 +1509,18 @@ export const __test__ = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function startWidgetRefresh() {
|
function startWidgetRefresh() {
|
||||||
if (sharedRuntime.widgetInterval) return;
|
const runtimeToken = sharedRuntime.activeRuntimeToken;
|
||||||
|
if (!runtimeToken) return;
|
||||||
|
if (getRefreshInterval("widget")) return;
|
||||||
|
|
||||||
updateWidget(); // immediate first render
|
updateWidget(); // immediate first render
|
||||||
sharedRuntime.widgetInterval = setInterval(() => {
|
if (runningSubagents.size === 0) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (!ownsRefreshInterval("widget", interval, runtimeToken)) return;
|
||||||
updateWidget();
|
updateWidget();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
sharedRuntime.widgetInterval = interval;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1706,11 +2025,25 @@ async function watchSubagent(
|
|||||||
|
|
||||||
export default function subagentsExtension(pi: ExtensionAPI) {
|
export default function subagentsExtension(pi: ExtensionAPI) {
|
||||||
const runtimeToken = Symbol("pi-subagents/runtime");
|
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
|
// Bind only after Pi has installed the fresh runtime. During /reload the old
|
||||||
// session_shutdown handler clears these references first, so watcher results
|
// session_shutdown handler clears these references first, so watcher results
|
||||||
// cannot enqueue a turn through stale extension APIs.
|
// cannot enqueue a turn through stale extension APIs.
|
||||||
pi.on("session_start", (_event, ctx) => {
|
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.activeRuntimeToken = runtimeToken;
|
||||||
sharedRuntime.latestPi = pi;
|
sharedRuntime.latestPi = pi;
|
||||||
sharedRuntime.latestCtx = ctx;
|
sharedRuntime.latestCtx = ctx;
|
||||||
@@ -1729,6 +2062,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
// state already rebound to the replacement runtime.
|
// state already rebound to the replacement runtime.
|
||||||
if (sharedRuntime.activeRuntimeToken !== runtimeToken) return;
|
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.activeRuntimeToken = null;
|
||||||
sharedRuntime.latestPi = null;
|
sharedRuntime.latestPi = null;
|
||||||
sharedRuntime.latestCtx = 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) {
|
function restoreEnvVar(name: string, value: string | undefined) {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
delete process.env[name];
|
delete process.env[name];
|
||||||
@@ -1860,33 +1891,11 @@ describe("commands", () => {
|
|||||||
|
|
||||||
describe("reload lifecycle", () => {
|
describe("reload lifecycle", () => {
|
||||||
const testApi = (subagentsModule as any).__test__;
|
const testApi = (subagentsModule as any).__test__;
|
||||||
const shared = testApi.sharedRuntime as {
|
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
|
||||||
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 ctx = { hasUI: false, ui: { setWidget() {} } };
|
const ctx = { hasUI: false, ui: { setWidget() {} } };
|
||||||
|
|
||||||
function resetSharedRuntime() {
|
beforeEach(() => resetSharedRuntimeForTest(shared));
|
||||||
if (shared.widgetInterval) clearInterval(shared.widgetInterval);
|
after(() => resetSharedRuntimeForTest(shared));
|
||||||
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);
|
|
||||||
|
|
||||||
it("preserves a live subagent and delivers its queued result through the fresh runtime", async () => {
|
it("preserves a live subagent and delivers its queued result through the fresh runtime", async () => {
|
||||||
const oldRuntime = createMockExtensionApi();
|
const oldRuntime = createMockExtensionApi();
|
||||||
@@ -2623,6 +2632,71 @@ describe("subagent startup delay", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe("subagents widget rendering", () => {
|
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", () => {
|
it("keeps every rendered line within a very narrow width", () => {
|
||||||
const testApi = (subagentsModule as any).__test__;
|
const testApi = (subagentsModule as any).__test__;
|
||||||
assert.ok(testApi, "expected subagents test helpers to be exported");
|
assert.ok(testApi, "expected subagents test helpers to be exported");
|
||||||
@@ -2663,13 +2737,33 @@ describe("subagents widget rendering", () => {
|
|||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
lines.map((line: string) => visibleWidth(line)),
|
lines.map((line: string) => visibleWidth(line)),
|
||||||
[16, 16, 16, 16, 16],
|
[16, 16, 16, 16],
|
||||||
);
|
);
|
||||||
|
assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line)));
|
||||||
} finally {
|
} finally {
|
||||||
Date.now = originalNow;
|
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", () => {
|
it("truncates the right-hand status instead of overflowing when it alone is too wide", () => {
|
||||||
const testApi = (subagentsModule as any).__test__;
|
const testApi = (subagentsModule as any).__test__;
|
||||||
assert.ok(testApi, "expected subagents test helpers to be exported");
|
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", () => {
|
describe("subagent display helpers", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user