#!/usr/bin/env node /** * answer-picker — a standalone clickable "answer picker" for a running pi agent. * * Runs in its OWN terminal pane and talks to the pi process over a unix socket. * Click a button (or press its number) and the corresponding preset prompt is * sent to the agent as a real user message. * * usage: answer-picker [--selftest] * PI_ANSWER_PICKER_SOCKET= answer-picker * * WHY THIS IS A SEPARATE PROCESS * Terminal mouse reporting is per-terminal, not per-region: the moment an app * enables it, that terminal stops doing native wheel scrolling and click-drag * selection, because the wheel is just another button in the protocol. Running * the buttons inside pi therefore cost pi's pane its scrollback scrolling. Here * the tracking is confined to THIS pane, which has nothing worth scrolling, so * pi's pane keeps native scrolling and selection untouched. * * A second benefit: we render our own alt-screen canvas, so we know exactly * which row every button is on. Hit-testing is direct arithmetic instead of * trying to locate ourselves inside somebody else's frame. * * PROTOCOL (newline-delimited JSON, both directions) * pi -> picker {"t":"hello","nudges":[{"label":"continue"},...],"title":"..."} * {"t":"state","running":true,"turn":7} * picker -> pi {"t":"pick","idx":3} * The preset list is owned by the pi extension and pushed on connect, so this * process holds no copy of it and the two can never disagree. * * TERMINAL STATE * We enter the alt screen, hide the cursor, and enable SGR mouse reporting with * all-motion (?1003h) so buttons can highlight on hover. Every one of those is * reversed on exit, including on SIGINT/SIGTERM/SIGHUP and on an uncaught * exception, so a crash cannot leave the pane in raw mouse mode. */ "use strict"; const net = require("node:net"); const os = require("node:os"); // ── terminal control ──────────────────────────────────────────────────────── const ALT_ON = "\x1b[?1049h"; const ALT_OFF = "\x1b[?1049l"; const CURSOR_HIDE = "\x1b[?25l"; const CURSOR_SHOW = "\x1b[?25h"; // 1000=click 1002=button-drag 1003=any-motion (for hover) 1006=SGR encoding const MOUSE_ON = "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h"; const MOUSE_OFF = "\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1006l"; const CLEAR = "\x1b[H\x1b[2J"; const C = { reset: "\x1b[0m", dim: "\x1b[38;5;244m", muted: "\x1b[38;5;245m", accent: "\x1b[38;5;208m", bold: "\x1b[1m", hotFg: "\x1b[1;38;5;16m", hotBg: "\x1b[48;5;208m", hoverBg: "\x1b[48;5;238m", ok: "\x1b[38;5;114m", warn: "\x1b[38;5;214m", }; // Layout geometry. Buttons begin at PILL_ROW0 (1-based screen row) and we own // the whole canvas, so a click's row maps to a button row by subtraction. const PILL_ROW0 = 3; const INDENT = 2; const GAP = 2; const SGR_MOUSE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/; /** * Lay presets out into rows of pills, wrapping to the pane width. * Returns [{ row, spans:[{start,end,idx}] }] with 1-based inclusive columns. * Pure function — exercised by --selftest. */ function layout(nudges, width) { const usable = Math.max(1, width - 1); const rows = []; let cur = { row: PILL_ROW0, spans: [], len: INDENT }; for (let idx = 0; idx < nudges.length; idx++) { const text = `[${idx + 1} ${nudges[idx].label}]`; const pad = cur.spans.length === 0 ? 0 : GAP; if (cur.len + pad + text.length > usable && cur.spans.length > 0) { rows.push(cur); cur = { row: cur.row + 1, spans: [], len: INDENT }; } // Still too wide on a fresh row: skip it (number key remains available). if (INDENT + text.length > usable) continue; const start = (cur.spans.length === 0 ? INDENT : cur.len + GAP) + 1; cur.len = start - 1 + text.length; cur.spans.push({ start, end: start + text.length - 1, idx, text }); } if (cur.spans.length > 0) rows.push(cur); return rows; } /** Which preset is at 1-based (col,row)? undefined if none. Pure. */ function hit(rows, col, row) { for (const r of rows) { if (r.row !== row) continue; for (const s of r.spans) if (col >= s.start && col <= s.end) return s.idx; } return undefined; } // ── selftest (no TTY required) ─────────────────────────────────────────────── if (process.argv.includes("--selftest")) { const nudges = ["continue", "yes", "no", "explain", "tests", "commit"].map((label) => ({ label })); let fails = 0; const check = (cond, msg) => { if (!cond) { console.error("FAIL:", msg); fails++; } }; for (const w of [200, 120, 80, 60, 40, 30, 20, 12, 8]) { const rows = layout(nudges, w); for (const r of rows) { check(r.spans.length > 0, `w=${w} empty row emitted`); const last = r.spans[r.spans.length - 1]; check(last.end <= w, `w=${w} row overflows: end=${last.end}`); for (const s of r.spans) { check(hit(rows, s.start, r.row) === s.idx, `w=${w} miss at start col ${s.start}`); check(hit(rows, s.end, r.row) === s.idx, `w=${w} miss at end col ${s.end}`); check(hit(rows, s.start - 1, r.row) !== s.idx, `w=${w} false hit left of ${s.idx}`); check(hit(rows, s.end + 1, r.row) !== s.idx, `w=${w} false hit right of ${s.idx}`); } } // no row may claim a column twice for (const r of rows) { for (let c = 1; c <= w; c++) { const owners = r.spans.filter((s) => c >= s.start && c <= s.end); check(owners.length <= 1, `w=${w} col ${c} claimed by ${owners.length} pills`); } } check(hit(rows, 1, PILL_ROW0 - 1) === undefined, `w=${w} hit above button rows`); check(hit(rows, 1, PILL_ROW0 + 99) === undefined, `w=${w} hit below button rows`); } // mouse sequence parsing const parse = (d) => { const m = SGR_MOUSE.exec(d); return m ? { b: +m[1], col: +m[2], row: +m[3], press: m[4] === "M" } : undefined; }; check(parse("\x1b[<0;5;3M").press === true, "press not parsed"); check(parse("\x1b[<0;5;3m").press === false, "release not parsed"); check(parse("\x1b[<35;5;3M").b === 35, "motion button not parsed"); check(parse("hello") === undefined, "plain text parsed as mouse"); console.log(fails === 0 ? "answer-picker selftest: ALL PASS" : `answer-picker selftest: ${fails} FAILURE(S)`); process.exit(fails === 0 ? 0 : 1); } // ── runtime ───────────────────────────────────────────────────────────────── const sockPath = process.argv[2] || process.env.PI_ANSWER_PICKER_SOCKET; if (!sockPath) { console.error("answer-picker: no socket path given (argv[1] or PI_ANSWER_PICKER_SOCKET)"); process.exit(2); } let nudges = []; let title = "answers"; let running = false; let turn = 0; let hover; let flash; let flashTimer; let rows = []; let restored = false; let connected = false; const out = (s) => { try { process.stdout.write(s); } catch {} }; function restore() { if (restored) return; restored = true; out(MOUSE_OFF + CURSOR_SHOW + ALT_OFF); try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {} } function bail(code, msg) { restore(); if (msg) process.stderr.write(msg + "\n"); process.exit(code); } function pad(s, n) { return s.length >= n ? s : s + " ".repeat(n - s.length); } function render() { const width = process.stdout.columns || 80; rows = layout(nudges, width); const status = !connected ? `${C.dim}connecting…${C.reset}` : running ? `${C.warn}● working${C.reset}` : `${C.ok}● idle${C.reset}`; const head = " ".repeat(INDENT) + `${C.accent}◈ ${C.bold}${title}${C.reset}` + (turn ? `${C.dim} #${turn}${C.reset}` : "") + " " + status; const lines = [head, ""]; const maxRow = rows.length ? rows[rows.length - 1].row : PILL_ROW0 - 1; for (let r = PILL_ROW0; r <= maxRow; r++) { const row = rows.find((x) => x.row === r); if (!row) { lines.push(""); continue; } let line = " ".repeat(INDENT); let col = INDENT + 1; for (const s of row.spans) { if (s.start > col) line += " ".repeat(s.start - col); const isFlash = flash === s.idx; const isHover = hover === s.idx; if (isFlash) line += `${C.hotBg}${C.hotFg}${s.text}${C.reset}`; else if (isHover) line += `${C.hoverBg}${C.bold}${s.text}${C.reset}`; else { const n = String(s.idx + 1); const label = s.text.slice(3 + n.length - 1, -1); line += `${C.muted}[${C.reset}${C.dim}${n}${C.reset} ${C.accent}${label}${C.reset}${C.muted}]${C.reset}`; } col = s.end + 1; } lines.push(line); } lines.push(""); lines.push(`${" ".repeat(INDENT)}${C.dim}click · 1-9 · q quit${C.reset}`); out(CLEAR + lines.map((l) => pad(l, 0)).join("\r\n")); } // ── socket ────────────────────────────────────────────────────────────────── const sock = net.createConnection(sockPath); let rx = ""; sock.on("connect", () => { connected = true; render(); }); sock.on("data", (chunk) => { rx += chunk.toString("utf8"); let nl; while ((nl = rx.indexOf("\n")) >= 0) { const line = rx.slice(0, nl); rx = rx.slice(nl + 1); if (!line.trim()) continue; let msg; try { msg = JSON.parse(line); } catch { continue; } if (msg.t === "hello") { nudges = Array.isArray(msg.nudges) ? msg.nudges : []; if (msg.title) title = msg.title; if (typeof msg.running === "boolean") running = msg.running; if (typeof msg.turn === "number") turn = msg.turn; render(); } else if (msg.t === "state") { if (typeof msg.running === "boolean") running = msg.running; if (typeof msg.turn === "number") turn = msg.turn; render(); } else if (msg.t === "bye") { bail(0); } } }); // pi went away (exit or /reload): nothing left to drive, so close the pane. sock.on("close", () => bail(0)); sock.on("error", (err) => bail(1, `answer-picker: cannot reach pi at ${sockPath}\n${err.message}`)); function pick(idx) { if (idx < 0 || idx >= nudges.length) return; flash = idx; render(); clearTimeout(flashTimer); flashTimer = setTimeout(() => { flash = undefined; render(); }, 160); try { sock.write(JSON.stringify({ t: "pick", idx }) + "\n"); } catch {} } // ── input ─────────────────────────────────────────────────────────────────── if (!process.stdin.isTTY) bail(2, "answer-picker: stdin is not a TTY (run me in a terminal pane)"); process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.on("data", (buf) => { const data = buf.toString("utf8"); const m = SGR_MOUSE.exec(data); if (m) { const b = +m[1]; const col = +m[2]; const row = +m[3]; const press = m[4] === "M"; const idx = hit(rows, col, row); // motion (bit 5) with no button: hover only if (b & 32 || !press) { if (idx !== hover) { hover = idx; render(); } return; } if (b & 64) return; // wheel: nothing to scroll here if ((b & 3) !== 0) return; // not the left button if (idx !== undefined) pick(idx); return; } if (data === "\x03" || data === "q" || data === "\x1b") bail(0); // ctrl+c / q / esc const n = data.charCodeAt(0) - 48; if (n >= 1 && n <= 9) pick(n - 1); }); process.stdout.on("resize", render); for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => bail(0)); process.on("uncaughtException", (e) => bail(1, "answer-picker: " + (e && e.message))); process.on("exit", restore); out(ALT_ON + CURSOR_HIDE + MOUSE_ON); render();