Expose theme-aware status widget integration
This commit is contained in:
+456
-115
@@ -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);
|
||||
sharedRuntime.statusInterval = null;
|
||||
|
||||
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;
|
||||
try {
|
||||
ctx.ui.setWidget(
|
||||
"subagent-status",
|
||||
(_tui: any, theme?: StatusWidgetTheme) => {
|
||||
return {
|
||||
invalidate() {},
|
||||
render(width: number) {
|
||||
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;
|
||||
}
|
||||
|
||||
ctx.ui.setWidget(
|
||||
"subagent-status",
|
||||
(_tui: any, _theme: any) => {
|
||||
return {
|
||||
invalidate() {},
|
||||
render(width: number) {
|
||||
return renderSubagentWidgetLines(Array.from(runningSubagents.values()), width);
|
||||
},
|
||||
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);
|
||||
},
|
||||
{ placement: "aboveEditor" },
|
||||
);
|
||||
};
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user