/** * Answer Picker — preset follow-up prompts, driven from a SEPARATE pane. * * Sends the short prompts you type over and over ("continue", "yes", "run the * tests", ...) with one click or one keypress. * * ┌─ pi pane ─────────────────┐ ┌─ picker pane ──────────────────┐ * │ transcript, editor, … │ │ ◈ answers #7 ● idle │ * │ (NO mouse tracking here, │◀──┤ [1 continue] [2 yes] [3 no] │ * │ so the wheel still │ │ [4 explain] [5 tests] … │ * │ scrolls natively) │ │ click · 1-9 · q quit │ * └───────────────────────────┘ └────────────────────────────────┘ * ▲ unix socket (newline-delimited JSON) │ * └──────────────────────────────────────── ┘ * * ── WHY A SEPARATE PROCESS ─────────────────────────────────────────────────── * Terminal mouse reporting is per-terminal, not per-region. The wheel is just * another button in the protocol (SGR bit 64), and the DECSET modes only choose * how much MOTION is reported — there is no bit that selects which buttons. So * any process that wants clicks necessarily takes the wheel too, and pi renders * inline into the main screen, so it cannot scroll the emulator's scrollback on * your behalf to compensate. An earlier in-process version of this therefore * traded away native wheel scrolling in pi's pane to get clickable buttons. * * Moving the buttons into their own pane dissolves that trade instead of * managing it: mouse tracking is confined to a pane that has nothing worth * scrolling, and THIS process never enables mouse reporting at all. pi's pane * keeps native wheel scrolling and click-drag selection, permanently. * * Two further wins fall out of the split: * - the picker owns its own alt-screen canvas, so it knows exactly which row * each button is on. Hit-testing is plain arithmetic, replacing the fragile * trick of locating our widget inside pi's frame via private render state. * - it is renderer-agnostic: it works identically under --tui-mode regular and * fullscreen, where pi grabs the mouse for its own selection/scrolling and * consumes every mouse event before extensions can see it. * * ── TRANSPORT ──────────────────────────────────────────────────────────────── * A unix domain socket in $XDG_RUNTIME_DIR (0700, falling back to os.tmpdir()), * with the socket itself chmod 0600. The data path is deliberately * multiplexer-agnostic — only pane SPAWNING is pluggable (see LAUNCHERS), and if * no launcher matches we print the exact command so you can run the picker in * any terminal, tab or ssh session you like. * * Anything that can write to that socket can inject a prompt into your agent, * which is why it lives in a 0700 directory and is 0600 itself. * * Protocol (newline-delimited JSON): * pi -> picker {"t":"hello","nudges":[{"label":...}],"title":...,"running":b} * {"t":"state","running":b,"turn":n} * {"t":"bye"} * picker -> pi {"t":"pick","idx":n} * The preset list lives HERE and is pushed on connect, so the picker holds no * copy and the two can never drift apart. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import net from "node:net"; import os from "node:os"; import path from "node:path"; import fs from "node:fs"; /** The presets. `label` shows on the button, `text` is sent verbatim. */ const NUDGES: Array<{ label: string; text: string }> = [ { label: "continue", text: "continue" }, { label: "yes", text: "yes, go ahead" }, { label: "no", text: "no — don't do that" }, { label: "explain", text: "explain what you just did, and why" }, { label: "tests", text: "run the tests and fix anything that fails" }, { label: "commit", text: "commit this with a clear message" }, ]; // Open the picker pane automatically when a TUI session starts. const AUTO_SPAWN = true; // Show the keyboard cheat-sheet row under pi's editor. const SHOW_WIDGET = true; // Per-preset keyboard shortcuts, usable without the picker pane at all. // Spelled out because pi types shortcuts as a KeyId literal union. const NUDGE_KEYS = ["alt+1", "alt+2", "alt+3", "alt+4", "alt+5", "alt+6", "alt+7", "alt+8", "alt+9"] as const; const KEY_HINT = "alt+N"; // Mid-stream clicks: "followUp" queues until the turn ends, "steer" interrupts. const MIDRUN_DELIVERY: "steer" | "followUp" = "followUp"; const INDENT = 4; const RIGHT_RESERVE = 2; const DEBOUNCE_MS = 250; /** Pane spawners, first match wins. Keep the data path out of here. */ type Launcher = { name: string; detect: () => boolean; argv: (cmd: string[]) => string[] }; const LAUNCHERS: Launcher[] = [ { name: "zellij", detect: () => !!process.env.ZELLIJ || !!process.env.ZELLIJ_SESSION_NAME, // --no-focus keeps you typing in pi; --close-on-exit disposes of the pane // when the picker exits, so teardown needs no multiplexer-specific call. argv: (cmd) => ["zellij", "run", "--no-focus", "--close-on-exit", "--name", "answers", "-d", "down", "--", ...cmd], }, { name: "tmux", detect: () => !!process.env.TMUX, // -d = do not focus the new pane; -l 6 = six rows tall. argv: (cmd) => ["tmux", "split-window", "-v", "-d", "-l", "6", ...cmd], }, { name: "wezterm", detect: () => !!process.env.WEZTERM_PANE, argv: (cmd) => ["wezterm", "cli", "split-pane", "--bottom", "--percent", "20", "--", ...cmd], }, { name: "kitty", detect: () => !!process.env.KITTY_LISTEN_ON, argv: (cmd) => ["kitty", "@", "launch", "--location", "hsplit", "--dont-take-focus", "--", ...cmd], }, ]; export default function (pi: ExtensionAPI) { const WIDGET_ID = "answer-picker"; let server: net.Server | undefined; let sockPath: string | undefined; const clients = new Set(); let isRunning = false; let turn = 0; let lastFireAt = 0; let spawning = false; // ── picker executable resolution ────────────────────────────────────────── // env override -> repo checkout (dev) -> ~/bin (install.sh) -> bare PATH name. function pickerCmd(): string { const env = process.env.PI_ANSWER_PICKER; if (env && fs.existsSync(env)) return env; const dev = path.join(process.cwd(), "scripts", "answer-picker"); if (fs.existsSync(dev)) return dev; const home = path.join(os.homedir(), "bin", "pi-answers"); if (fs.existsSync(home)) return home; return "pi-answers"; } function send(sock: net.Socket, msg: unknown) { try { sock.write(JSON.stringify(msg) + "\n"); } catch { /* client vanished mid-write */ } } function broadcast(msg: unknown) { for (const c of clients) send(c, msg); } function fire(idx: number, ctx?: any) { const n = NUDGES[idx]; if (!n) return; const now = Date.now(); if (now - lastFireAt < DEBOUNCE_MS) return; // stray double-click lastFireAt = now; if (isRunning) pi.sendUserMessage(n.text, { deliverAs: MIDRUN_DELIVERY }); else pi.sendUserMessage(n.text); if (ctx?.hasUI) updateWidget(ctx); } // ── socket server ───────────────────────────────────────────────────────── function startServer(ctx: any): string | undefined { if (server) return sockPath; const dir = process.env.XDG_RUNTIME_DIR || os.tmpdir(); const p = path.join(dir, `pi-answers-${process.pid}.sock`); try { if (fs.existsSync(p)) fs.unlinkSync(p); // stale socket from a crash } catch { /* ignore */ } const srv = net.createServer((sock) => { clients.add(sock); sock.setEncoding("utf8"); send(sock, { t: "hello", nudges: NUDGES.map((n) => ({ label: n.label })), title: "answers", running: isRunning, turn }); let rx = ""; sock.on("data", (chunk: string) => { rx += chunk; let nl: number; while ((nl = rx.indexOf("\n")) >= 0) { const line = rx.slice(0, nl).trim(); rx = rx.slice(nl + 1); if (!line) continue; try { const msg = JSON.parse(line); if (msg?.t === "pick" && Number.isInteger(msg.idx)) fire(msg.idx, ctx); } catch { /* ignore malformed frame */ } } }); const drop = () => { clients.delete(sock); if (ctx?.hasUI) updateWidget(ctx); }; sock.on("close", drop); sock.on("error", drop); if (ctx?.hasUI) updateWidget(ctx); }); srv.on("error", (err: any) => { if (ctx?.hasUI) ctx.ui.notify(`answer picker: socket error — ${err?.message ?? err}`, "error"); }); try { srv.listen(p, () => { try { fs.chmodSync(p, 0o600); // only us may inject prompts } catch { /* best effort */ } }); } catch (err: any) { if (ctx?.hasUI) ctx.ui.notify(`answer picker: cannot listen — ${err?.message ?? err}`, "error"); return undefined; } server = srv; sockPath = p; return p; } function stopServer() { broadcast({ t: "bye" }); for (const c of clients) { try { c.destroy(); } catch {} } clients.clear(); try { server?.close(); } catch {} server = undefined; if (sockPath) { try { fs.unlinkSync(sockPath); } catch {} sockPath = undefined; } } // ── pane spawning ───────────────────────────────────────────────────────── async function spawnPane(ctx: any, { quiet = false } = {}) { if (spawning) return; const p = sockPath ?? startServer(ctx); if (!p) return; const cmd = [pickerCmd(), p]; const launcher = LAUNCHERS.find((l) => l.detect()); if (!launcher) { // Fully generic fallback: the picker is just a program that takes a socket // path, so it can run in any terminal, tab, or ssh session. if (!quiet && ctx?.hasUI) { ctx.ui.notify(`No multiplexer detected. Run the picker anywhere: ${cmd.join(" ")}`, "warning"); } return; } const argv = launcher.argv(cmd); spawning = true; try { const res = await pi.exec(argv[0], argv.slice(1), { cwd: process.cwd() }); if (res.code !== 0 && !quiet && ctx?.hasUI) { const detail = (res.stderr || res.stdout || "").trim().split("\n")[0]; ctx.ui.notify(`answer picker: ${launcher.name} spawn failed — ${detail || `exit ${res.code}`}`, "error"); } } catch (err: any) { if (!quiet && ctx?.hasUI) ctx.ui.notify(`answer picker: spawn failed — ${err?.message ?? err}`, "error"); } finally { spawning = false; } } // ── pi-side cheat-sheet widget (no mouse, no hit-testing) ───────────────── function updateWidget(ctx: any) { if (!ctx?.hasUI || !SHOW_WIDGET) return; ctx.ui.setWidget( WIDGET_ID, (_tui: any, theme: any) => ({ render(width: number): string[] { const usable = Math.max(1, width - RIGHT_RESERVE); let plain = " ".repeat(INDENT); let line = " ".repeat(INDENT); NUDGES.forEach((n, idx) => { const piece = `${idx + 1} ${n.label}`; const sep = idx === 0 ? "" : " "; if (plain.length + sep.length + piece.length > usable) return; plain += sep + piece; line += sep + theme.fg("dim", String(idx + 1)) + " " + theme.fg("accent", n.label); }); const tail = clients.size > 0 ? ` ${KEY_HINT} · picker ✓` : ` ${KEY_HINT}`; if (plain.length + tail.length <= usable) line += theme.fg("dim", tail); return [line]; }, invalidate() {}, }), { placement: "belowEditor" }, ); } // ── wiring ──────────────────────────────────────────────────────────────── NUDGES.forEach((n, idx) => { const key = NUDGE_KEYS[idx]; if (!key) return; // >9 presets: extras are picker-only pi.registerShortcut(key, { description: `Answer: ${n.label}`, handler: (ctx) => fire(idx, ctx) }); }); pi.registerCommand("answers", { description: "Open the clickable answer picker in a new pane", // Command handlers receive (args, ctx) — args is the raw text after /answers. handler: async (_args: string, ctx: any) => { startServer(ctx); await spawnPane(ctx); if (ctx.hasUI && sockPath) ctx.ui.notify(`answer picker socket: ${sockPath}`, "info"); }, }); pi.on("input", async (event, ctx) => { if (!event.text) return; turn++; broadcast({ t: "state", running: isRunning, turn }); updateWidget(ctx); }); pi.on("agent_start", async (_e, ctx) => { isRunning = true; broadcast({ t: "state", running: true, turn }); updateWidget(ctx); }); const settle = (ctx: any) => { isRunning = false; broadcast({ t: "state", running: false, turn }); updateWidget(ctx); }; pi.on("agent_end", async (_e, ctx) => settle(ctx)); pi.on("agent_settled", async (_e, ctx) => settle(ctx)); pi.on("session_start", async (_e, ctx) => { isRunning = false; turn = 0; // RPC/print modes have no pane to spawn into and no user to click. if (!ctx.hasUI || ctx.mode !== "tui") return; startServer(ctx); updateWidget(ctx); if (AUTO_SPAWN) void spawnPane(ctx, { quiet: true }); }); // Closing the socket makes the picker exit, which (with --close-on-exit) // disposes of its pane. No multiplexer-specific teardown needed. pi.on("session_shutdown", async () => stopServer()); }