diff --git a/packages/cli/package.json b/packages/cli/package.json index 50e8711c..813ed6b7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.10.6", + "version": "0.10.7", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/picker.ts b/packages/cli/src/picker.ts index 3573eb00..1dce2df1 100644 --- a/packages/cli/src/picker.ts +++ b/packages/cli/src/picker.ts @@ -124,194 +124,8 @@ function getTTYCols(ttyFd: number): number { * fd) but returns void so callers can `await` it uniformly. */ export function pickToTTY(config: PickConfig): string | null { - if (config.options.length === 0) { - return config.defaultValue ?? null; - } - - // ── open /dev/tty ────────────────────────────────────────────────────────── - let ttyFd: number; - try { - ttyFd = fs.openSync("/dev/tty", "r+"); - } catch { - return pickFallback(config); - } - - // ── save terminal settings ──────────────────────────────────────────────── - const savedRes = spawnSync( - "stty", - [ - "-g", - ], - { - stdio: [ - ttyFd, - "pipe", - "pipe", - ], - }, - ); - if (savedRes.status !== 0 || !savedRes.stdout) { - fs.closeSync(ttyFd); - return pickFallback(config); - } - const savedSettings = savedRes.stdout.toString().trim(); - - // ── enable raw / no-echo mode ───────────────────────────────────────────── - const rawRes = spawnSync( - "stty", - [ - "raw", - "-echo", - ], - { - stdio: [ - ttyFd, - "pipe", - "pipe", - ], - }, - ); - if (rawRes.status !== 0) { - fs.closeSync(ttyFd); - return pickFallback(config); - } - - // ── helpers ─────────────────────────────────────────────────────────────── - const w = (s: string) => { - try { - fs.writeSync(ttyFd, s); - } catch {} - }; - - const restore = () => { - try { - spawnSync( - "stty", - [ - savedSettings, - ], - { - stdio: [ - ttyFd, - "pipe", - "pipe", - ], - }, - ); - } catch {} - w(A.showC); - try { - fs.closeSync(ttyFd); - } catch {} - }; - - // ── initial state ───────────────────────────────────────────────────────── - let selected = 0; - if (config.defaultValue) { - const idx = config.options.findIndex((o) => o.value === config.defaultValue); - if (idx >= 0) { - selected = idx; - } - } - - const cols = getTTYCols(ttyFd); - const maxW = cols - 1; // leave 1 char margin to prevent terminal auto-wrap - - // header line + one line per option + footer line - const pickerHeight = config.options.length + 2; - - const render = (first: boolean) => { - if (!first) { - w(A.up(pickerHeight) + A.col1 + A.clearBelow); - } - w(`${A.bold}${A.cyan}? ${trunc(config.message, maxW - 2)}${A.reset}\r\n`); - for (let i = 0; i < config.options.length; i++) { - const opt = config.options[i]; - if (i === selected) { - const label = trunc(opt.label, maxW - 2); - w(`${A.green}${A.bold}> ${label}${A.reset}`); - if (opt.hint) { - const remaining = maxW - 2 - label.length - 2; - if (remaining > 3) { - w(` ${A.dim}${trunc(opt.hint, remaining)}${A.reset}`); - } - } - } else { - w(` ${A.dim}${trunc(opt.label, maxW - 2)}${A.reset}`); - } - w("\r\n"); - } - w(`${A.dim} ${trunc("\u2191/\u2193 move \u23ce select Ctrl-C cancel", maxW - 2)}${A.reset}\r\n`); - }; - - // ── render & key loop ───────────────────────────────────────────────────── - w(A.hideC); - render(true); - - const buf = Buffer.alloc(8); - let result: string | null = null; - - try { - // Synchronous blocking read loop — each iteration waits for one keypress. - // Arrow keys (\x1b[A / \x1b[B) arrive as a single read() because the - // terminal driver delivers escape sequences atomically. - outer: while (true) { - let n: number; - try { - n = fs.readSync(ttyFd, buf, 0, 8); - } catch { - break; - } - if (n === 0) { - continue; - } - - const key = buf.slice(0, n).toString("binary"); - - switch (key) { - // ── cancel ───────────────────────────────────────────────────────── - case "\x03": // Ctrl-C - case "\x1b": // standalone Escape - break outer; - - // ── confirm ──────────────────────────────────────────────────────── - case "\r": - case "\n": { - result = config.options[selected].value; - // Replace picker with a one-line confirmation - w(A.up(pickerHeight) + A.col1 + A.clearBelow); - const opt = config.options[selected]; - w( - `${A.green}${A.bold}> ${config.message}:${A.reset} ` + - `${A.cyan}${trunc(opt.label, maxW - config.message.length - 4)}${A.reset}\r\n`, - ); - break outer; - } - - // ── navigation ───────────────────────────────────────────────────── - case "\x1b[A": // Up (CSI) - case "\x1bOA": // Up (SS3, some terminals) - case "k": // vim-style - selected = (selected - 1 + config.options.length) % config.options.length; - render(false); - break; - - case "\x1b[B": // Down (CSI) - case "\x1bOB": // Down (SS3) - case "j": // vim-style - selected = (selected + 1) % config.options.length; - render(false); - break; - - default: - break; - } - } - } finally { - restore(); - } - - return result; + const result = pickToTTYWithActions(config); + return result.action === "select" ? result.value : null; } /**