feat: package Pi extensions as Bun workspaces
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
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);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user