aboutsummaryrefslogtreecommitdiffstats
path: root/pi-ext/attic/answer-picker-socket.ts
blob: 4494657fe886425e0fece15bebb360ba641c0c00 (plain) (blame)
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
342
343
344
345
346
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());
}