import type { ExtensionAPI, ExtensionContext, Theme, } from "@earendil-works/pi-coding-agent"; import { type KeybindingsManager, type OverlayOptions, type TUI, type TuiMouseEvent, visibleWidth, } from "@earendil-works/pi-tui"; import { describe, expect, it, vi } from "vitest"; import { EphemletPanel, PanelHost, panelGeometry } from "../src/panel.ts"; import { EphemletStore } from "../src/store.ts"; import type { Json } from "../src/types.ts"; const theme = { fg: (_color: string, text: string) => text } as Theme; const keybindings = {} as KeybindingsManager; const api = {} as ExtensionAPI; function mouse( x: number, y: number, type: TuiMouseEvent["type"] = "click", ): TuiMouseEvent { return { x, y, screenX: x, screenY: y, width: 60, height: 30, type, button: "left", shift: false, alt: false, ctrl: false, }; } function fixture() { const store = new EphemletStore(() => {}); const terminal = { columns: 120, rows: 32 }; const tui = { terminal, requestRender() {} } as unknown as TUI; const ctx = { mode: "tui", hasUI: true } as ExtensionContext; const closed: string[] = []; const saved: string[] = []; let hidden = 0; const panel = new EphemletPanel( api, store, () => ctx, tui, theme, keybindings, { changed: () => panel.refresh(), close: (id) => { closed.push(id); store.delete(id); panel.refresh(); }, save: (id) => saved.push(id), hide: () => { hidden++; store.view(store.selected, false); }, }, ); const put = (source: string, state: Json = null) => { const record = store.put({ title: "Native UI", purpose: "Interaction", kind: "interactive", source, state, }); store.view(record.id, true); panel.refresh(); return record; }; return { store, terminal, tui, panel, put, closed, saved, hidden: () => hidden, }; } const counter = `export default context => ({ render: width => [String(context.state?.value ?? 0)], invalidate() {}, handleInput(data) { context.setState({ value: data }); }, handleMouse(event) { context.setState({ value: event.x + ":" + event.y }); return { handled: true }; }, dispose() {} });`; describe("ephemlet responsive overlay", () => { it("positions landscape right and portrait top, including small terminals", () => { expect(panelGeometry(120, 32)).toEqual({ anchor: "top-right", width: 54, height: 28, }); expect(panelGeometry(60, 50)).toEqual({ anchor: "top-left", width: 60, height: 24, }); expect(panelGeometry(2, 2)).toEqual({ anchor: "top-left", width: 2, height: 1, }); }); it("routes native keyboard and local mouse coordinates; fits resized content", async () => { const f = fixture(); const record = f.put(counter, { value: "initial" }); try { await vi.waitFor(() => expect(f.panel.render(54).join("\n")).toContain("initial"), ); f.panel.handleInput("x"); expect(f.store.get(record.id).state).toEqual({ value: "x" }); f.panel.handleMouse(mouse(3, 4)); expect(f.store.get(record.id).state).toEqual({ value: "2:1" }); f.terminal.columns = 30; f.terminal.rows = 50; const lines = f.panel.render(30); expect(lines.length).toBeLessThanOrEqual(24); expect(lines.every((line) => visibleWidth(line) <= 30)).toBe(true); } finally { f.panel.dispose(); } }); it("switches ephemlets by keyboard/mouse and retains durable state", async () => { const f = fixture(); const first = f.put(counter, { value: "first" }); const second = f.put(counter, { value: "second" }); try { await vi.waitFor(() => expect(f.panel.render(54).join("\n")).toContain("second"), ); f.panel.handleInput("\x1b[1;3D"); expect(f.store.selected).toBe(first.id); await vi.waitFor(() => expect(f.panel.render(54).join("\n")).toContain("first"), ); f.panel.handleMouse(mouse(22, 1)); expect(f.store.selected).toBe(second.id); f.panel.handleInput("\x1b"); expect(f.hidden()).toBe(1); expect(f.store.records.size).toBe(2); } finally { f.panel.dispose(); } }); it("chrome has independent Close/Save and keyboard focus", async () => { const f = fixture(); const record = f.put(counter, { value: "state" }); try { await vi.waitFor(() => expect(f.panel.render(54).join("\n")).toContain("state"), ); f.panel.handleInput("\x13"); expect(f.saved).toEqual([record.id]); expect(f.store.records.size).toBe(1); f.panel.handleMouse(mouse(10, 1)); expect(f.saved).toEqual([record.id, record.id]); f.panel.handleInput("\x1b[9;3u"); f.panel.handleInput("\r"); expect(f.closed).toEqual([record.id]); expect(f.store.records.size).toBe(0); } finally { f.panel.dispose(); } }); it("aborts and disposes native resources when source changes or closes", async () => { const f = fixture(); const record = f.put( 'export default c => ({render: () => ["mounted"], invalidate() {}, dispose() { c.pi.disposed = c.signal.aborted; }})', ); const scopedApi = api as unknown as { disposed?: boolean }; try { await vi.waitFor(() => expect(f.panel.render(54).join("\n")).toContain("mounted"), ); f.store.delete(record.id); f.panel.refresh(); expect(scopedApi.disposed).toBe(true); } finally { f.panel.dispose(); delete scopedApi.disposed; } }); it("retains last usable revision after factory or first-render failure", async () => { const f = fixture(); const first = f.put(counter, { value: "usable" }); try { await vi.waitFor(() => expect(f.panel.render(54).join("\n")).toContain("usable"), ); f.store.put({ ...first, expectedRevision: first.revision, source: 'export default () => { throw new Error("bad factory"); }', }); f.panel.refresh(); await vi.waitFor(() => expect(f.store.get(first.id).source).toBe(counter), ); expect(f.store.get(first.id)).toMatchObject({ status: "failed", state: { value: "usable" }, revision: 3, }); await vi.waitFor(() => expect(f.panel.render(54).join("\n")).toContain("usable"), ); } finally { f.panel.dispose(); } }); it("cleans failed widget registration and allows retry", () => { const f = fixture(); f.put(counter); const mounted: { render(width: number): string[] }[] = []; const hide = vi.fn(); const notify = vi.fn(); let calls = 0; f.tui.showOverlay = (component) => { mounted.push(component); return { hide, setHidden() {}, isHidden: () => false, focus() {}, unfocus() {}, isFocused: () => false, getBounds: () => undefined, }; }; const ctx = { mode: "tui", ui: { notify, setWidget: ( _key: string, factory: ((tui: TUI, theme: Theme) => unknown) | undefined, ) => { if (!factory) return; factory(f.tui, theme); if (++calls === 1) throw new Error("Widget registration failed"); }, }, } as unknown as ExtensionContext; const host = new PanelHost(api, f.store, () => ctx, { changed() {}, close() {}, save() {}, hide() {}, }); try { host.refresh(); expect(notify).toHaveBeenCalledWith( "Ephemlet panel: Error: Widget registration failed", "error", ); expect(hide).toHaveBeenCalledTimes(1); expect(mounted[0]?.render(54)).toEqual([]); host.refresh(); expect(calls).toBe(2); expect(mounted[1]?.render(54).length).toBeGreaterThan(0); host.dispose(); expect(mounted[1]?.render(54)).toEqual([]); expect(hide).toHaveBeenCalledTimes(2); } finally { host.dispose(); f.panel.dispose(); } }); it("hides through overlay handle rather than deleting/reloading", async () => { const f = fixture(); f.put(counter); let calls = 0; let isHidden = false; let options: OverlayOptions | undefined; let widget: { dispose?(): void } | undefined; f.tui.showOverlay = (_component, nextOptions) => { options = nextOptions; return { setHidden(value: boolean) { isHidden = value; }, isHidden: () => isHidden, focus() {}, unfocus() {}, isFocused: () => false, getBounds: () => undefined, hide() {}, }; }; const ctx = { mode: "tui", ui: { setWidget: ( _key: string, factory: | ((tui: TUI, theme: Theme) => { dispose?(): void }) | undefined, ) => { widget?.dispose?.(); widget = undefined; if (factory) { calls++; widget = factory(f.tui, theme); } }, }, } as unknown as ExtensionContext; const host = new PanelHost(api, f.store, () => ctx, { changed() {}, close() {}, save() {}, hide() {}, }); try { host.refresh(); f.store.view(f.store.selected, false); host.refresh(); expect(isHidden).toBe(true); f.store.view(f.store.selected, true); host.refresh(true); expect(isHidden).toBe(false); expect(calls).toBe(1); expect(options?.anchor).toBe("top-right"); f.terminal.columns = 40; f.terminal.rows = 50; expect(options?.anchor).toBe("top-left"); expect(options?.width).toBe(40); } finally { host.dispose(); f.panel.dispose(); } }); });