import { CustomEditor, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; const WIDGET_ID = "pi-glow-ui-widget"; const TIMER_MS = 80; const RESET = "\x1b[0m"; const RESET_FG = "\x1b[39m"; const ANSI_OR_CONTROL_RE = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][\s\S]*?(?:\x07|\x1b\\)|\x1b_[\s\S]*?\x1b\\|\x1b[PX^][\s\S]*?\x1b\\/g; let enabled = true; let running = false; let phase = 0; let timer: NodeJS.Timeout | undefined; let activeCtx: ExtensionContext | undefined; let requestRender: (() => void) | undefined; function hslToRgb(h: number, s: number, l: number): [number, number, number] { h = ((h % 360) + 360) % 360; s /= 100; l /= 100; const c = (1 - Math.abs(2 * l - 1)) * s; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const m = l - c / 2; let r = 0; let g = 0; let b = 0; if (h < 60) [r, g, b] = [c, x, 0]; else if (h < 120) [r, g, b] = [x, c, 0]; else if (h < 180) [r, g, b] = [0, c, x]; else if (h < 240) [r, g, b] = [0, x, c]; else if (h < 300) [r, g, b] = [x, 0, c]; else [r, g, b] = [c, 0, x]; return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]; } function fgRgb([r, g, b]: [number, number, number]): string { return `\x1b[38;2;${r};${g};${b}m`; } function hueForPosition(x: number, y = 0): number { // Slightly faster than the original phase * 3 border animation. return phase * 4 + x * 3.2 + y * 18; } function rainbowText(text: string, offset = 0): string { let out = ""; let x = 0; for (const ch of Array.from(text)) { const w = visibleWidth(ch); if (w === 0) { out += ch; continue; } // Use a faster hue shift than the long editor border so the short // "Working..." label visibly flows instead of looking like a static gradient. out += `${fgRgb(hslToRgb(phase * 14 + offset + x * 26, 98, 64))}${ch}${RESET_FG}`; x += w; } return `${out}${RESET}`; } function rainbowLine(line: string, width: number, y = 0): string { let out = ""; let x = 0; for (const ch of Array.from(line.replace(ANSI_OR_CONTROL_RE, ""))) { const w = visibleWidth(ch); if (w === 0) { out += ch; continue; } out += `${fgRgb(hslToRgb(hueForPosition(x, y), 96, 62))}${ch}${RESET_FG}`; x += w; } return `${truncateToWidth(out, width, "")}${RESET}`; } function isEditorBottomBorder(line: string, width: number): boolean { const plain = line.replace(ANSI_OR_CONTROL_RE, ""); if (visibleWidth(plain) !== width) return false; return /^─+$/.test(plain) || /^─── ↓ \d+ more ─*$/.test(plain); } function indicatorFrames(): string[] { // Same characters as Pi's default Loader, but colored with a flowing rainbow. return ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"].map((frame, i) => { return `${fgRgb(hslToRgb(phase * 3 + i * 36, 96, 62))}${frame}${RESET_FG}`; }); } function applyWorkingIndicator(ctx: ExtensionContext): void { if (enabled && running) { ctx.ui.setWorkingIndicator({ frames: indicatorFrames(), intervalMs: TIMER_MS }); ctx.ui.setWorkingMessage(rainbowText("Working...", 140)); } else { ctx.ui.setWorkingIndicator(); ctx.ui.setWorkingMessage(undefined); } } function applyWidget(ctx: ExtensionContext): void { // Border mode: no extra bar above the editor. ctx.ui.setWidget(WIDGET_ID, undefined); } function startTimer(): void { if (timer) return; timer = setInterval(() => { if (!enabled || !running) return; phase = (phase + 1) % 10_000; if (activeCtx) { // Update the message every tick; Loader's own spinner interval does not // recompute message colors for us. activeCtx.ui.setWorkingMessage(rainbowText("Working...", 140)); } requestRender?.(); }, TIMER_MS); } function stopTimer(): void { if (!timer) return; clearInterval(timer); timer = undefined; } function refresh(ctx = activeCtx): void { if (!ctx) return; applyWorkingIndicator(ctx); applyWidget(ctx); if (enabled && running) startTimer(); else stopTimer(); requestRender?.(); } class GlowEditor extends CustomEditor { render(width: number): string[] { const lines = super.render(width); if (!enabled || !running || lines.length < 2) return lines; // Autocomplete suggestions are appended after the editor's real bottom border, // so lines.length - 1 is not necessarily the border. Locate it by structure. const bottomBorderIndex = lines.findIndex((line, index) => index > 0 && isEditorBottomBorder(line, width)); return lines.map((line, index) => { if (index === 0 || index === bottomBorderIndex) { return rainbowLine(line, width, index); } // Content and autocomplete lines may contain cursor/IME control markers. // Leave them untouched so hidden markers never become visible garbage. return line; }); } } export default function (pi: ExtensionAPI) { pi.on("session_start", (_event, ctx) => { activeCtx = ctx; ctx.ui.setEditorComponent((tui, theme, keybindings) => { requestRender = () => tui.requestRender(); return new GlowEditor(tui, theme, keybindings); }); refresh(ctx); }); pi.on("agent_start", (_event, ctx) => { activeCtx = ctx; running = true; phase = 0; refresh(ctx); }); const settle = (_event: unknown, ctx: ExtensionContext) => { activeCtx = ctx; running = false; refresh(ctx); }; pi.on("agent_settled", settle); pi.on("agent_end", settle); pi.on("session_shutdown", () => { running = false; stopTimer(); }); pi.registerCommand("glow", { description: "Toggle the iTerm2-style flowing rainbow editor border: /glow on|off|status", handler: async (args, ctx) => { const action = args.trim().toLowerCase(); if (!action || action === "status") { ctx.ui.notify(`Glow UI is ${enabled ? "on" : "off"}${running ? " and active" : ""}.`, "info"); return; } if (action !== "on" && action !== "off") { ctx.ui.notify("Usage: /glow [on|off|status]", "error"); return; } enabled = action === "on"; refresh(ctx); ctx.ui.notify(`Glow UI ${enabled ? "enabled" : "disabled"}.`, "info"); }, }); }