mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-19 08:01:17 +00:00
refactor: deduplicate pickToTTY as wrapper around pickToTTYWithActions (#1891)
Fixes #1890 Agent: complexity-hunter Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
fda44b10f7
commit
d423ea57d5
2 changed files with 3 additions and 189 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.10.6",
|
||||
"version": "0.10.7",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue