import { truncateToWidth } from "@earendil-works/pi-tui"; import type { CustomUiComponent, ExtensionContext, WidgetFactoryTui } from "./pi-api.js"; import { setDismissibleView } from "./command-ui.js"; import { renderOperatorBlock, renderOperatorBlockPlain, type OperatorBlock, type OperatorThemeLike, } from "./operator-ui.js"; /** * Widget placement policy (single source of truth — T-188 Q1). * * - Generic LIVE "work" surfaces render ABOVE the editor. * - The interactive fleet is the deliberate exception: it renders BELOW the * editor so its passive rows stay in place while `ctx.ui.custom()` temporarily * replaces only the editor to capture management keys (REQ-007). * - Passive VIEW surfaces (settings / help / catalog / list) render BELOW the * editor. * - Settled CHANGE / RUN / RESULT / WARN / ERROR surfaces render ABOVE the * editor. INPUT / SELECT are normally host-owned focus surfaces; their * passive fallback also renders above. * * Host support for `placement` is `pi-api.ts` `ctx.ui.setWidget(..., { placement })`. * Live surfaces must import LIVE_WORK_PLACEMENT rather than hard-coding the string, * so the rule stays defined in exactly one place. */ export const LIVE_WORK_PLACEMENT = "aboveEditor" as const; export const FLEET_MENU_PLACEMENT = "belowEditor" as const; export const SETTINGS_HELP_PLACEMENT = "belowEditor" as const; export function operatorBlockPlacement( block: Pick, ): "aboveEditor" | "belowEditor" { return block.type === "VIEW" ? SETTINGS_HELP_PLACEMENT : LIVE_WORK_PLACEMENT; } export interface SetOperatorWidgetOptions { placement?: "aboveEditor" | "belowEditor"; fallbackWidth?: number; } class StaticTextWidget implements CustomUiComponent { constructor(private readonly lines: string[]) {} render(width: number): string[] { return this.lines.map((line) => truncateToWidth(line, width)); } invalidate(): void { // Static text widget owns no cached state. } } class OperatorBlockWidget implements CustomUiComponent { constructor( private readonly tui: WidgetFactoryTui, private readonly block: OperatorBlock, private readonly theme?: OperatorThemeLike, ) { this.tui.requestRender(true); } render(width: number): string[] { const rows = this.tui.terminal?.rows ?? 24; const budget = Math.max(1, Math.min(rows - 6, 48)); return renderOperatorBlock(this.block, width, this.theme, { maxLines: budget }); } invalidate(): void { // The block is projected from source on every render; no styled output is cached. } dispose(): void { // Full redraw clears cells outside a replacement/shrunk frame. A normal // differential render can preserve stale glyphs in terminal scrollback. this.tui.requestRender(true); } } /** * Deliver a typed operator block through the host mode's supported widget path. * The adapter registers only passive VIEW dismissibility; it does not own * domain cleanup or create transcript/session messages. */ export function setOperatorWidget( ctx: ExtensionContext, key: string, block: OperatorBlock, options: SetOperatorWidgetOptions = {}, ): void { if (ctx.hasUI === false) return; const placement = { placement: options.placement ?? operatorBlockPlacement(block) }; if (ctx.mode === "tui") { ctx.ui.setWidget( key, (tui, theme) => new OperatorBlockWidget( tui as WidgetFactoryTui, block, operatorTheme(theme) ?? ctx.ui.theme, ), placement, ); setDismissibleView(ctx, key, block.type === "VIEW"); return; } ctx.ui.setWidget( key, renderOperatorBlockPlain(block, options.fallbackWidth ?? 80), placement, ); setDismissibleView(ctx, key, false); } function operatorTheme(value: unknown): OperatorThemeLike | undefined { return typeof value === "object" && value !== null ? value as OperatorThemeLike : undefined; } /** * Render fixed text through the factory-widget path (ctx.ui.setWidget(key, () => * component, ...)), which is EXEMPT from the host's hard 10-line cap on the * string[] setWidget path. Use this when a command's output is bounded by * domain data (e.g. a precondition list) rather than a small fixed constant — * the 10-line cap would otherwise silently hide real diagnostic content. */ export function setUncappedTextWidget( ctx: ExtensionContext, key: string, content: string, options?: { placement?: "aboveEditor" | "belowEditor" }, ): void { const lines = content.split(/\r?\n/); const component = new StaticTextWidget(lines); ctx.ui.setWidget(key, () => component, options ?? { placement: "belowEditor" }); }