/** * Last Prompt Topbar Extension ("last turn") * * Renders a persistent widget above the editor showing the last user prompt in * a rounded sl0p-style frame. Spiced up with: * - an animated running spinner + LIVE elapsed-time counter while running * - the last run's duration shown when idle * - a "turn #N" counter for the session * - a full-frame border that pulses through a color palette while running * (accent border when idle) * * PERFORMANCE HISTORY (why the animation is safe now): * The original regression wasn't the timer itself — it was that a full-frame * re-render recomputed EVERY tool box (native inner render + framing) from * scratch, so an animation ticking requestRender() several times a second * turned into hundreds of MB/s of string churn + typing lag over a large * transcript. That is now fixed at the source: slop-tool-boxes.ts memoizes each * tool row by width and returns cached lines on unchanged width, so a * full-frame re-render is cheap. With that in place a bounded animation timer * is fine, and we keep it conservative: * - the timer runs ONLY while a run is active (isRunning). While active, pi is * already streaming tokens and re-rendering constantly, so our extra frames * are marginal — not a new idle render load. It stops the instant the run * settles, so it can never become a runaway. * - it calls the captured tui.requestRender() directly instead of setWidget() * each tick, so we don't rebuild the widget factory/closure every frame. * - render() reads live state (Date.now()-startedAt, tick) so each frame shows * the current elapsed time and spinner/scanline position. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; // Straight end caps for the top/bottom rules: with no side rails there's no // vertical for a corner to turn into. Set to ╭╮╰╯ to restore the rounded box. const H = "─"; const TL = H; const TR = H; const BL = H; const BR = H; // Animation cadence while a run is active. 10fps gives a smooth spinner + a // live sub-second timer. Only ever runs during active runs (see file header). const FRAME_MS = 100; // While a run is active the whole frame (corners, dashes, verticals, label) // pulses by cycling through this palette. theme.fg() only accepts semantic // tokens, which would look steppy, so we interpolate truecolor RGB directly for // a smooth glide. Curated sl0p/synthwave anchors; the cycle wraps last->first. const PULSE: Array<[number, number, number]> = [ [0xff, 0x8a, 0x1e], // amber [0xff, 0x2e, 0x88], // hot pink [0xb0, 0x5c, 0xff], // violet [0x2e, 0xd6, 0xff], // cyan ]; // Frames for one full trip around the palette (~4s at 10fps). Slow = classy. const PULSE_PERIOD = 40; function lerp(a: number, b: number, t: number): number { return Math.round(a + (b - a) * t); } // phase in [0,1) -> interpolated [r,g,b] around the palette ring. function pulseRGB(phase: number): [number, number, number] { const n = PULSE.length; const x = ((phase % 1) + 1) % 1 * n; const i = Math.floor(x) % n; const j = (i + 1) % n; const t = x - Math.floor(x); const a = PULSE[i]; const b = PULSE[j]; return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; } function fgRGB(rgb: [number, number, number], s: string): string { return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${s}\x1b[39m`; } function fmtDuration(ms: number): string { if (ms < 0) ms = 0; return `${(ms / 1000).toFixed(1)}s`; } export default function (pi: ExtensionAPI) { const WIDGET_ID = "last-prompt-topbar"; let lastPrompt: string | undefined; let isRunning = false; let count = 0; let startedAt = 0; let lastDurationMs: number | undefined; let tick = 0; let timer: ReturnType | undefined; let lastCtx: { ui: { setWidget: Function }; hasUI: boolean } | undefined; // Captured from the widget factory so the animation timer can drive frames // directly via tui.requestRender() instead of rebuilding the widget each tick. let tuiRef: { requestRender?: () => void } | undefined; // Bounded animation timer. Runs ONLY while a run is active (see file header for // why that makes it cheap now that tool boxes are width-memoized). Each tick // just bumps `tick` and asks the TUI to re-render; render() reads live state // (Date.now()-startedAt, tick) to show the current elapsed time + spinner. function startAnim() { if (timer) return; timer = setInterval(() => { tick++; tuiRef?.requestRender?.(); }, FRAME_MS); } function stopAnim() { if (timer) { clearInterval(timer); timer = undefined; } } function buildTopBorder(width: number, theme: any, border: (s: string) => string): string { const h = H; const tl = border(TL); const tr = border(TR); const dash = (n: number) => border(h.repeat(Math.max(0, n))); const innerWidth = Math.max(0, width - 2); // Left label: ◈ turn #7 — stays STATIC (accent); only the border // rails pulse, so the label reads clearly against the moving frame. const leftLabel = " " + theme.fg("accent", "◈") + " " + theme.fg("accent", theme.bold("turn")) + theme.fg("muted", ` #${count}`) + " "; // Right status segment: animated spinner + LIVE elapsed while running, // final duration when idle. let rightSeg = ""; if (isRunning) { const spin = SPINNER[tick % SPINNER.length]; const elapsed = fmtDuration(Date.now() - startedAt); rightSeg = ` ${theme.fg("warning", spin)} ${theme.fg("dim", elapsed)} `; } else if (lastDurationMs !== undefined) { rightSeg = ` ${theme.fg("success", "✓")} ${theme.fg("dim", fmtDuration(lastDurationMs))} `; } else { rightSeg = ` ${theme.fg("success", "✓")} `; } const leftLead = 1; const rightTrail = 1; let midLen = innerWidth - leftLead - rightTrail - visibleWidth(leftLabel) - visibleWidth(rightSeg); if (midLen < 1) { // Not enough room for the status segment — drop it. rightSeg = ""; midLen = innerWidth - leftLead - rightTrail - visibleWidth(leftLabel); } if (midLen < 0) { // Extreme narrow: just draw a plain border. return `${tl}${dash(innerWidth)}${tr}`; } return `${tl}${dash(leftLead)}${leftLabel}${dash(midLen)}${rightSeg}${dash(rightTrail)}${tr}`; } function buildBottomBorder(width: number, theme: any, border: (s: string) => string): string { const h = H; const bl = border(BL); const br = border(BR); const innerWidth = Math.max(0, width - 2); return `${bl}${border(h.repeat(innerWidth))}${br}`; } function updateWidget(ctx: { ui: { setWidget: Function }; hasUI: boolean }) { if (!ctx.hasUI) return; if (!lastPrompt) { ctx.ui.setWidget(WIDGET_ID, undefined); return; } ctx.ui.setWidget(WIDGET_ID, (tui: any, theme: any) => { tuiRef = tui; return { render(width: number): string[] { // While running, the whole frame pulses through the palette in unison. // The phase advances with `tick` (bumped by the animation timer), so a // single phase drives every border glyph on this frame. Idle = accent. const border: (s: string) => string = isRunning ? ((rgb) => (s: string) => fgRGB(rgb, s))(pulseRGB((tick % PULSE_PERIOD) / PULSE_PERIOD)) : (s: string) => theme.fg("accent", s); const topLine = buildTopBorder(width, theme, border); // Prompt text with accent marker — wrap up to MAX_LINES const MAX_LINES = 3; const promptText = lastPrompt!.replace(/\n/g, " ↵ ").replace(/\s+/g, " ").trim(); // No side rails — the zellij pane already borders us, so the body is // just indented to sit under the box interior. The leading blank keeps // the text in the exact column it occupied when a rail was drawn there, // and RIGHT_RESERVE keeps the old wrap width (and a right margin) even // though we no longer draw or pad to a right rail. const RIGHT_RESERVE = 2; const firstPrefix = " " + " " + border("❯") + " "; const contPrefix = " "; const available = width - visibleWidth(firstPrefix) - RIGHT_RESERVE; const wrapped = wrapTextWithAnsi(promptText, Math.max(1, available)); const lineCount = Math.min(wrapped.length, MAX_LINES); const promptLines: string[] = []; for (let i = 0; i < lineCount; i++) { const prefix = i === 0 ? firstPrefix : contPrefix; let text = wrapped[i]; if (i === lineCount - 1 && wrapped.length > MAX_LINES) { const remaining = wrapped.slice(i).join(" "); text = truncateToWidth(remaining, available, "…"); } promptLines.push(`${prefix}${theme.bold(text)}`); } const bottomLine = buildBottomBorder(width, theme, border); return [topLine, ...promptLines, bottomLine]; }, invalidate() {}, }; }); } // Capture all user input (normal prompts, steers, and follow-ups) pi.on("input", async (event, ctx) => { if (event.text) { lastPrompt = event.text; count++; lastCtx = ctx; updateWidget(ctx); } }); // Hide pi's built-in "Working …" status row — the last turn box already // shows a live spinner + timer, so the second indicator is pure noise. // setWorkingVisible(false) drops the whole row (spinner AND message); the empty // frames call is a fallback for older pi builds that only knew the indicator. // (pi resets this on runtime reset, so we re-apply on session_start too.) function hideWorkingIndicator(ctx: any) { try { if (!ctx?.hasUI) return; if (typeof ctx.ui?.setWorkingVisible === "function") { ctx.ui.setWorkingVisible(false); } else if (typeof ctx.ui?.setWorkingIndicator === "function") { ctx.ui.setWorkingIndicator({ frames: [] }); } } catch { /* ignore */ } } // Track agent running state pi.on("agent_start", async (_event, ctx) => { isRunning = true; startedAt = Date.now(); lastCtx = ctx; hideWorkingIndicator(ctx); startAnim(); updateWidget(ctx); }); function settle(ctx: any) { isRunning = false; if (startedAt) lastDurationMs = Date.now() - startedAt; lastCtx = ctx; stopAnim(); updateWidget(ctx); } pi.on("agent_end", async (_event, ctx) => settle(ctx)); // agent_settled fires when pi will not continue automatically — covers abort/ // error paths where agent_end may not fire, guaranteeing the timer stops. pi.on("agent_settled", async (_event, ctx) => settle(ctx)); pi.on("session_shutdown", async () => { isRunning = false; stopAnim(); }); // Restore state from session on start/reload pi.on("session_start", async (_event, ctx) => { lastPrompt = undefined; count = 0; lastDurationMs = undefined; isRunning = false; lastCtx = ctx; // Walk the session branch: track last user prompt + count turns. for (const entry of ctx.sessionManager.getBranch()) { if ( entry.type === "message" && entry.message.role === "user" && Array.isArray(entry.message.content) ) { for (const block of entry.message.content) { if (block.type === "text" && block.text) { lastPrompt = block.text; count++; } } } } hideWorkingIndicator(ctx); updateWidget(ctx); }); }