import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme, type Theme, } from "@earendil-works/pi-coding-agent"; import { getKeybindings, type KeybindingsManager, Markdown, matchesKey, type OverlayHandle, type OverlayOptions, type TUI, type TuiMouseEvent, type TuiMouseEventResult, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui"; import { loadFactory } from "./loader.ts"; import { cloneState, type EphemletStore } from "./store.ts"; import type { EphemletComponent, MountContext } from "./types.ts"; export function panelGeometry( columns: number, rows: number, ): { width: number; height: number; anchor: "top-right" | "top-left" } { // Terminal cells are approximately twice as tall as wide. const landscape = columns >= rows * 2; return { width: Math.max( 1, Math.min( columns, landscape ? Math.max(32, Math.floor(columns * 0.45)) : columns, ), ), height: Math.max( 1, Math.min(rows, landscape ? rows - 4 : Math.floor(rows * 0.48)), ), anchor: landscape ? "top-right" : "top-left", }; } function validateLines(lines: string[]): string[] { if ( !Array.isArray(lines) || lines.length > 8192 || lines.some((line) => typeof line !== "string") ) throw new Error("Component render must return at most 8192 string lines"); return lines; } type MouseBounds = { originX: number; originY: number; width: number; height: number; }; type Instance = { revision: number; component: EphemletComponent; controller: AbortController; }; type PanelActions = { changed(): void; close(id: string): void; save(id: string): void; hide(): void; }; export class EphemletPanel { private readonly instances = new Map(); private readonly loading = new Map< string, { revision: number; controller: AbortController } >(); private readonly failures = new Map< string, { revision: number; message: string } >(); private mouseGesture: | { id: string; revision: number; target: MouseBounds } | undefined; private suppressClick = false; private focusedState = false; private chrome: "content" | "close" | "save" = "content"; private scroll = 0; private contentHeight = 1; private contentWidth = 1; private totalLines = 0; private disposed = false; private closeRange: [number, number] = [0, 0]; private saveRange: [number, number] = [0, 0]; private viewRange: [number, number] = [0, 0]; constructor( private readonly pi: ExtensionAPI, private readonly store: EphemletStore, private readonly context: () => ExtensionContext, readonly tui: TUI, private readonly theme: Theme, private readonly keybindings: KeybindingsManager, private readonly actions: PanelActions, ) {} get focused(): boolean { return this.focusedState; } set focused(value: boolean) { this.focusedState = value; this.syncFocus(); } geometry(): ReturnType { return panelGeometry(this.tui.terminal.columns, this.tui.terminal.rows); } refresh(): void { if (this.disposed) return; for (const [id, instance] of this.instances) { if (this.store.records.get(id)?.revision !== instance.revision) this.remove(id); } for (const [id, failure] of this.failures) { if (this.store.records.get(id)?.revision !== failure.revision) this.failures.delete(id); } for (const [id, pending] of this.loading) { if (this.store.records.get(id)?.revision !== pending.revision) { pending.controller.abort(); this.loading.delete(id); } } const geometry = this.geometry(); this.contentWidth = Math.max(1, geometry.width - 2); this.contentHeight = Math.max(1, geometry.height - 5); const id = this.store.selected; const record = id ? this.store.records.get(id) : undefined; if ( record?.source && !this.instances.has(record.id) && !this.loading.has(record.id) && this.failures.get(record.id)?.revision !== record.revision ) { const controller = new AbortController(); const revision = record.revision; this.loading.set(record.id, { revision, controller }); void (async () => { let component: EphemletComponent | undefined; try { if (record.kind === "markdown") component = new Markdown(record.source, 0, 0, getMarkdownTheme()); else { const factory = await loadFactory(record.source); if (controller.signal.aborted) return; const host = this; const mount: MountContext = { pi: this.pi, get ctx() { return host.context(); }, tui: this.tui, theme: this.theme, keybindings: this.keybindings, get state() { return cloneState(host.store.get(record.id).state); }, setState(state) { controller.signal.throwIfAborted(); host.store.setState(record.id, revision, state); host.instances.get(record.id)?.component.invalidate(); host.tui.requestRender(); }, viewport: () => ({ width: this.contentWidth, height: this.contentHeight, }), signal: controller.signal, }; component = await factory(mount); } if ( !component || typeof component.render !== "function" || typeof component.invalidate !== "function" ) throw new Error("Factory must return a native Pi Component"); if ( controller.signal.aborted || this.disposed || this.store.records.get(record.id)?.revision !== revision ) { this.disposeComponent(component); return; } // Prove the first render before accepting a replacement revision. validateLines(component.render(this.contentWidth)); this.instances.set(record.id, { revision, component, controller }); this.store.accept(record.id, revision); this.syncFocus(); } catch (error) { controller.abort(); if (component) this.disposeComponent(component); if ( !this.disposed && this.store.records.get(record.id)?.revision === revision ) { this.failures.set(record.id, { revision, message: String(error) }); this.store.reject(record.id, revision, String(error)); this.actions.changed(); } } finally { if (this.loading.get(record.id)?.controller === controller) this.loading.delete(record.id); if (!this.disposed) this.tui.requestRender(); } })(); } this.invalidate(); this.syncFocus(); this.tui.requestRender(); } private syncFocus(): void { for (const [id, instance] of this.instances) { try { instance.component.focused = this.focusedState && this.chrome === "content" && id === this.store.selected; } catch { this.fail(id, "Focus handler failed"); } } } private disposeComponent(component: EphemletComponent): void { try { component.dispose?.(); } catch { /* Cleanup failures must not break Pi. */ } } private remove(id: string): void { const instance = this.instances.get(id); if (instance) { instance.controller.abort(); this.disposeComponent(instance.component); this.instances.delete(id); } } private fail(id: string, message: string): void { const revision = this.store.records.get(id)?.revision; this.remove(id); if (revision !== undefined) { this.failures.set(id, { revision, message }); this.store.reject(id, revision, message); } } switch(delta: number): void { const ids = [...this.store.records.keys()]; if (!ids.length) return; const current = this.store.selected ? ids.indexOf(this.store.selected) : 0; this.store.view( ids[(current + delta + ids.length) % ids.length] ?? null, true, ); this.scroll = 0; this.chrome = "content"; this.actions.changed(); } handleInput(data: string): void { const id = this.store.selected; if (matchesKey(data, "escape")) { this.actions.hide(); return; } if (matchesKey(data, "alt+left")) { this.switch(-1); return; } if (matchesKey(data, "alt+right")) { this.switch(1); return; } if (id && matchesKey(data, "ctrl+s")) { this.actions.save(id); return; } if (id && matchesKey(data, "ctrl+w")) { this.actions.close(id); return; } if (matchesKey(data, "alt+tab")) { this.chrome = this.chrome === "content" ? "close" : this.chrome === "close" ? "save" : "content"; this.syncFocus(); this.tui.requestRender(); return; } if (this.chrome !== "content" && matchesKey(data, "enter") && id) { if (this.chrome === "close") this.actions.close(id); else this.actions.save(id); return; } if (this.chrome !== "content") return; if ( matchesKey(data, "shift+pageUp") || matchesKey(data, "shift+pageDown") ) { this.scrollBy( matchesKey(data, "shift+pageUp") ? -this.contentHeight : this.contentHeight, ); return; } const child = id ? this.instances.get(id)?.component : undefined; try { if (child?.handleInput) child.handleInput(data); else if (matchesKey(data, "up") || matchesKey(data, "pageUp")) this.scrollBy(matchesKey(data, "up") ? -1 : -this.contentHeight); else if (matchesKey(data, "down") || matchesKey(data, "pageDown")) this.scrollBy(matchesKey(data, "down") ? 1 : this.contentHeight); } catch (error) { if (id) this.fail(id, String(error)); } this.tui.requestRender(); } private scrollBy(delta: number): void { this.scroll = Math.max( 0, Math.min( Math.max(0, this.totalLines - this.contentHeight), this.scroll + delta, ), ); this.tui.requestRender(); } private forwardMouse( id: string, event: TuiMouseEvent, target?: MouseBounds, ): TuiMouseEventResult | undefined { const instance = this.instances.get(id); if (!instance) return undefined; const local = target ? { ...event, x: event.screenX - target.originX, y: event.screenY - target.originY, width: target.width, height: target.height, } : { ...event, x: event.x - 1, y: event.y - 3 + this.scroll, width: this.contentWidth, height: this.contentHeight, }; const result = instance.component.handleMouse?.(local); if (event.type === "release") this.mouseGesture = undefined; if (!result) return undefined; if (event.type === "press") this.mouseGesture = { id, revision: instance.revision, target: { originX: local.screenX - local.x, originY: local.screenY - local.y, width: local.width, height: local.height, }, }; // Keep capture on the host so disposed/replaced children never receive late events. return { handled: result.handled ?? true, capture: result.capture ?? false, focus: true, ...(result.render === undefined ? {} : { render: result.render }), }; } handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined { if (this.disposed) return { handled: true }; if (event.type === "press") { this.mouseGesture = undefined; this.suppressClick = false; } if (event.type === "click" && this.suppressClick) { this.suppressClick = false; return { handled: true }; } const gesture = this.mouseGesture; if (gesture) { if ( this.store.selected !== gesture.id || this.instances.get(gesture.id)?.revision !== gesture.revision ) { if (event.type === "release") { this.mouseGesture = undefined; this.suppressClick = true; } return { handled: true }; } try { return ( this.forwardMouse(gesture.id, event, gesture.target) ?? { handled: true, } ); } catch (error) { this.mouseGesture = undefined; this.fail(gesture.id, String(error)); return { handled: true, render: true }; } } if (event.y === 1 && event.type === "click" && event.button === "left") { const id = this.store.selected; if (id && event.x >= this.closeRange[0] && event.x < this.closeRange[1]) this.actions.close(id); else if ( id && event.x >= this.saveRange[0] && event.x < this.saveRange[1] ) this.actions.save(id); else if (event.x >= this.viewRange[0] && event.x < this.viewRange[1]) this.switch( event.x < (this.viewRange[0] + this.viewRange[1]) / 2 ? -1 : 1, ); return { handled: true, focus: true, render: true }; } if ( event.y >= 3 && event.y < 3 + this.contentHeight && event.x > 0 && event.x <= this.contentWidth ) { this.chrome = "content"; this.syncFocus(); const id = this.store.selected; try { const result = id ? this.forwardMouse(id, event) : undefined; if (result?.handled || result?.capture || result?.focus) return { ...result, focus: true }; } catch (error) { if (id) this.fail(id, String(error)); } if (event.type === "wheel") this.scrollBy(event.wheelDelta ?? 0); return { handled: true, focus: true, render: true }; } return { handled: true, focus: true, render: false }; } render(width: number): string[] { if (this.disposed) return []; const geometry = this.geometry(); const height = geometry.height; if (width < 4 || height < 6) return [truncateToWidth("Ephemlets: enlarge terminal", width, "")]; this.contentWidth = width - 2; this.contentHeight = height - 5; const id = this.store.selected; const record = id ? this.store.records.get(id) : undefined; const child = id ? this.instances.get(id)?.component : undefined; let body: string[]; try { body = child ? validateLines(child.render(this.contentWidth)) : [record?.status === "building" ? "Building…" : "Loading…"]; } catch (error) { if (id) this.fail(id, String(error)); body = []; } const failure = id ? this.failures.get(id) : undefined; if (failure && failure.revision === record?.revision) body = [ "Ephemlet failed:", ...new Markdown(failure.message, 0, 0, getMarkdownTheme()).render( this.contentWidth, ), ]; if (record?.error) body = [record.error, ...body]; this.totalLines = body.length; this.scroll = Math.max( 0, Math.min(this.scroll, Math.max(0, body.length - this.contentHeight)), ); const border = (text: string) => this.theme.fg("border", text); const framed = (text: string) => { const content = truncateToWidth(text, this.contentWidth, ""); return `${border("│")}${content}${" ".repeat(Math.max(0, this.contentWidth - visibleWidth(content)))}${border("│")}`; }; const close = this.chrome === "close" ? this.theme.fg("accent", "[Close]") : "[Close]"; const save = this.chrome === "save" ? this.theme.fg("accent", "[Save]") : "[Save]"; const controls = `${close} ${save}`; this.closeRange = [1, 8]; this.saveRange = [9, 15]; const navigation = ` ‹ ${Math.max(1, [...this.store.records.keys()].indexOf(id ?? "") + 1)}/${this.store.records.size} ›`; this.viewRange = [16, 16 + visibleWidth(navigation)]; const title = truncateToWidth( ` ${record?.title ?? "Ephemlets"} `, width - 2, "", ); const lines = [ border( `┌${title}${"─".repeat(Math.max(0, width - 2 - visibleWidth(title)))}┐`, ), framed(`${controls}${navigation}`), border(`├${"─".repeat(width - 2)}┤`), ]; for (let i = 0; i < this.contentHeight; i++) lines.push(framed(body[this.scroll + i] ?? "")); lines.push( framed( this.theme.fg("dim", "esc hide · alt+←/→ switch · alt+tab chrome"), ), border(`└${"─".repeat(width - 2)}┘`), ); return lines; } invalidate(): void { for (const [id, instance] of this.instances) { try { instance.component.invalidate(); } catch (error) { this.fail(id, String(error)); } } } dispose(): void { if (this.disposed) return; this.disposed = true; this.mouseGesture = undefined; for (const pending of this.loading.values()) pending.controller.abort(); this.loading.clear(); for (const id of this.instances.keys()) this.remove(id); } } export class PanelHost { private panel: EphemletPanel | undefined; private handle: OverlayHandle | undefined; private ui: ExtensionContext["ui"] | undefined; private registered = false; constructor( private readonly pi: ExtensionAPI, private readonly store: EphemletStore, private readonly context: () => ExtensionContext, private readonly actions: PanelActions, ) {} refresh(focus = false): void { const ctx = this.context(); if (ctx.mode !== "tui") return; if (this.panel) { this.handle?.setHidden(!this.store.visible); this.panel.refresh(); if (focus && this.store.visible) this.handle?.focus(); return; } if (!this.store.visible || this.registered) return; this.registered = true; this.ui = ctx.ui; // Zero-line widget obtains native TUI/theme and owns our overlay's lifetime. // Unlike custom(), closing this overlay never pops another extension's modal. try { ctx.ui.setWidget( "ephemlets-host", (tui, theme) => { const panel = new EphemletPanel( this.pi, this.store, this.context, tui, theme, getKeybindings(), this.actions, ); this.panel = panel; const options: OverlayOptions = { get width() { return panel.geometry().width; }, get maxHeight() { return panel.geometry().height; }, get anchor() { return panel.geometry().anchor; }, nonCapturing: true, }; const handle = tui.showOverlay(panel, options); this.handle = handle; panel.refresh(); if (focus) handle.focus(); return { render: () => [], invalidate: () => panel.invalidate(), dispose: () => { handle.hide(); panel.dispose(); if (this.panel === panel) { this.panel = undefined; this.handle = undefined; this.registered = false; } }, }; }, { placement: "belowEditor" }, ); } catch (error) { this.clearPanel(); this.registered = false; ctx.ui.notify(`Ephemlet panel: ${String(error)}`, "error"); } } focus(): void { this.refresh(true); } unfocus(): void { this.handle?.unfocus(); } private clearPanel(): void { this.handle?.hide(); this.panel?.dispose(); this.panel = undefined; this.handle = undefined; } dispose(): void { this.clearPanel(); if (this.registered) this.ui?.setWidget("ephemlets-host", undefined); this.registered = false; this.ui = undefined; } }