repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/ephemlets/src/panel.ts
Rawimport {
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<string, Instance>();
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<typeof panelGeometry> {
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;
}
}