470 lines
16 KiB
TypeScript
470 lines
16 KiB
TypeScript
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
import type { TaskState } from "./state.ts";
|
|
import { renderTodoPanel } from "./ui.ts";
|
|
|
|
const WIDGET_ID = "pi-todo-herdr";
|
|
const COMBINED_WIDGET_BREAKPOINT = 120;
|
|
const COMBINED_BODY_ROWS = 10;
|
|
export const TODO_ANIMATION_INTERVAL_MS = 500;
|
|
|
|
interface StatusWidgetTheme {
|
|
fg(color: string, text: string): string;
|
|
}
|
|
|
|
interface StatusWidget {
|
|
readonly source: object;
|
|
readonly hasContent: () => boolean;
|
|
readonly getBodyRowCount: () => number;
|
|
readonly render: (width: number, maxBodyRows?: number, theme?: StatusWidgetTheme) => string[];
|
|
readonly claim: (onChange: () => void) => () => void;
|
|
readonly isClaimed: (onChange: () => void) => boolean;
|
|
}
|
|
|
|
interface InteractiveSubagentsGlobal {
|
|
statusWidget?: unknown;
|
|
}
|
|
|
|
class OptionalIntegrationError extends Error {
|
|
constructor(message: string, cause?: unknown) {
|
|
super(message, { cause });
|
|
this.name = "OptionalIntegrationError";
|
|
}
|
|
}
|
|
|
|
class TuiRenderFailure extends Error {
|
|
readonly original: unknown;
|
|
|
|
constructor(original: unknown) {
|
|
super("TUI render failed", { cause: original });
|
|
this.name = "TuiRenderFailure";
|
|
this.original = original;
|
|
}
|
|
}
|
|
|
|
function optionalIntegrationFailure(message: string, cause?: unknown): OptionalIntegrationError {
|
|
return new OptionalIntegrationError(message, cause);
|
|
}
|
|
|
|
function callOptionalIntegration<T>(message: string, operation: () => T): T {
|
|
try {
|
|
return operation();
|
|
} catch (error) {
|
|
if (error instanceof TuiRenderFailure) throw error.original;
|
|
if (error instanceof OptionalIntegrationError) throw error;
|
|
throw optionalIntegrationFailure(message, error);
|
|
}
|
|
}
|
|
|
|
interface ActiveClaim {
|
|
owner: object;
|
|
source: object;
|
|
onChange: () => void;
|
|
release: () => void;
|
|
generation: number;
|
|
}
|
|
|
|
// Symbols make the claim and generation survive extension reloads. A newly
|
|
// loaded module can invalidate a stale component before installing its own.
|
|
const ACTIVE_CLAIM_KEY = Symbol.for("pi-todo-herdr/active-subagents-claim");
|
|
const WIDGET_GENERATION_KEY = Symbol.for("pi-todo-herdr/widget-generation");
|
|
const ACTIVE_RUNTIME_KEY = Symbol.for("pi-todo-herdr/active-runtime");
|
|
const RUNTIME_TOKEN = Symbol("pi-todo-herdr-runtime");
|
|
const previousClaim = Reflect.get(globalThis, ACTIVE_CLAIM_KEY);
|
|
Reflect.set(globalThis, ACTIVE_RUNTIME_KEY, RUNTIME_TOKEN);
|
|
if (previousClaim !== null && typeof previousClaim === "object") {
|
|
const release = (previousClaim as { release?: unknown }).release;
|
|
Reflect.deleteProperty(globalThis, ACTIVE_CLAIM_KEY);
|
|
if (typeof release === "function") {
|
|
try {
|
|
release.call(previousClaim);
|
|
} catch {
|
|
// A stale claim must not prevent the new extension runtime loading.
|
|
}
|
|
}
|
|
}
|
|
|
|
function isCurrentRuntime(): boolean {
|
|
return Reflect.get(globalThis, ACTIVE_RUNTIME_KEY) === RUNTIME_TOKEN;
|
|
}
|
|
|
|
function getActiveClaim(): ActiveClaim | undefined {
|
|
const claim = Reflect.get(globalThis, ACTIVE_CLAIM_KEY);
|
|
return claim !== null && typeof claim === "object" ? (claim as ActiveClaim) : undefined;
|
|
}
|
|
|
|
function setActiveClaim(claim: ActiveClaim | undefined): void {
|
|
if (claim === undefined) Reflect.deleteProperty(globalThis, ACTIVE_CLAIM_KEY);
|
|
else Reflect.set(globalThis, ACTIVE_CLAIM_KEY, claim);
|
|
}
|
|
|
|
function claimBelongsTo(claim: ActiveClaim | undefined, owner: object, generation: number): claim is ActiveClaim {
|
|
return claim?.owner === owner && claim.generation === generation;
|
|
}
|
|
|
|
function nextWidgetGeneration(): number {
|
|
const current = Reflect.get(globalThis, WIDGET_GENERATION_KEY);
|
|
const generation = typeof current === "number" && Number.isSafeInteger(current) ? current + 1 : 1;
|
|
Reflect.set(globalThis, WIDGET_GENERATION_KEY, generation);
|
|
return generation;
|
|
}
|
|
|
|
function currentWidgetGeneration(): number {
|
|
const generation = Reflect.get(globalThis, WIDGET_GENERATION_KEY);
|
|
return typeof generation === "number" && Number.isSafeInteger(generation) ? generation : 0;
|
|
}
|
|
|
|
function releaseActiveClaim(owner?: object, generation?: number): void {
|
|
const activeClaim = getActiveClaim();
|
|
if (
|
|
(owner !== undefined && activeClaim?.owner !== owner) ||
|
|
(generation !== undefined && activeClaim?.generation !== generation)
|
|
) return;
|
|
setActiveClaim(undefined);
|
|
if (activeClaim === undefined) return;
|
|
try {
|
|
activeClaim.release();
|
|
} catch {
|
|
// A broken optional integration must never prevent the Todo widget clearing.
|
|
}
|
|
}
|
|
|
|
function getStatusWidget(): StatusWidget | undefined {
|
|
try {
|
|
const globals = globalThis as typeof globalThis & {
|
|
__pi_interactive_subagents?: unknown;
|
|
};
|
|
const integration = globals.__pi_interactive_subagents;
|
|
if (integration === null || typeof integration !== "object") return undefined;
|
|
const statusWidget = (integration as InteractiveSubagentsGlobal).statusWidget;
|
|
if (statusWidget === null || typeof statusWidget !== "object") return undefined;
|
|
|
|
const candidate = statusWidget as Record<string, unknown>;
|
|
if (
|
|
typeof candidate.hasContent !== "function" ||
|
|
typeof candidate.getBodyRowCount !== "function" ||
|
|
typeof candidate.render !== "function" ||
|
|
typeof candidate.claim !== "function" ||
|
|
typeof candidate.isClaimed !== "function"
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
const source = statusWidget;
|
|
const call = <T>(methodName: string, args: unknown[] = []): T =>
|
|
callOptionalIntegration(`Subagents ${methodName} failed`, () => {
|
|
const method = candidate[methodName];
|
|
if (typeof method !== "function") {
|
|
throw optionalIntegrationFailure(`invalid Subagents ${methodName} method`);
|
|
}
|
|
return method.call(source, ...args) as T;
|
|
});
|
|
|
|
return {
|
|
source,
|
|
hasContent: () => {
|
|
const content = call<unknown>("hasContent");
|
|
if (typeof content !== "boolean") throw optionalIntegrationFailure("invalid Subagents content state");
|
|
return content;
|
|
},
|
|
getBodyRowCount: () => {
|
|
const count = call<unknown>("getBodyRowCount");
|
|
if (typeof count !== "number" || !Number.isFinite(count)) {
|
|
throw optionalIntegrationFailure("invalid Subagents body row count");
|
|
}
|
|
return count;
|
|
},
|
|
render: (width, maxBodyRows, theme) => {
|
|
const lines = call<unknown>("render", [width, maxBodyRows, theme]);
|
|
const hasAllEntries =
|
|
Array.isArray(lines) &&
|
|
lines.length > 0 &&
|
|
Array.from({ length: lines.length }, (_, index) => {
|
|
return Object.prototype.hasOwnProperty.call(lines, index) && typeof lines[index] === "string";
|
|
}).every(Boolean);
|
|
if (!hasAllEntries) throw optionalIntegrationFailure("invalid Subagents render output");
|
|
const validLines = lines as string[];
|
|
if (maxBodyRows !== undefined) {
|
|
const isBordered = validLines[0]?.includes("╭") && validLines.at(-1)?.includes("╰");
|
|
// Body rows exclude the provider's header. Legacy bordered output has
|
|
// top and bottom chrome; the current unboxed output has one header.
|
|
const headerAndChromeRows = isBordered ? 2 : 1;
|
|
const maxLines = maxBodyRows + headerAndChromeRows;
|
|
if (validLines.length > maxLines) {
|
|
throw optionalIntegrationFailure("Subagents render output exceeded its body limit");
|
|
}
|
|
}
|
|
return validLines;
|
|
},
|
|
claim: (onChange) => {
|
|
const release = call<unknown>("claim", [onChange]);
|
|
if (typeof release !== "function") throw optionalIntegrationFailure("invalid Subagents claim release");
|
|
return release as () => void;
|
|
},
|
|
isClaimed: (onChange) => {
|
|
const claimed = call<unknown>("isClaimed", [onChange]);
|
|
if (typeof claimed !== "boolean") throw optionalIntegrationFailure("invalid Subagents claim state");
|
|
return claimed;
|
|
},
|
|
};
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function claimStatusWidget(
|
|
statusWidget: StatusWidget,
|
|
owner: object,
|
|
generation: number,
|
|
tui: { requestRender(): void },
|
|
): boolean {
|
|
const activeClaim = getActiveClaim();
|
|
if (activeClaim?.owner !== undefined && activeClaim.owner !== owner) return false;
|
|
if (claimBelongsTo(activeClaim, owner, generation) && activeClaim.source === statusWidget.source) {
|
|
try {
|
|
if (statusWidget.isClaimed(activeClaim.onChange)) return true;
|
|
} catch (error) {
|
|
releaseActiveClaim(owner, generation);
|
|
if (error instanceof OptionalIntegrationError) return false;
|
|
throw error;
|
|
}
|
|
// The provider may have been reset by a reload or another lifecycle event.
|
|
// Drop the stale local handle before asking it for a fresh claim.
|
|
}
|
|
releaseActiveClaim(owner, generation);
|
|
|
|
try {
|
|
const onChange = () => {
|
|
if (!isCurrentRuntime()) {
|
|
releaseActiveClaim(owner, generation);
|
|
return;
|
|
}
|
|
const currentClaim = getActiveClaim();
|
|
if (!claimBelongsTo(currentClaim, owner, generation) || currentClaim.onChange !== onChange) return;
|
|
try {
|
|
tui.requestRender();
|
|
} catch (error) {
|
|
// Let the provider restore its standalone widget when the TUI is gone.
|
|
releaseActiveClaim(owner, generation);
|
|
throw new TuiRenderFailure(error);
|
|
}
|
|
};
|
|
const release = statusWidget.claim(onChange);
|
|
setActiveClaim({ owner, source: statusWidget.source, onChange, release, generation });
|
|
return true;
|
|
} catch (error) {
|
|
if (error instanceof OptionalIntegrationError) return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function normalizeWidth(width: number): number {
|
|
return Math.max(1, Math.floor(Number.isFinite(width) ? width : 1));
|
|
}
|
|
|
|
function fitLine(line: string, width: number): string {
|
|
const fittedWidth = normalizeWidth(width);
|
|
const fitted = truncateToWidth(line, fittedWidth);
|
|
return `${fitted}${" ".repeat(Math.max(0, fittedWidth - visibleWidth(fitted)))}`;
|
|
}
|
|
|
|
function renderStatusPanel(
|
|
statusWidget: StatusWidget,
|
|
width: number,
|
|
maxBodyRows: number,
|
|
theme: StatusWidgetTheme,
|
|
): string[] {
|
|
return statusWidget.render(width, maxBodyRows, theme).map((line) => fitLine(line, width));
|
|
}
|
|
|
|
function normalizePanelHeight(lines: string[], height: number, blankLine: string): string[] {
|
|
if (lines.length >= height) return lines;
|
|
const blanks = Array.from({ length: height - lines.length }, () => blankLine);
|
|
const bottom = lines.at(-1);
|
|
if (bottom?.includes("╰")) return [...lines.slice(0, -1), ...blanks, bottom];
|
|
return [...lines, ...blanks];
|
|
}
|
|
|
|
function renderSideBySide(
|
|
tasks: ReturnType<TaskState["list"]>,
|
|
theme: Theme,
|
|
width: number,
|
|
inProgressFrame: number,
|
|
statusWidget: StatusWidget,
|
|
): string[] {
|
|
const leftWidth = Math.floor((width - 1) / 2);
|
|
const rightWidth = width - 1 - leftWidth;
|
|
const todo = renderTodoPanel(tasks, theme, leftWidth, inProgressFrame, COMBINED_BODY_ROWS);
|
|
const subagents = renderStatusPanel(statusWidget, rightWidth, COMBINED_BODY_ROWS, theme);
|
|
const height = Math.max(todo.length, subagents.length);
|
|
const left = normalizePanelHeight(todo, height, " ".repeat(leftWidth)).map((line) => fitLine(line, leftWidth));
|
|
const right = normalizePanelHeight(subagents, height, " ".repeat(rightWidth));
|
|
return left.map((line, index) => `${line} ${right[index]}`);
|
|
}
|
|
|
|
function bodyRowsForTodo(
|
|
tasks: ReturnType<TaskState["list"]>,
|
|
theme: Theme,
|
|
width: number,
|
|
inProgressFrame: number,
|
|
): number {
|
|
return Math.max(1, renderTodoPanel(tasks, theme, width, inProgressFrame, COMBINED_BODY_ROWS).length - 1);
|
|
}
|
|
|
|
function bodyRowsForSubagents(statusWidget: StatusWidget): number {
|
|
return Math.max(1, Math.min(COMBINED_BODY_ROWS, Math.floor(statusWidget.getBodyRowCount())));
|
|
}
|
|
|
|
function distributeBodyRows(todoNeed: number, subagentsNeed: number): { todo: number; subagents: number } {
|
|
let todo = Math.min(todoNeed, Math.floor(COMBINED_BODY_ROWS / 2));
|
|
let subagents = Math.min(subagentsNeed, Math.floor(COMBINED_BODY_ROWS / 2));
|
|
let remaining = COMBINED_BODY_ROWS - todo - subagents;
|
|
|
|
const todoUnused = Math.max(0, todoNeed - todo);
|
|
const todoExtra = Math.min(remaining, todoUnused);
|
|
todo += todoExtra;
|
|
remaining -= todoExtra;
|
|
|
|
const subagentsUnused = Math.max(0, subagentsNeed - subagents);
|
|
subagents += Math.min(remaining, subagentsUnused);
|
|
return { todo: Math.max(1, todo), subagents: Math.max(1, subagents) };
|
|
}
|
|
|
|
function renderStacked(
|
|
tasks: ReturnType<TaskState["list"]>,
|
|
theme: Theme,
|
|
width: number,
|
|
inProgressFrame: number,
|
|
statusWidget: StatusWidget,
|
|
subagentsNeed: number,
|
|
): string[] {
|
|
const todoNeed = bodyRowsForTodo(tasks, theme, width, inProgressFrame);
|
|
const rows = distributeBodyRows(todoNeed, subagentsNeed);
|
|
return [
|
|
...renderTodoPanel(tasks, theme, width, inProgressFrame, rows.todo),
|
|
"",
|
|
...renderStatusPanel(statusWidget, width, rows.subagents, theme),
|
|
];
|
|
}
|
|
|
|
function renderCombinedWidget(
|
|
tasks: ReturnType<TaskState["list"]>,
|
|
theme: Theme,
|
|
width: number,
|
|
inProgressFrame: number,
|
|
tui: { requestRender(): void },
|
|
owner: object,
|
|
generation: number,
|
|
composeSubagents: boolean,
|
|
): string[] {
|
|
if (!Number.isFinite(width) || width <= 0 || tasks.length === 0) {
|
|
releaseActiveClaim(owner, generation);
|
|
return [];
|
|
}
|
|
|
|
const fittedWidth = normalizeWidth(width);
|
|
const renderTodoFallback = (): string[] => {
|
|
releaseActiveClaim(owner, generation);
|
|
return renderTodoPanel(tasks, theme, fittedWidth, inProgressFrame);
|
|
};
|
|
if (!composeSubagents) return renderTodoFallback();
|
|
|
|
const statusWidget = getStatusWidget();
|
|
if (statusWidget === undefined) return renderTodoFallback();
|
|
|
|
try {
|
|
if (!statusWidget.hasContent()) return renderTodoFallback();
|
|
if (!claimStatusWidget(statusWidget, owner, generation, tui)) return renderTodoFallback();
|
|
|
|
const subagentsNeed = bodyRowsForSubagents(statusWidget);
|
|
return fittedWidth > COMBINED_WIDGET_BREAKPOINT
|
|
? renderSideBySide(tasks, theme, fittedWidth, inProgressFrame, statusWidget)
|
|
: renderStacked(tasks, theme, fittedWidth, inProgressFrame, statusWidget, subagentsNeed);
|
|
} catch (error) {
|
|
if (!(error instanceof OptionalIntegrationError)) {
|
|
releaseActiveClaim(owner, generation);
|
|
throw error;
|
|
}
|
|
return renderTodoFallback();
|
|
}
|
|
}
|
|
|
|
export function clearTaskWidget(ctx: ExtensionContext): void {
|
|
if (!isCurrentRuntime()) return;
|
|
nextWidgetGeneration();
|
|
releaseActiveClaim();
|
|
ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
}
|
|
|
|
export function updateTaskWidget(
|
|
state: TaskState,
|
|
ctx: ExtensionContext,
|
|
visible: boolean,
|
|
refreshMs = TODO_ANIMATION_INTERVAL_MS,
|
|
): void {
|
|
if (!isCurrentRuntime()) return;
|
|
const tasks = state.list();
|
|
if (!visible || tasks.length === 0) {
|
|
clearTaskWidget(ctx);
|
|
return;
|
|
}
|
|
|
|
const generation = nextWidgetGeneration();
|
|
// Subagents owns a native widget outside this extension. Only compose it
|
|
// while Pi is rendering the terminal UI, never in RPC/print modes.
|
|
const composeSubagents = ctx.mode === "tui";
|
|
|
|
// Replacing a widget can happen before Pi calls its old dispose hook. Release
|
|
// the optional Subagents claim eagerly so it cannot hide a standalone widget.
|
|
releaseActiveClaim();
|
|
const hasActiveTimer = tasks.some((task) => task.status === "in-progress" && task.startedAt !== undefined);
|
|
ctx.ui.setWidget(WIDGET_ID, (tui, theme) => {
|
|
let inProgressFrame = 0;
|
|
let disposed = false;
|
|
const owner = {};
|
|
const isActive = (): boolean =>
|
|
!disposed && isCurrentRuntime() && currentWidgetGeneration() === generation;
|
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
if (hasActiveTimer) {
|
|
timer = setInterval(() => {
|
|
if (!isActive()) {
|
|
clearInterval(timer);
|
|
return;
|
|
}
|
|
inProgressFrame += 1;
|
|
try {
|
|
tui.requestRender();
|
|
} catch {
|
|
disposed = true;
|
|
clearInterval(timer);
|
|
releaseActiveClaim(owner, generation);
|
|
}
|
|
}, Math.max(1, refreshMs));
|
|
}
|
|
timer?.unref();
|
|
|
|
return {
|
|
render(width: number): string[] {
|
|
if (!isActive()) return [];
|
|
const currentTasks = state.list();
|
|
return renderCombinedWidget(
|
|
currentTasks,
|
|
theme,
|
|
width,
|
|
inProgressFrame,
|
|
tui,
|
|
owner,
|
|
generation,
|
|
composeSubagents,
|
|
);
|
|
},
|
|
invalidate(): void {},
|
|
dispose(): void {
|
|
disposed = true;
|
|
releaseActiveClaim(owner, generation);
|
|
if (timer !== undefined) clearInterval(timer);
|
|
},
|
|
};
|
|
});
|
|
}
|