import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; /** * Host-compatibility shims for the `ExtensionContext["ui"]` surface. * * RETENTION POLICY (W4 2026-10-03): these typeof-based capability guards are * deliberate no-ops, NOT version guards, and must be kept. pi-crew declares * `"@earendil-works/pi-coding-agent": "*"` and `"@earendil-works/pi-tui": "*"` * as optional peerDependencies with no minimum-version floor * (package.json peerDependencies/peerDependenciesMeta), so host installs older * than any SDK floor we verify against must keep working — retiring these * shims would crash hosts whose UI predates * `setWidget`/`setFooter`/`custom`/`setStatus`/`setWorkingIndicator`. * Verified live: each guard is a pure feature check; there is no version * constant or gate in this file. New host UI APIs should be added here * (feature-detected), not called directly from src/. */ export interface WorkingIndicatorOptions { frames?: string[]; intervalMs?: number; } type UiContext = Pick; type ExtensionUi = ExtensionContext["ui"]; type WidgetContent = string[] | ((tui: unknown, theme: unknown) => unknown); type WidgetOptions = Parameters[2]; type CustomOptions = Parameters[1]; type CustomFactory = (tui: unknown, theme: unknown, keybindings: unknown, done: (result: T) => void) => unknown; type GenericCustom = (factory: CustomFactory, options?: CustomOptions) => Promise; function maybeRecord(value: unknown): Record | undefined { return value && typeof value === "object" ? (value as Record) : undefined; } export function requestRender(ctx: UiContext): void { requestRenderTarget(ctx.ui); } export function requestRenderTarget(target: unknown): void { const record = maybeRecord(target); const fn = record?.requestRender; if (typeof fn === "function") fn.call(target); } export function setWorkingIndicator(ctx: UiContext, options?: WorkingIndicatorOptions): void { const record = maybeRecord(ctx.ui); const fn = record?.setWorkingIndicator; if (typeof fn === "function") fn.call(ctx.ui, options); } /** Install or update an extension widget slot. * No-op when the host UI predates the `setWidget` API. */ export function setExtensionWidget(ctx: UiContext, key: string, content: WidgetContent | undefined, options?: WidgetOptions): void { const record = maybeRecord(ctx.ui); const fn = record?.setWidget; if (typeof fn === "function") fn.call(ctx.ui, key, content as never, options); } /** * Map the crew widget placement onto pi's `WidgetPlacement`. `"bottom"` is a * crew-only placement (rendered inside the crew-vibes footer, below the quota * lines); when a raw widget slot is involved it falls back to `belowEditor`. */ export function toPiWidgetPlacement(placement: "aboveEditor" | "belowEditor" | "bottom"): "aboveEditor" | "belowEditor" { return placement === "bottom" ? "belowEditor" : placement; } type FooterFactory = (tui: unknown, theme: unknown, footerData: unknown) => unknown; /** Install a custom footer component, or pass `undefined` to restore pi's built-in footer. * No-op when the host UI predates the `setFooter` API. */ export function setFooter(ctx: UiContext | undefined, factory: FooterFactory | undefined): void { if (!ctx) return; const record = maybeRecord(ctx.ui); const fn = record?.setFooter; if (typeof fn === "function") fn.call(ctx.ui, factory as never); } /** Show a custom focused component. * When the host UI predates (or mangles) the `custom` API, resolves `undefined` * instead of rejecting or hanging: the sole caller today * (src/extension/registration/ui.ts) already fire-and-forgets via * `void showCustom(...)`, so a rejected or never-settling promise would only * surface as an unhandled rejection. */ export function showCustom(ctx: UiContext, factory: CustomFactory, options?: CustomOptions): Promise { const record = maybeRecord(ctx.ui); const fn = record?.custom; if (typeof fn !== "function") return Promise.resolve(undefined as T); const custom = fn as unknown as GenericCustom; return custom(factory, options); } /** Set a status fallback line. * No-op when the host UI predates the `setStatus` API. */ export function setStatusFallback(ctx: UiContext, key: string, lines: string | readonly string[] | undefined, segment?: string): void { const text = typeof lines === "string" ? lines : lines ? [...lines].join("\n") : undefined; const record = maybeRecord(ctx.ui); const fn = record?.setStatus; if (typeof fn === "function") fn.call(ctx.ui, segment ? `${key}:${segment}` : key, text); } /** Minimal structural shape of a host theme entry (`ctx.ui.getAllThemes()` item). */ export interface HostThemeEntry { name: string; path?: string; } /** * Delegate theme discovery to the host `ctx.ui.getAllThemes()` (available * since ≤1.0.4 — `ExtensionUIContext`, types.d.ts:181). Returns `undefined` * — NOT `[]` — when the API is missing, the call throws, or the host returns * an empty/degenerate array: RPC-degraded hosts legitimately return `[]`, so * callers MUST treat both outcomes as "fall back to the filesystem scan" * (research round 2, B-4 cold-verify). Never throws. */ export function getAllThemes(ctx: { ui: unknown }): HostThemeEntry[] | undefined { const record = maybeRecord(ctx.ui); const fn = record?.getAllThemes; if (typeof fn !== "function") return undefined; try { const themes = fn.call(ctx.ui) as unknown; if (!Array.isArray(themes) || themes.length === 0) return undefined; const out: HostThemeEntry[] = []; for (const theme of themes) { const entry = maybeRecord(theme); if (typeof entry?.name !== "string") continue; // malformed entry — skip out.push({ name: entry.name, path: typeof entry.path === "string" ? entry.path : undefined }); } return out.length > 0 ? out : undefined; } catch { return undefined; // host call failed — RPC-degraded or older runtime } }