Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/ephemlets/src/panel.ts

Raw
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<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;
	}
}