import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { visibleWidth, type Terminal } from "@earendil-works/pi-tui"; import { constants as fsConstants } from "node:fs"; import { access } from "node:fs/promises"; import { loadVideoTuiConfig, type VideoTuiConfig } from "./config.ts"; import { shouldDismissVideo } from "./input-controller.ts"; import { NativeClient, resolveNativeBinary } from "./native-client.ts"; import { VideoWidget } from "./video-widget.ts"; const VIDEO_WIDGET_KEY = "pi-video-tui"; const FIXED_LAYOUT_ROWS = 6; const MAX_EDITOR_PADDING_COLUMNS = 6; const SYNCHRONIZED_OUTPUT_MARKERS = /\x1b\[\?2026[hl]/g; const VIDEO_LINE_ERASE = /\x1b\[2K(?=[^\r\n]*\x1b\[38;2;[^\r\n]*▀)/g; interface ActivePlayback { close(): Promise; } export default function videoTuiExtension(pi: ExtensionAPI): void { let activePlayback: ActivePlayback | undefined; let shuttingDown = false; pi.on("session_start", async (event, ctx) => { if (shuttingDown || !shouldAutoplay(event.reason, ctx.mode, true)) return; let config: VideoTuiConfig; try { config = await loadVideoTuiConfig(ctx.cwd, ctx.isProjectTrusted()); } catch (error) { if (!shuttingDown) { ctx.ui.notify(`pi-video-tui config error: ${errorMessage(error)}`, "error"); } return; } if (shuttingDown || !shouldAutoplay(event.reason, ctx.mode, config.enabled)) return; if (config.videoPath === undefined) { ctx.ui.notify( "pi-video-tui: configure videoPath in ~/.pi/agent/pi-video-tui.json to enable playback", "info", ); return; } const videoPath = config.videoPath; const binaryPath = resolveNativeBinary(config.nativeBinary); try { await assertFile(videoPath, "video", fsConstants.R_OK); await assertFile( binaryPath, "native sidecar (run `npm run build:native` in pi-video-tui)", process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK, ); } catch (error) { if (!shuttingDown) ctx.ui.notify(`pi-video-tui: ${errorMessage(error)}`, "error"); return; } if (shuttingDown) return; try { ctx.ui.setWidget( VIDEO_WIDGET_KEY, (tui) => { let client: NativeClient; let widget: VideoWidget; let unsubscribeTerminalInput: () => void = () => {}; let restoreTerminalOutput: () => void = () => {}; let cleanupPromise: Promise | undefined; let playback: ActivePlayback; const close = (): Promise => { if (cleanupPromise) return cleanupPromise; cleanupPromise = (async () => { try { unsubscribeTerminalInput(); } finally { try { restoreTerminalOutput(); } finally { try { ctx.ui.setWidget(VIDEO_WIDGET_KEY, undefined); } finally { widget.dispose(); client.stop(); await client.dispose(); } } } })() .catch((error) => { ctx.ui.notify(`pi-video-tui cleanup failed: ${errorMessage(error)}`, "error"); }) .finally(() => { if (activePlayback === playback) activePlayback = undefined; }); return cleanupPromise; }; const availableRows = (width: number) => computeAvailableVideoRows( tui.terminal.rows, width, ctx.ui.getEditorText(), config.maxHeightPercent, ); client = new NativeClient( { binaryPath, videoPath, fps: config.fps, volume: config.volume, }, { onReady: () => tui.requestRender(), onFrame: () => tui.requestRender(), onEnd: () => void close(), onError: (error) => { ctx.ui.notify(`pi-video-tui playback failed: ${error.message}`, "error"); void close(); }, }, ); widget = new VideoWidget(client, config, () => void close(), availableRows); playback = { close }; activePlayback = playback; restoreTerminalOutput = installGhosttyOutputWorkaround(tui.terminal); unsubscribeTerminalInput = ctx.ui.onTerminalInput((data) => { if (!shouldDismissVideo(data)) return; void close(); return { consume: true }; }); return widget; }, { placement: "aboveEditor" }, ); } catch (error) { const playback = activePlayback; activePlayback = undefined; await playback?.close(); ctx.ui.notify(`pi-video-tui setup failed: ${errorMessage(error)}`, "error"); } }); pi.on("session_shutdown", async () => { shuttingDown = true; const playback = activePlayback; activePlayback = undefined; await playback?.close(); }); } export function shouldAutoplay(reason: string, mode: string, enabled: boolean): boolean { return enabled && reason === "startup" && mode === "tui"; } export function installGhosttyOutputWorkaround( terminal: Terminal, termProgram: string | undefined = process.env.TERM_PROGRAM, ): () => void { if (termProgram?.toLowerCase() !== "ghostty") return () => {}; // Ghostty 1.3.1 corrupts synchronized incremental SGR updates. Video rows // already overwrite the full terminal width, so their line erase is redundant. const originalWrite = terminal.write; const patchedWrite = (data: string) => { const withoutSync = data.replace(SYNCHRONIZED_OUTPUT_MARKERS, ""); originalWrite.call(terminal, withoutSync.replace(VIDEO_LINE_ERASE, "")); }; terminal.write = patchedWrite; return () => { if (terminal.write === patchedWrite) terminal.write = originalWrite; }; } export function computeAvailableVideoRows( terminalRows: number, width: number, editorText: string, maxHeightPercent: number, ): number { const rows = Math.max(1, Math.floor(terminalRows)); const editorWidth = Math.max(1, Math.floor(width) - MAX_EDITOR_PADDING_COLUMNS); const editorContentRows = editorText.split("\n").reduce((total, line) => { return total + Math.max(1, Math.ceil(visibleWidth(line) / editorWidth)); }, 0); const maxEditorRows = Math.max(5, Math.floor(rows * 0.3)); const visibleEditorRows = Math.min(editorContentRows, maxEditorRows); const layoutAvailableRows = Math.max(1, rows - FIXED_LAYOUT_ROWS - visibleEditorRows); return Math.max(1, Math.floor((layoutAvailableRows * maxHeightPercent) / 100)); } async function assertFile(path: string, description: string, mode: number): Promise { try { await access(path, mode); } catch { throw new Error(`${description} is not accessible: ${path}`); } } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); }