import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { CustomEditor, getAgentDir, type ExtensionAPI, type ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { DEFAULT_CURSOR_CONFIG, defaultCursorConfig, isHexColor, parseCursorConfig, type CursorConfig } from "../src/config.ts"; import { hasFakeCursor, hideFakeCursor, replaceFakeCursor } from "../src/cursor.ts"; import { clearOwnedEditorComponent, installEditorComponent } from "../src/install.ts"; import { addInputPrompt } from "../src/prompt.ts"; const CONFIG_PATH = join(getAgentDir(), "editor-cursor.json"); type Paint = (text: string) => string; type EditorArguments = ConstructorParameters; class EditorCursor extends CustomEditor { private config: CursorConfig = defaultCursorConfig(); private paint: Paint = (text) => text; private visible = true; private working = false; private timer?: ReturnType; private disposed = false; configure(config: CursorConfig, paint: Paint): void { this.stopAnimation(); this.config = config; this.paint = paint; this.visible = true; this.working = false; if (config.blinkMs > 0) this.scheduleAnimation(); } probeCursor(): void { if (!hasFakeCursor(super.render(20))) throw new Error("Unsupported Pi editor cursor marker"); } setWorking(working: boolean): void { if (this.working === working) return; this.working = working; this.stopAnimation(); if (!working && this.config.blinkMs > 0) this.scheduleAnimation(); this.tui.requestRender(); } dispose(): void { this.disposed = true; this.stopAnimation(); } override render(width: number): string[] { const rawPrompt = truncateToWidth(this.config.prompt, Math.max(0, width - 1), ""); const promptWidth = visibleWidth(rawPrompt); const lines = super.render(Math.max(1, width - promptWidth)); const paint = this.working ? dimPaint(this.paint) : this.paint; const cursorLines = this.working ? lines.map((line) => replaceFakeCursor(line, paint(this.config.working.symbol))) : this.visible ? lines.map((line) => replaceFakeCursor(line, paint(this.config.symbol))) : lines.map(hideFakeCursor); return addInputPrompt( cursorLines, rawPrompt ? paint(rawPrompt) : "", promptWidth, this.borderColor("─".repeat(promptWidth)), ); } private stopAnimation(): void { if (this.timer) clearTimeout(this.timer); this.timer = undefined; } private scheduleAnimation(): void { this.timer = setTimeout(() => { if (this.disposed || this.working) return; this.visible = !this.visible; this.tui.requestRender(); this.scheduleAnimation(); }, this.config.blinkMs); } } async function loadConfig(): Promise { try { return parseCursorConfig(JSON.parse(await readFile(CONFIG_PATH, "utf8"))); } catch { return defaultCursorConfig(); } } export function cursorPaint(ctx: ExtensionContext, color: string): Paint { if (isHexColor(color)) { const red = Number.parseInt(color.slice(1, 3), 16); const green = Number.parseInt(color.slice(3, 5), 16); const blue = Number.parseInt(color.slice(5, 7), 16); return (text) => `\x1b[38;2;${red};${green};${blue}m${text}\x1b[39m`; } try { ctx.ui.theme.fg(color as never, ""); } catch { color = DEFAULT_CURSOR_CONFIG.color; } return (text) => ctx.ui.theme.fg(color as never, text); } function dimPaint(paint: Paint): Paint { return (text) => paint(`\x1b[2m${text}\x1b[22m`); } export default function editorCursorExtension(pi: ExtensionAPI): void { const editors = new Set(); let installedFactory: unknown; let warned = false; pi.on("session_start", async (_event, ctx) => { if (ctx.mode !== "tui") return; const config = await loadConfig(); const paint = cursorPaint(ctx, config.color); let candidate: EditorCursor | undefined; const factory = ( tui: EditorArguments[0], theme: EditorArguments[1], keybindings: EditorArguments[2], ) => { candidate = new EditorCursor(tui, theme, keybindings); candidate.configure(config, paint); candidate.probeCursor(); return candidate; }; const result = installEditorComponent(ctx.ui, factory); if (result === "installed" && candidate) { editors.add(candidate); installedFactory = factory; return; } candidate?.dispose(); if (result === "installed") clearOwnedEditorComponent(ctx.ui, factory); if (result !== "occupied" && !warned) { warned = true; if (typeof ctx.ui.notify === "function") { ctx.ui.notify("Editor cursor disabled: incompatible Pi editor API.", "warning"); } } }); pi.on("agent_start", () => { for (const editor of editors) editor.setWorking(true); }); pi.on("agent_settled", () => { for (const editor of editors) editor.setWorking(false); }); pi.on("session_shutdown", (_event, ctx) => { for (const editor of editors) editor.dispose(); editors.clear(); if (ctx.mode === "tui") clearOwnedEditorComponent(ctx.ui, installedFactory); installedFactory = undefined; }); }