1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
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();
|