aboutsummaryrefslogtreecommitdiffstats
path: root/pi-ext
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-12 16:25:46 +0200
committeruser <user@clank>2026-08-12 19:34:35 +0200
commit258703d6cb417e170c951d0ac86685633cb8c773 (patch)
treedd9ce2acd9f6cc5e236efa1f31150f6ae8e07b7a /pi-ext
parentlast-prompt: call it a turn, not a sl0peration (diff)
downloadsl0ppi-258703d6cb417e170c951d0ac86685633cb8c773.tar.gz
sl0ppi-258703d6cb417e170c951d0ac86685633cb8c773.tar.xz
sl0ppi-258703d6cb417e170c951d0ac86685633cb8c773.zip
add answer-picker: clickable preset answers in the pane frame (OSC 5577)
Puts the follow-ups you retype constantly ("continue", "yes", "run the tests") into the bottom frame row of pi's own pane, using the sl0p zellij fork's OSC 5577 pane-status-bar protocol. Click one or press alt+N and it is sent as a real user message. ╰ alt+N · ready ──[continue]─[yes]─[no]─[explain]───╯ WHY THE THIRD DESIGN. v1 drew its own button row and enabled terminal mouse reporting inside pi. It worked and cost too much: mouse reporting is per-terminal, not per-region, the wheel is just another button in that protocol (SGR bit 64), and the DECSET modes only select how much MOTION is reported -- there is no bit that selects which buttons. So pi's pane traded native wheel scrolling and click-drag selection for clickable buttons, and because pi renders inline into the main screen it cannot scroll the emulator's scrollback back to compensate. v2 moved the buttons to a second pane to confine the tracking; that restored scrolling but cost a pane, a picker process and a unix socket. OSC 5577 removes the tradeoff instead of relocating it: zellij draws the buttons in the frame it already owns, hit-tests them itself, and reports clicks on our stdin. No mouse reporting anywhere, no extra pane, and the frame row does not shrink the content area so nothing resizes the PTY. v2 is parked in attic/ (not globbed by install.sh) as the only non-zellij path, should one be wanted. ANSWERS come from DEFAULTS until <cwd>/.pi-answers.md exists, after which the file is authoritative. It is SEEDED with the defaults on first write, which makes "the file wins" and "you still get the defaults" one rule instead of a merge order to remember -- and it means deleting a line genuinely removes a default, which an additive model could not express. /answers add|rm persists in one step; hand-edit + /answers reload also works. Labels are backtick-delimited in the markdown so a body may contain the em-dash separator (as "no -- don't do that" does). /answers list renders a table via appendEntry + registerEntryRenderer, so it is scrollable and copyable in the transcript but never enters LLM context. BUTTON IDS ARE DERIVED FROM THE LABEL (ans-ship), not positional. With a mutable answer list, positional ids are a live bug: change the answers and an in-flight click for a vanished button resolves to whatever now occupies that index and fires the wrong prompt. Label-derived ids fail the allow-list instead. BUTTONS EXIST ONLY BETWEEN TURNS. While a run is in flight they are withdrawn -- so a click cannot queue a second answer behind the one already working -- and the status text stays up to advertise that alt+N still does. Withdrawing sends `buttons` with an empty payload, NOT `clear`: the fork's buttons verb assigns parse_status_bar_buttons(payload), which filter_maps away every field lacking an id:label, while `clear` is the one verb that would also wipe the status text. Restoring keys off agent_settled as much as agent_end, since agent_end never fires on the abort/error paths -- an escape cancel or a provider blowup would otherwise leave the frame permanently bare. SECURITY. These are in-band escapes, so anything that writes to the pane (a cat of a hostile file) can declare buttons and draw a misleading bar. A click report is a hint that a user pressed something, not proof we put it there: ids are validated against what we actually declared, and the action is deliberately non-destructive -- it sends a prompt, it does not deploy. Click reports arrive interleaved with ordinary stdin and are stripped, or they would be typed into the editor; pi-tui's StdinBuffer holds an ESC ] sequence until its ST/BEL terminator, so we always see whole reports. Caps match the fork exactly (8 buttons, 512-char status, 32-char ids), so we never rely on zellij silently dropping our excess. Answers past the 8th stay on alt+N and the status line says so.
Diffstat (limited to 'pi-ext')
-rw-r--r--pi-ext/answer-picker.ts617
-rw-r--r--pi-ext/attic/answer-picker-socket.ts347
-rwxr-xr-xpi-ext/attic/answer-picker-standalone341
3 files changed, 1305 insertions, 0 deletions
diff --git a/pi-ext/answer-picker.ts b/pi-ext/answer-picker.ts
new file mode 100644
index 0000000..a5f2640
--- /dev/null
+++ b/pi-ext/answer-picker.ts
@@ -0,0 +1,617 @@
+/**
+ * Answer Picker — preset follow-up prompts as clickable buttons in the pane frame.
+ *
+ * Puts the short prompts you type over and over ("continue", "yes", "run the
+ * tests", ...) into the bottom frame row of pi's own pane, via the sl0p zellij
+ * fork's OSC 5577 pane-status-bar protocol. Click one, or press alt+<N>, and it
+ * is sent to the agent as a real user message.
+ *
+ * ╭───────────────────────── pi ─────────────────────────╮
+ * │ transcript, editor, … │
+ * ╰ alt+N · ready ──[continue]─[yes]─[no]─[explain]───────╯
+ * ^ status text ^ zellij-drawn, clickable
+ *
+ * ── WHERE THE ANSWERS COME FROM ──────────────────────────────────────────────
+ * Two sources, with the file winning outright when it exists:
+ *
+ * 1. DEFAULTS below — the built-in set, used when no file is present.
+ * 2. <cwd>/.pi-answers.md — per-project answers, read at session start.
+ *
+ * The file is authoritative rather than additive: it is SEEDED with DEFAULTS the
+ * first time anything writes it, so "file wins" and "you still get the defaults"
+ * are the same statement. That keeps precedence trivial to reason about (there is
+ * no merge order to remember) while making the defaults editable — delete a line
+ * from the file and that button is gone, which an additive model could not do.
+ *
+ * `/answers add <label> [text…]` appends and persists in one step, creating the
+ * file (defaults + the new answer) if it does not exist yet. Hand-editing the
+ * file is equally supported; `/answers reload` re-reads it.
+ *
+ * File format — a markdown list, label in backticks so the text may contain any
+ * punctuation (including the em dash used as separator):
+ *
+ * - `continue` — continue
+ * - `no` — no — don't do that
+ *
+ * Parsing takes the label from between the backticks and everything after the
+ * first separator as the text, so a dash inside the text is not a problem. A
+ * looser `- label: text` / `- label -> text` form is also accepted for
+ * hand-written files.
+ *
+ * ── WHY OSC 5577 RATHER THAN DRAWING OUR OWN BUTTONS ─────────────────────────
+ * v1 drew its own button row and enabled terminal mouse reporting inside pi.
+ * Mouse reporting is per-terminal, not per-region — the wheel is just another
+ * button in that protocol — so pi's pane lost native wheel scrolling and
+ * click-drag selection as the price of clickable buttons. v2 moved the buttons to
+ * a second pane to confine the tracking, which restored scrolling but cost an
+ * extra pane, a picker process and a unix socket.
+ *
+ * OSC 5577 removes the tradeoff instead of relocating it: zellij draws the
+ * buttons in the frame it already owns, hit-tests them itself, and reports a
+ * click as a fixed-format sequence on our stdin. No mouse reporting anywhere, no
+ * second pane, no socket. The frame row also does not shrink the content area, so
+ * setting or clearing it never resizes the PTY nor sends SIGWINCH.
+ *
+ * we send: ESC ] 5577;1;buttons;ans-continue:continue;ans-yes:yes ESC \
+ * ESC ] 5577;1;status;alt+N · ready ESC \
+ * we get: ESC ] 5577;1;click;ans-continue ESC \
+ *
+ * ── CONSEQUENCES OF THE PROTOCOL WE HAVE TO HANDLE ───────────────────────────
+ * - Click reports arrive INTERLEAVED with ordinary stdin, like a cursor-position
+ * or OSC 11 reply. We scan for them and strip them, because anything left in
+ * the stream would be typed into the editor as garbage. We do get whole
+ * reports: pi-tui's StdinBuffer holds an `ESC ]` sequence until its ST/BEL
+ * terminator arrives (isCompleteOscSequence), so a report split across
+ * read() boundaries is reassembled before it reaches us.
+ * - Ids are validated against the buttons we actually declared. They are
+ * DERIVED FROM THE LABEL (ans-<slug>) rather than positional, so that when
+ * the answer list changes, a click report for a button that no longer exists
+ * simply fails to match instead of resolving to whatever now occupies that
+ * index. Positional ids would silently fire the wrong prompt.
+ * - These are in-band escapes, so anything that writes to the pane (a `cat` of
+ * a hostile file) can declare buttons and draw a misleading bar. A click
+ * report is a hint that a user pressed something, not proof that we put it
+ * there — hence the allow-list, and hence keeping the action non-destructive
+ * (it sends a prompt; it does not deploy anything).
+ * - The bar survives detach/reattach AND session resurrection, so a restored
+ * pane can come back showing a stale bar. We re-assert on every session_start
+ * and clear on shutdown rather than trusting the screen.
+ * - Buttons are click-only; the protocol has no keyboard access. alt+1..9 is
+ * ours, is registered once against a live lookup (so answers added at runtime
+ * get a working key immediately), and works even with frames off — including
+ * mid-run, when the buttons themselves are deliberately withdrawn.
+ *
+ * ── BUTTONS ONLY EXIST BETWEEN TURNS ───────────────────────────────────────
+ * While a run is in flight the buttons are withdrawn and only the status text
+ * remains, so a click cannot queue a second answer behind the one already
+ * working. They come back when the run settles — completed, errored, or
+ * cancelled. That relies on agent_settled as much as agent_end: agent_settled is
+ * what fires on the abort/error paths where agent_end never arrives, so an
+ * escape-key cancel or a provider blow-up still restores them rather than
+ * leaving the frame permanently bare.
+ *
+ * Withdrawing means sending `buttons` with an empty payload (an empty list),
+ * NOT `clear` — `clear` would take the status text with it. Verified against the
+ * fork: the buttons verb assigns parse_status_bar_buttons(payload), which
+ * filter_maps away every field lacking an `id:label`, and only `clear` touches
+ * status_bar.
+ * - The bar needs pane frames. With pane_frame_style "none"/"titles", or after
+ * TogglePaneFrames, it is simply not drawn — nothing breaks.
+ * - Protocol limits: 8 buttons, 512-char status, control chars stripped.
+ * Answers past the 8th stay keyboard-only rather than being silently dropped.
+ */
+
+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
+import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
+import fs from "node:fs";
+import path from "node:path";
+
+type Answer = { label: string; text: string };
+
+/** Payload for the /answers list transcript entry. */
+type AnswersListData = {
+ answers: Answer[];
+ source: "file" | "defaults";
+ path: string;
+ maxButtons: number;
+};
+
+/** Built-in set. Used until a .pi-answers.md exists, and seeds it when created. */
+const DEFAULTS: readonly Answer[] = [
+ { 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" },
+];
+
+/** Per-project answers, looked up in the cwd pi was started in. */
+const ANSWERS_FILE = ".pi-answers.md";
+
+const NUDGE_KEYS = ["alt+1", "alt+2", "alt+3", "alt+4", "alt+5", "alt+6", "alt+7", "alt+8", "alt+9"] as const;
+const MIDRUN_DELIVERY: "steer" | "followUp" = "followUp";
+const DEBOUNCE_MS = 250;
+
+// Protocol ceilings (zellij drops the excess itself; we cap to stay predictable).
+const MAX_BUTTONS = 8;
+const MAX_STATUS = 512;
+const MAX_LABEL = 24;
+
+const OSC = "\x1b]5577;1;";
+const ST = "\x1b\\";
+const CLICK_RE = /\x1b\]5577;1;click;([A-Za-z0-9_-]{1,32})(?:\x1b\\|\x07)/g;
+
+/** Button fields are `;`-separated, so `;` `:` `%` must be %-escaped in labels. */
+const escLabel = (s: string) => s.replace(/%/g, "%25").replace(/;/g, "%3B").replace(/:/g, "%3A");
+// eslint-disable-next-line no-control-regex
+const stripCtl = (s: string) => s.replace(/[\x00-\x1f\x7f]/g, "");
+
+/**
+ * Labels are delimited by backticks in the file, so a backtick inside one would
+ * corrupt the format on the next write. Strip them here rather than at the write
+ * site, so a hand-written ``- `` — x`` (empty backticks) also collapses to an
+ * empty label and gets dropped instead of becoming a junk `` `` `` button.
+ */
+const cleanLabel = (s: string) => stripCtl(s).replace(/`/g, "").slice(0, MAX_LABEL).trim();
+
+/** Markdown list item: canonical ``- `label` — text`` plus looser fallbacks. */
+function parseAnswers(md: string): Answer[] {
+ const out: Answer[] = [];
+ const seen = new Set<string>();
+ for (const raw of md.split(/\r?\n/)) {
+ const line = raw.trim();
+ if (!line.startsWith("-") && !line.startsWith("*")) continue;
+ const item = line.replace(/^[-*]\s*/, "");
+ if (!item) continue;
+
+ let label: string;
+ let text: string;
+ const backticked = /^`([^`]{1,64})`\s*(.*)$/.exec(item);
+ if (backticked) {
+ label = backticked[1].trim();
+ text = backticked[2].replace(/^\s*(?:—|--|->|:)\s*/, "").trim();
+ } else {
+ const sep = /\s+(?:—|--|->)\s+|:\s+/.exec(item);
+ if (sep) {
+ label = item.slice(0, sep.index).trim();
+ text = item.slice(sep.index + sep[0].length).trim();
+ } else {
+ label = item.trim();
+ text = label;
+ }
+ }
+ label = cleanLabel(label);
+ text = stripCtl(text).trim() || label;
+ if (!label || seen.has(label.toLowerCase())) continue;
+ seen.add(label.toLowerCase());
+ out.push({ label, text });
+ }
+ return out;
+}
+
+function renderAnswers(list: readonly Answer[]): string {
+ return [
+ "# pi answer buttons",
+ "",
+ "<!-- Clickable buttons in this pane's zellij frame row (OSC 5577), also alt+1..9.",
+ " Format: - `label` — text sent to the agent",
+ " This file is authoritative: it was seeded with answer-picker.ts defaults,",
+ " so deleting a line removes that button. `/answers reload` re-reads it. -->",
+ "",
+ ...list.map((a) => `- \`${a.label}\` — ${a.text}`),
+ "",
+ ].join("\n");
+}
+
+/**
+ * Stable, label-derived button id. Deliberately not positional: if the answer
+ * list changes, an in-flight click for a vanished button must fail the allow-list
+ * rather than resolve to whatever took its index.
+ */
+function buttonId(label: string, idx: number, taken: Set<string>): string {
+ const slug = label
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 24);
+ let id = slug ? `ans-${slug}` : `ans-${idx}`;
+ if (taken.has(id)) id = `${id}-${idx}`.slice(0, 32);
+ return id;
+}
+
+export default function (pi: ExtensionAPI) {
+ let answers: Answer[] = DEFAULTS.map((a) => ({ ...a }));
+ /** id -> index allow-list for the buttons currently declared. */
+ const declared = new Map<string, number>();
+ let filePath: string | undefined;
+ let fromFile = false;
+ let barActive = false;
+ let buttonsShown = false;
+ let isRunning = false;
+ let turn = 0;
+ let lastFireAt = 0;
+ let unsubInput: (() => void) | undefined;
+
+ const inZellij = () => !!process.env.ZELLIJ || !!process.env.ZELLIJ_SESSION_NAME;
+
+ function write(s: string) {
+ try {
+ process.stdout.write(s);
+ } catch {
+ /* closed stdout */
+ }
+ }
+
+ // ── answers file ──────────────────────────────────────────────────────────
+ function resolvePath(ctx: any): string {
+ return path.join(ctx?.cwd || process.cwd(), ANSWERS_FILE);
+ }
+
+ function loadAnswers(ctx: any): { source: "file" | "defaults"; count: number; error?: string } {
+ filePath = resolvePath(ctx);
+ try {
+ if (fs.existsSync(filePath)) {
+ const parsed = parseAnswers(fs.readFileSync(filePath, "utf8"));
+ if (parsed.length > 0) {
+ answers = parsed;
+ fromFile = true;
+ return { source: "file", count: parsed.length };
+ }
+ // Present but unparseable: fall back rather than leave the user with no
+ // buttons, and say so instead of failing silently.
+ answers = DEFAULTS.map((a) => ({ ...a }));
+ fromFile = false;
+ return { source: "defaults", count: answers.length, error: `${ANSWERS_FILE} has no readable entries` };
+ }
+ } catch (err: any) {
+ answers = DEFAULTS.map((a) => ({ ...a }));
+ fromFile = false;
+ return { source: "defaults", count: answers.length, error: err?.message ?? String(err) };
+ }
+ answers = DEFAULTS.map((a) => ({ ...a }));
+ fromFile = false;
+ return { source: "defaults", count: answers.length };
+ }
+
+ /** Persist current answers, creating the file (seeded with defaults) if needed. */
+ function saveAnswers(ctx: any): string | undefined {
+ const p = filePath ?? resolvePath(ctx);
+ try {
+ fs.writeFileSync(p, renderAnswers(answers), "utf8");
+ filePath = p;
+ fromFile = true;
+ return undefined;
+ } catch (err: any) {
+ return err?.message ?? String(err);
+ }
+ }
+
+ // ── OSC 5577 bar ──────────────────────────────────────────────────────────
+ function showButtons() {
+ if (!inZellij()) return;
+ declared.clear();
+ const spec: string[] = [];
+ answers.slice(0, MAX_BUTTONS).forEach((a, idx) => {
+ const id = buttonId(a.label, idx, new Set(declared.keys()));
+ declared.set(id, idx);
+ spec.push(`${id}:${escLabel(a.label)}`);
+ });
+ if (spec.length === 0) {
+ hideButtons();
+ return;
+ }
+ write(`${OSC}buttons;${spec.join(";")}${ST}`);
+ buttonsShown = true;
+ barActive = true;
+ }
+
+ /**
+ * Drop ONLY the buttons, keeping the status text. `buttons` with no id:label
+ * field parses to an empty list in the fork (parse_status_bar_buttons
+ * filter_maps every field lacking a ':'), whereas `clear` is the one verb that
+ * would also wipe the status.
+ *
+ * Clearing `declared` matters as much as the redraw: a click report already in
+ * flight when the run started must not fire against a button that is no longer
+ * on screen.
+ */
+ function hideButtons() {
+ declared.clear();
+ if (!inZellij() || !buttonsShown) return;
+ buttonsShown = false;
+ write(`${OSC}buttons${ST}`);
+ barActive = true;
+ }
+
+ function setStatus() {
+ if (!inZellij()) return;
+ const state = isRunning ? "working" : "ready";
+ const extra = answers.length > MAX_BUTTONS ? ` · +${answers.length - MAX_BUTTONS} via alt+N` : "";
+ write(`${OSC}status;${stripCtl(`alt+N · ${state}${turn ? ` · #${turn}` : ""}${extra}`).slice(0, MAX_STATUS)}${ST}`);
+ barActive = true;
+ }
+
+ function clearBar() {
+ if (!barActive) return;
+ barActive = false;
+ buttonsShown = false;
+ declared.clear();
+ write(`${OSC}clear${ST}`);
+ }
+
+ /**
+ * Assert the bar for the current state. Buttons exist only between turns:
+ * while the agent is working they are withdrawn, so a click cannot queue a
+ * second answer behind the one already in flight. The status text stays up
+ * throughout, which is also what advertises that alt+N still works.
+ */
+ function refresh() {
+ if (isRunning) hideButtons();
+ else showButtons();
+ setStatus();
+ }
+
+ function fire(idx: number) {
+ const a = answers[idx];
+ if (!a) return;
+ const now = Date.now();
+ if (now - lastFireAt < DEBOUNCE_MS) return;
+ lastFireAt = now;
+ if (isRunning) pi.sendUserMessage(a.text, { deliverAs: MIDRUN_DELIVERY });
+ else pi.sendUserMessage(a.text);
+ }
+
+ function onInput(data: string): { consume?: boolean; data?: string } | undefined {
+ if (!data.includes("5577")) return undefined;
+ let residual = "";
+ let cursor = 0;
+ let saw = false;
+ CLICK_RE.lastIndex = 0;
+ let m: RegExpExecArray | null;
+ while ((m = CLICK_RE.exec(data)) !== null) {
+ saw = true;
+ residual += data.slice(cursor, m.index);
+ cursor = m.index + m[0].length;
+ const idx = declared.get(m[1]);
+ if (idx !== undefined) fire(idx); // unknown id: swallowed, never acted on
+ }
+ if (!saw) return undefined;
+ residual += data.slice(cursor);
+ return residual.length === 0 ? { consume: true } : { data: residual };
+ }
+
+ // ── /answers list rendering ───────────────────────────────────────────────
+ // A ▪ marks an answer that has a clickable button; · marks one only reachable
+ // by alt+N, because the protocol's 8-button row is full. Bodies are truncated
+ // to the pane width and wrapped in full when the entry is expanded (ctrl+o).
+ pi.registerEntryRenderer<AnswersListData>("answers-list", (entry, { expanded }, theme) => {
+ const d = entry.data;
+ if (!d) return undefined;
+ return {
+ render(width: number): string[] {
+ const IND = " ";
+ const srcLabel = d.source === "file" ? path.basename(d.path) : "built-in defaults";
+ const lines: string[] = [];
+
+ lines.push(
+ `${IND}${theme.fg("accent", "◈")} ${theme.fg("accent", theme.bold("answers"))}` +
+ theme.fg("dim", ` ${d.answers.length} · ${srcLabel}`),
+ );
+ lines.push("");
+
+ if (d.answers.length === 0) {
+ lines.push(`${IND}${theme.fg("warning", "(none)")} ${theme.fg("dim", "— /answers init restores the defaults")}`);
+ } else {
+ const idxW = String(d.answers.length).length;
+ const labelW = Math.min(20, Math.max(...d.answers.map((a) => a.label.length)));
+ d.answers.forEach((a, i) => {
+ const isBtn = i < d.maxButtons;
+ const marker = isBtn ? theme.fg("accent", "▪") : theme.fg("dim", "·");
+ const num = String(i + 1).padStart(idxW);
+ const label = a.label.length > labelW ? `${a.label.slice(0, labelW - 1)}…` : a.label.padEnd(labelW);
+ const prefix = `${IND}${marker} ${theme.fg("dim", num)} ${theme.fg(isBtn ? "accent" : "muted", label)} `;
+ const avail = Math.max(8, width - visibleWidth(prefix) - 2);
+ if (expanded) {
+ const wrapped = wrapTextWithAnsi(a.text, avail);
+ lines.push(prefix + theme.fg("muted", wrapped[0] ?? ""));
+ for (const cont of wrapped.slice(1)) {
+ lines.push(" ".repeat(visibleWidth(prefix)) + theme.fg("muted", cont));
+ }
+ } else {
+ lines.push(prefix + theme.fg("muted", truncateToWidth(a.text, avail, "…")));
+ }
+ });
+ }
+
+ lines.push("");
+ lines.push(
+ `${IND}${theme.fg("dim", d.source === "file" ? d.path : `${d.path} (created on first add)`)}`,
+ );
+ if (d.answers.length > d.maxButtons) {
+ lines.push(`${IND}${theme.fg("dim", `▪ button · alt+N only — the frame row holds ${d.maxButtons}`)}`);
+ }
+ lines.push(`${IND}${theme.fg("dim", "/answers add <label> [text…] · rm <label> · reload")}`);
+ // Safety net: the header, footer and legend are fixed strings, so on a
+ // narrow pane they would wrap and visually break the entry. Clamp every
+ // line to the width (ANSI-aware) rather than trusting each producer.
+ return lines.map((l) => (visibleWidth(l) > width ? truncateToWidth(l, width, "…") : l));
+ },
+ invalidate() {},
+ };
+ });
+
+ // ── /answers ──────────────────────────────────────────────────────────────
+ const SUBS = [
+ { value: "list", label: "list", description: "show current answers and their source" },
+ { value: "add", label: "add <label> [text…]", description: "add an answer and persist it" },
+ { value: "rm", label: "rm <label>", description: "remove an answer and persist it" },
+ { value: "reload", label: "reload", description: `re-read ${ANSWERS_FILE}` },
+ { value: "init", label: "init", description: `create ${ANSWERS_FILE} from the defaults` },
+ { value: "refresh", label: "refresh", description: "re-assert the buttons in the frame" },
+ ];
+
+ function summary(): string {
+ const src = fromFile ? ANSWERS_FILE : "built-in defaults";
+ const shown = Math.min(answers.length, MAX_BUTTONS);
+ return `${answers.length} answers from ${src} (${shown} as buttons)`;
+ }
+
+ /**
+ * Render the list into the transcript rather than a one-line notification:
+ * the bodies are the useful part and they do not fit on one row. appendEntry
+ * keeps it out of the LLM context while staying scrollable and copyable.
+ */
+ function showList(ctx: any) {
+ pi.appendEntry<AnswersListData>("answers-list", {
+ answers: answers.map((a) => ({ ...a })),
+ source: fromFile ? "file" : "defaults",
+ path: filePath ?? resolvePath(ctx),
+ maxButtons: MAX_BUTTONS,
+ });
+ }
+
+ pi.registerCommand("answers", {
+ description: "Manage the clickable answer buttons (list/add/rm/reload/init)",
+ getArgumentCompletions: (prefix: string) => {
+ const first = prefix.trimStart();
+ if (first.includes(" ")) {
+ // completing a label for rm
+ const [sub, ...rest] = first.split(/\s+/);
+ if (sub === "rm" || sub === "remove" || sub === "del") {
+ const p = (rest[0] ?? "").toLowerCase();
+ return answers
+ .filter((a) => a.label.toLowerCase().startsWith(p))
+ .map((a) => ({ value: `rm ${a.label}`, label: a.label, description: a.text }));
+ }
+ return null;
+ }
+ return SUBS.filter((s) => s.value.startsWith(first));
+ },
+ handler: async (args: string, ctx: any) => {
+ const notify = (msg: string, kind: "info" | "warning" | "error" = "info") => {
+ if (ctx.hasUI) ctx.ui.notify(msg, kind);
+ };
+ const parts = args.trim().split(/\s+/).filter(Boolean);
+ const sub = (parts[0] ?? "list").toLowerCase();
+ const rest = parts.slice(1);
+
+ if (sub === "list") {
+ showList(ctx);
+ return;
+ }
+
+ if (sub === "refresh") {
+ refresh();
+ notify(isRunning ? "bar re-asserted — buttons return when this turn settles" : `answer buttons re-asserted (${declared.size})`);
+ return;
+ }
+
+ if (sub === "reload") {
+ const res = loadAnswers(ctx);
+ refresh();
+ if (res.error) notify(`${res.error} — using defaults`, "warning");
+ showList(ctx);
+ return;
+ }
+
+ if (sub === "init") {
+ if (fs.existsSync(resolvePath(ctx))) {
+ notify(`${ANSWERS_FILE} already exists — use /answers reload`, "warning");
+ return;
+ }
+ const err = saveAnswers(ctx);
+ refresh();
+ notify(err ? `could not write ${ANSWERS_FILE}: ${err}` : `wrote ${filePath} with ${answers.length} defaults`, err ? "error" : "info");
+ return;
+ }
+
+ if (sub === "add") {
+ const label = cleanLabel(rest[0] ?? "");
+ if (!label) {
+ notify("usage: /answers add <label> [text…] (text defaults to the label)", "warning");
+ return;
+ }
+ const text = stripCtl(rest.slice(1).join(" ")).trim() || label;
+ // First write seeds the file with the defaults + this answer, which is
+ // what makes "the file always wins" safe to state without caveats.
+ const existing = answers.findIndex((a) => a.label.toLowerCase() === label.toLowerCase());
+ if (existing >= 0) answers[existing] = { label, text };
+ else answers.push({ label, text });
+ const err = saveAnswers(ctx);
+ refresh();
+ if (err) {
+ notify(`added "${label}" for this session, but could not write ${ANSWERS_FILE}: ${err}`, "error");
+ return;
+ }
+ const overflow = answers.length > MAX_BUTTONS ? ` (button row is full — reachable via alt+${answers.length})` : "";
+ notify(`${existing >= 0 ? "updated" : "added"} "${label}" → ${filePath}${overflow}`);
+ return;
+ }
+
+ if (sub === "rm" || sub === "remove" || sub === "del") {
+ const label = (rest[0] ?? "").toLowerCase();
+ const at = answers.findIndex((a) => a.label.toLowerCase() === label);
+ if (at < 0) {
+ notify(`no answer labelled "${rest[0] ?? ""}"`, "warning");
+ return;
+ }
+ const [gone] = answers.splice(at, 1);
+ // Persist even when removing a default: the file is the record of intent,
+ // so a removed default must be written down or it returns next session.
+ const err = saveAnswers(ctx);
+ refresh();
+ notify(err ? `removed "${gone.label}" for this session, but could not write ${ANSWERS_FILE}: ${err}` : `removed "${gone.label}" → ${filePath}`, err ? "error" : "info");
+ return;
+ }
+
+ notify(`unknown subcommand "${sub}" — try list / add / rm / reload / init / refresh`, "warning");
+ },
+ });
+
+ // Registered once against a live lookup, so answers added at runtime work
+ // immediately without re-registering shortcuts.
+ NUDGE_KEYS.forEach((key, idx) => {
+ pi.registerShortcut(key, { description: `Answer #${idx + 1}`, handler: () => fire(idx) });
+ });
+
+ pi.on("input", async (event) => {
+ if (!event.text) return;
+ turn++;
+ setStatus();
+ });
+
+ // Withdraw the buttons for the whole in-flight window, and put them back when
+ // the run settles. agent_settled is the important half of that pair: it fires
+ // on the abort/error paths where agent_end never arrives, so an escape-key
+ // cancel or a provider failure still gets the buttons back.
+ pi.on("agent_start", async () => {
+ isRunning = true;
+ refresh();
+ });
+ const settle = () => {
+ isRunning = false;
+ refresh();
+ };
+ pi.on("agent_end", async () => settle());
+ pi.on("agent_settled", async () => settle());
+
+ pi.on("session_start", async (_e, ctx) => {
+ isRunning = false;
+ turn = 0;
+ const res = loadAnswers(ctx);
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
+
+ unsubInput?.();
+ unsubInput = ctx.ui.onTerminalInput?.(onInput);
+
+ refresh();
+ if (res.error) ctx.ui.notify(`answer picker: ${res.error} — using defaults`, "warning");
+ });
+
+ pi.on("session_shutdown", async () => {
+ clearBar();
+ unsubInput?.();
+ unsubInput = undefined;
+ });
+}
diff --git a/pi-ext/attic/answer-picker-socket.ts b/pi-ext/attic/answer-picker-socket.ts
new file mode 100644
index 0000000..4494657
--- /dev/null
+++ b/pi-ext/attic/answer-picker-socket.ts
@@ -0,0 +1,347 @@
+/**
+ * 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<net.Socket>();
+ 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());
+}
diff --git a/pi-ext/attic/answer-picker-standalone b/pi-ext/attic/answer-picker-standalone
new file mode 100755
index 0000000..a688ba5
--- /dev/null
+++ b/pi-ext/attic/answer-picker-standalone
@@ -0,0 +1,341 @@
+#!/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 <socket-path> [--selftest]
+ * PI_ANSWER_PICKER_SOCKET=<path> 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();