From 6a08f46ec87e47e3edd42fc9608b1593164c77d8 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 30 Aug 2026 02:07:27 +0000 Subject: [PATCH] fix(mirror): let the session page type into a running engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arrow keys sent from app.moshcode.sh/sessions never reached an engine, so Claude's "do you trust this folder?" arrived on the page sitting on No with no way to move off it. The keys were being delivered; they were delivered to the wrong place. pressKey ended in `stdin.emit("data", …)`, a synthesised event on this process's own stdin object. Readline hears that. A child spawned with `stdio: "inherit"` reads a file descriptor and hears nothing, and by then the pit's readline had been closed for the hand-off, so ↓ went nowhere at all. So own the child's stdin. captureSpec grows an `input` option that puts a fifo there instead of the tty, which script(1) reads and copies to the pty master exactly as it would a terminal. Two things have to be paid back for that, and both are, rather than being written off: - script takes the pty's geometry from its own stdin, and a fifo has none, so the child would start on a 0x0 terminal. It sizes itself on the way in with stty, which can ioctl the master from inside where we cannot from out here. - script can no longer forward SIGWINCH for the same reason. The child records its pty path on the way in, so a real window resize still reaches it via `stty -F`. The person at the keyboard keeps working throughout: local stdin is relayed byte-for-byte into the same fifo, in raw mode, because the pty on the far end is now the one echoing and splitting lines. Cursor keys are sent in the form the child asked for. A full-screen program usually sets DECCKM on its way in and then wants ESC O B rather than ESC [ B; a real terminal obliges silently, which is why this only shows up once something starts synthesising keys. Fed the CSI form in that mode `less` does not scroll, it prints "ESC[B" on its prompt line. The mode is read off the same output stream that already goes to the mirror. Typed lines follow the keys: with an engine up they go to it rather than parking for a prompt that will not come back until it exits. Unmirrored pits, and boxes with no script(1) we can drive, keep the plain inherited launch they have today. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y5jnkZKX4AdPgBMzMosxE7 --- src/engines.mjs | 23 ++- src/mirror.mjs | 39 +++- src/pty.mjs | 248 ++++++++++++++++++++++- src/tui.mjs | 15 +- test/mirror-engine-input.test.mjs | 314 ++++++++++++++++++++++++++++++ 5 files changed, 621 insertions(+), 18 deletions(-) create mode 100644 test/mirror-engine-input.test.mjs diff --git a/src/engines.mjs b/src/engines.mjs index 3872e1b..5effb5d 100644 --- a/src/engines.mjs +++ b/src/engines.mjs @@ -36,6 +36,7 @@ import { existsSync, readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; +import { setActiveChildInput } from "./mirror.mjs"; import { captureSpec } from "./pty.mjs"; export const ENGINES = { @@ -509,11 +510,27 @@ export function openPassthrough(target, args = [], { onOutput } = {}) { // the child the tty's own file descriptors, so none of its bytes ever pass // through this process. See src/pty.mjs for why this is script(1) and not // a pipe or node-pty. - const launch = captureSpec(spec, onOutput); - const cleanup = () => launch.stop(); + // + // `input` asks for the same pty to be one we can type into. An engine is + // the whole reason it exists: it puts up menus and trust prompts that only + // move for a keypress, and until we owned its stdin a session page could + // watch one of those appear and had no way to answer it. + const launch = captureSpec(spec, onOutput, { input: true }); + // Route web keys here for as long as this child is up, and only when there + // is really somewhere for them to go — an unmirrored pit, or a box with no + // `script(1)`, still runs the plain inherited launch, and saying otherwise + // would have pressKey silently swallow keys the pit could have handled. + // Registered before the spawn on purpose: the fifo buffers, so a key that + // arrives while the engine is still starting is delivered, not dropped. + const typeable = launch.stdio !== "inherit"; + if (typeable) setActiveChildInput(launch.write); + const cleanup = () => { + if (typeable) setActiveChildInput(null); + launch.stop(); + }; let child; - try { child = spawn(launch.cmd, launch.args, { stdio: "inherit", env }); } + try { child = spawn(launch.cmd, launch.args, { stdio: launch.stdio, env }); } catch (e) { cleanup(); resolve({ ok: false, error: e }); return; } child.on("error", (e) => { cleanup(); resolve({ ok: false, error: e }); }); child.on("exit", (code, signal) => { cleanup(); resolve({ ok: true, code, signal }); }); diff --git a/src/mirror.mjs b/src/mirror.mjs index f0a5c09..618e230 100644 --- a/src/mirror.mjs +++ b/src/mirror.mjs @@ -6,9 +6,12 @@ // every network call is swallowed, because a flaky link must never take down // the terminal you're actually working in. // -// What it can't see: once an engine takes the terminal (`/agents claude`), the -// child writes straight to the tty on its own fd — those bytes never pass -// through this process. The mirror shows the hand-off, not the engine's screen. +// A child that takes the terminal (`/agents claude`) writes straight to the tty +// on its own fd, so none of its bytes pass through this process on their own. +// Both directions are handled in src/pty.mjs instead: its output is copied out +// of a pty transcript, and its stdin is a fifo we hold, which is what lets a key +// pressed on the session page answer an engine's prompt rather than land in the +// pit behind it. import os from "node:os"; import { loadCreds } from "./auth.mjs"; @@ -30,7 +33,7 @@ export function decodeKey(body) { // What each key looks like to a program reading the tty in raw mode, and the // keypress readline wants when it is the one holding the line. -const KEY_BYTES = { up: "\u001b[A", down: "\u001b[B", right: "\u001b[C", left: "\u001b[D", enter: "\r" }; +export const KEY_BYTES = { up: "\u001b[A", down: "\u001b[B", right: "\u001b[C", left: "\u001b[D", enter: "\r" }; const KEY_PRESS = { up: { name: "up" }, down: { name: "down" }, right: { name: "right" }, left: { name: "left" }, enter: { name: "return" }, @@ -43,6 +46,14 @@ const KEY_PRESS = { export function pressKey(name, rl = null, stdin = process.stdin) { const bytes = KEY_BYTES[name]; if (!bytes) return false; + // A child engine takes precedence over everything below, because when one is + // running it is the thing the person on the session page can see. It reads a + // real file descriptor rather than this process's stdin object, so the bytes + // have to be *written* — the synthesised event further down reaches readline + // and the pit's own raw-mode readers, and nothing that was spawned. This is + // the line that decides whether an arrow key lands on Claude's trust prompt. + const toChild = activeChildInput(); + if (toChild && toChild(bytes)) return true; // At the prompt readline owns the line editor, so hand it a keypress rather // than bytes: ↑/↓ walk the history, ←/→ move within the line, enter runs it. if (rl) { @@ -78,6 +89,26 @@ export function activeChildSink() { return activeSink; } +// The other direction: where to put bytes so the program currently holding the +// terminal reads them. +// +// Null almost always, and set only while a child owns the tty under a pty we +// opened (src/pty.mjs captureWithInput). It has to be module-level for the same +// reason the sink does — pressKey is called from the mirror's poll loop, which +// has no idea which launcher is mid-flight — and it is what makes a key pressed +// on the session page land in an engine rather than in the pit behind it. +let activeInput = null; + +/** Point web keystrokes at a running child (or null when it exits). */ +export function setActiveChildInput(write) { + activeInput = typeof write === "function" ? write : null; +} + +/** How to type into whatever child owns the terminal, or null for the pit. */ +export function activeChildInput() { + return activeInput; +} + export function createMirror({ version = "", cwd = process.cwd(), diff --git a/src/pty.mjs b/src/pty.mjs index fc6b161..ddddac5 100644 --- a/src/pty.mjs +++ b/src/pty.mjs @@ -19,7 +19,10 @@ // `script` disagree on both flag names and argument order, and anything we // cannot positively identify falls back to today's plain `inherit`. import { spawnSync } from "node:child_process"; -import { closeSync, existsSync, mkdtempSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { + closeSync, constants, existsSync, mkdtempSync, openSync, + readFileSync, readSync, rmSync, statSync, writeFileSync, writeSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { StringDecoder } from "node:string_decoder"; @@ -78,6 +81,18 @@ export function ptySpec(cmd, args = [], transcript, flavor) { return null; } +/** + * The same thing for a shell *line* rather than an argv, which the input path + * needs: it prefixes the child with `stty` and `tty` so the session sizes + * itself and says where it landed, and those only exist as shell. + */ +export function ptyShellSpec(command, transcript, flavor) { + if (!command || !transcript) return null; + if (flavor === "util-linux") return { cmd: "script", args: ["-q", "-e", "-f", "-c", command, transcript] }; + if (flavor === "bsd") return { cmd: "script", args: ["-q", "-F", transcript, "sh", "-c", command] }; + return null; +} + /** * Follow a transcript as it is written, handing each new slice to `onChunk`. * @@ -197,16 +212,29 @@ export function ptyEnabled(sink, flavor = scriptFlavor()) { * of how this started, where each launcher had to be taught separately and only * two ever were. Pass `null` to opt a launch out. * - * Returns `{ cmd, args, stop }`. With nothing watching, or on a box with no - * `script(1)` we can drive, `cmd`/`args` come back exactly as passed in and - * `stop` is a no-op — the caller spawns what it always spawned. `stop()` must - * be called once the child exits: it drains the tail of the transcript (the - * last lines of a command are usually the ones you were waiting for) and - * removes the temp dir. + * Returns `{ cmd, args, stdio, write, stop }`. With nothing watching, or on a + * box with no `script(1)` we can drive, `cmd`/`args` come back exactly as + * passed in, `stdio` is "inherit" and `write` returns false — the caller spawns + * what it always spawned. `stop()` must be called once the child exits: it + * drains the tail of the transcript (the last lines of a command are usually + * the ones you were waiting for) and removes the temp dir. + * + * `input: true` additionally makes the child's stdin something we can type + * into, so the session page can drive it — see captureWithInput. */ -export function captureSpec({ cmd, args = [] }, onOutput = activeChildSink(), { flavor = scriptFlavor() } = {}) { - const plain = { cmd, args, stop: () => {} }; +export function captureSpec( + { cmd, args = [] }, + onOutput = activeChildSink(), + { flavor = scriptFlavor(), input = false, stdin = process.stdin, stdout = process.stdout } = {}, +) { + const plain = { cmd, args, stdio: "inherit", write: () => false, stop: () => {} }; if (!ptyEnabled(onOutput, flavor)) return plain; + if (input) { + const withInput = captureWithInput({ cmd, args }, onOutput, { flavor, stdin, stdout }); + if (withInput) return withInput; + // No fifo, no local tty, nothing we could drive — fall through to the + // output-only capture rather than dropping capture altogether. + } let workDir = null; try { workDir = mkdtempSync(path.join(tmpdir(), "moshcode-pty-")); @@ -224,6 +252,8 @@ export function captureSpec({ cmd, args = [] }, onOutput = activeChildSink(), { return { cmd: wrapped.cmd, args: wrapped.args, + stdio: "inherit", + write: () => false, stop() { try { stopFollow(); } catch { /* nothing left to drain */ } try { rmSync(dir, { recursive: true, force: true }); } catch { /* temp dir */ } @@ -235,3 +265,203 @@ export function captureSpec({ cmd, args = [] }, onOutput = activeChildSink(), { return plain; } } + +// --------------------------------------------------------------------------- +// Typing into the child +// --------------------------------------------------------------------------- + +/** A terminal geometry we can hand a child, with a sane floor. */ +function geometry(stdout) { + return { cols: Number(stdout?.columns) || 80, rows: Number(stdout?.rows) || 24 }; +} + +// Application cursor keys (DECCKM). A program that turns this on is saying "send +// me ESC O B for down, not ESC [ B", and a terminal obliges — which is why the +// distinction never comes up for the person at the keyboard, and why it bites +// the moment we start synthesising keys ourselves. `less` is the plain +// demonstration: fed the CSI form while it has DECCKM set, it does not scroll, +// it prints "ESC[B" on its own prompt line as if you had typed the characters. +// +// The mode is not something we can ask about, but it is announced: the child +// writes the escape on its way into full-screen mode, and every byte it writes +// is already passing under our nose on the way to the mirror. +const DECCKM = /\u001b\[\?1([hl])/g; + +/** Track a DECCKM change announced in `text`; returns the mode after it. */ +export function cursorKeyMode(text, current = false) { + const seen = [...String(text).matchAll(DECCKM)].pop(); + return seen ? seen[1] === "h" : current; +} + +/** + * Rewrite CSI cursor keys as SS3, for a child that asked for application mode. + * + * Only the four cursor keys move: everything else, including a literal ESC and + * anything the person at the keyboard typed, is left exactly as it arrived. + */ +export function toApplicationCursor(buf) { + const out = Buffer.from(buf); + for (let i = 0; i + 2 < out.length; i += 1) { + // ESC [ A|B|C|D -> ESC O A|B|C|D + if (out[i] === 0x1b && out[i + 1] === 0x5b && out[i + 2] >= 0x41 && out[i + 2] <= 0x44) { + out[i + 1] = 0x4f; + } + } + return out; +} + +/** + * The same capture, but with a stdin the mirror can write to. + * + * `inherit` hands the child the tty's own file descriptors, which is why a key + * pressed on the session page could never reach it: there is no fd in this + * process between the browser and the program, so the best the mirror could do + * was synthesise a `data` event on its own `process.stdin` — which the pit's + * readline hears and a child does not (see pressKey in src/mirror.mjs). To type + * into an engine we have to own its stdin, and a fifo is the one way to do that + * with nothing but the base system: `script(1)` reads it and copies it to the + * pty master, exactly as it would a terminal. + * + * Owning stdin costs two things back, and both are paid here rather than + * written off as limitations: + * + * - Size. `script` takes the pty's geometry from its own stdin, and a fifo has + * none, so the child would start on a 0x0 terminal — which full-screen + * engines do not survive. Nothing outside a pty can ioctl its master, but + * `stty` inside it can, so the session sizes itself on the way in. + * - Resize. For the same reason `script` can no longer forward SIGWINCH. The + * child records its pty path on the way in, which is enough to resize it + * from out here with `stty -F` when the real window changes, so dragging a + * window edge still reaches the engine. + * + * The person at the keyboard has to keep working throughout, so local stdin is + * relayed byte-for-byte into the same fifo. That means raw mode: this tty has + * to stop echoing and stop buffering lines, because the pty on the other end is + * now the one doing both. + * + * Returns null when this box can't do it (no `mkfifo`, no local tty), which + * leaves the caller on the output-only path it had before. + */ +export function captureWithInput({ cmd, args = [] }, onOutput, { flavor, stdin, stdout } = {}) { + // Without a local terminal there is nothing to relay and raw mode is + // meaningless, so capture alone is the honest thing to offer. + if (!stdin?.isTTY || typeof stdin.setRawMode !== "function") return null; + + let workDir = null; + let fd = null; + try { + workDir = mkdtempSync(path.join(tmpdir(), "moshcode-pty-")); + const transcript = path.join(workDir, "transcript"); + const fifo = path.join(workDir, "input"); + const ptsFile = path.join(workDir, "pts"); + writeFileSync(transcript, ""); + + // node has no mkfifo, so this is the one call out to the system — and a box + // without it simply does not get the input path. + const made = spawnSync("mkfifo", [fifo]); + if (made.error || made.status !== 0) throw new Error("no mkfifo on this box"); + + const { cols, rows } = geometry(stdout); + const command = [ + `tty > ${shQuote(ptsFile)} 2>/dev/null`, + `stty rows ${rows} cols ${cols} 2>/dev/null`, + // exec, so the engine *is* the process script is waiting on: its signals + // and its exit status pass straight through rather than via a shell. + `exec ${[cmd, ...args].map(shQuote).join(" ")}`, + ].join("; "); + const wrapped = ptyShellSpec(command, transcript, flavor); + if (!wrapped) throw new Error("no script(1) spec for this flavour"); + + // O_RDWR, not O_WRONLY: opening a fifo write-only blocks until a reader + // arrives, and the reader here is a child we have not spawned yet. Holding + // both ends also keeps the child from seeing EOF between writes. + fd = openSync(fifo, constants.O_RDWR); + + let first = true; + // Which form of cursor key this child is asking for, learned from the same + // stream that goes to the mirror. Tracked on the raw chunk rather than the + // banner-stripped one: the mode switch is a control sequence, and nothing + // about the banner is in its way. + let appCursor = false; + const stopFollow = followFile(transcript, (chunk) => { + appCursor = cursorKeyMode(chunk, appCursor); + const clean = stripScriptBanner(chunk, first); + first = false; + if (clean) onOutput(clean); + }); + + let stopped = false; + /** Put bytes in front of the child, from the web or from the keyboard. */ + const write = (data) => { + if (stopped || fd === null) return false; + const raw = typeof data === "string" ? Buffer.from(data, "latin1") : Buffer.from(data); + try { writeSync(fd, appCursor ? toApplicationCursor(raw) : raw); return true; } + catch { return false; } + }; + + // Raw, because the pty on the far end is now the one echoing and the one + // splitting lines. Leaving this tty cooked would double every character and + // hold Enter back until the child had already redrawn without it. + const wasRaw = Boolean(stdin.isRaw); + stdin.setRawMode(true); + stdin.resume(); + const onData = (buf) => { write(buf); }; + stdin.on("data", onData); + + // The child's own tty, once its prelude has written it down. Read lazily: + // at the moment we spawn, that file does not exist yet. + let pts = null; + const childTty = () => { + if (pts) return pts; + try { pts = readFileSync(ptsFile, "utf8").trim() || null; } catch { pts = null; } + return pts; + }; + let resizeTimer = null; + const onResize = () => { + clearTimeout(resizeTimer); + // Dragging an edge fires this continuously; settle for one ioctl per drag. + resizeTimer = setTimeout(() => { + resizeTimer = null; + const tty = childTty(); + if (!tty || stopped) return; + const size = geometry(stdout); + // -F on util-linux, -f on BSD/macOS — the same disagreement as the + // script(1) flags above, and getting it wrong here is a usage error on + // every resize rather than anything visible. + const on = flavor === "bsd" ? "-f" : "-F"; + try { spawnSync("stty", [on, tty, "rows", String(size.rows), "cols", String(size.cols)]); } + catch { /* the child owns it; a resize we lose is cosmetic */ } + }, 120); + resizeTimer.unref?.(); + }; + stdout?.on?.("resize", onResize); + + const dir = workDir; + return { + cmd: wrapped.cmd, + args: wrapped.args, + // The fifo is the child's stdin; its output still goes straight to the + // real terminal, so the engine draws at full speed exactly as before. + stdio: [fd, "inherit", "inherit"], + write, + stop() { + if (stopped) return; + stopped = true; + clearTimeout(resizeTimer); + stdout?.off?.("resize", onResize); + stdin.off("data", onData); + // Hand the terminal back the way we found it. Getting this wrong leaves + // the pit with no echo, which reads as a hung shell. + try { stdin.setRawMode(wasRaw); } catch { /* not a tty any more */ } + stdin.pause(); + try { stopFollow(); } catch { /* nothing left to drain */ } + if (fd !== null) { try { closeSync(fd); } catch { /* already gone */ } fd = null; } + try { rmSync(dir, { recursive: true, force: true }); } catch { /* temp dir */ } + }, + }; + } catch { + if (fd !== null) { try { closeSync(fd); } catch { /* already gone */ } } + if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* temp dir */ } } + return null; + } +} diff --git a/src/tui.mjs b/src/tui.mjs index 94e3620..fd1e6f4 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -18,7 +18,7 @@ import { createPrd, listPrds, authoringPrompt } from "./prd.mjs"; import { loginAuto, whoami, logout } from "./auth.mjs"; import { startAutoSync } from "./autosync.mjs"; import { loadCommand, saveCommand } from "./settings-sync.mjs"; -import { createMirror, pressKey, setActiveSink, teeOutput } from "./mirror.mjs"; +import { activeChildInput, createMirror, pressKey, setActiveSink, teeOutput } from "./mirror.mjs"; import { fetchMotdAd } from "./ads.mjs"; import { runScript } from "./runtime.mjs"; import { moshVocabulary } from "./commands.mjs"; @@ -1332,7 +1332,18 @@ async function startMirror() { promptRl.write(`${body}\n`); } }; - mirror.onCommand((body) => { queue.push(body); drainRemote(); }); + mirror.onCommand((body) => { + // An engine has the terminal: send the line to it rather than parking it + // for a prompt that will not come back until the engine exits. Without this + // the arrow keys could answer a menu but nothing could answer a question, + // which is half a session page. Typed straight in, so it arrives the way + // the keyboard would deliver it — no `▸ (web)` note, because the engine + // echoes it itself and printing over an engine's screen shifts it. + const toChild = activeChildInput(); + if (toChild && toChild(`${body}\r`)) return; + queue.push(body); + drainRemote(); + }); // Keys skip the queue: they are pressed the instant they arrive, whether the // prompt is armed or something else has the tty (a herd bar, the reader, a diff --git a/test/mirror-engine-input.test.mjs b/test/mirror-engine-input.test.mjs new file mode 100644 index 0000000..da5ba73 --- /dev/null +++ b/test/mirror-engine-input.test.mjs @@ -0,0 +1,314 @@ +// A key pressed on the session page has to reach the engine, not the pit +// behind it. +// +// The mirror could always *show* a hand-off — `/agents claude` goes under a pty +// and its screen streams up — but delivery ran the other way through +// `stdin.emit("data", …)`, a synthesised event on this process's own stdin +// object. Readline hears that. A child spawned with `stdio: "inherit"` reads a +// file descriptor and hears nothing, so Claude's "do you trust this folder?" +// arrived on the page defaulted to No with no way to move off it: ↓ went to a +// closed readline in the parent and the prompt never twitched. +// +// These pin the fix at the level it actually failed — real script(1), a real +// child, real escape sequences on a real fd — because every individual piece +// was already sound and it was the wiring between them that was missing. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + captureSpec, captureWithInput, cursorKeyMode, ptyShellSpec, scriptFlavor, toApplicationCursor, +} from "../src/pty.mjs"; +import { KEY_BYTES, activeChildInput, pressKey, setActiveChildInput } from "../src/mirror.mjs"; + +const CAPTURABLE = Boolean(scriptFlavor()); +// A literal ESC, spelled out so it survives every editor and diff viewer. +const ESC = String.fromCharCode(27); + +/** + * A stand-in for a real terminal. `node --test` has no tty, and the input path + * refuses to engage without one — quite deliberately — so the test supplies the + * handful of things it touches rather than skipping the whole file. + */ +function fakeTty({ columns = 100, rows = 30 } = {}) { + const listeners = new Map(); + return { + isTTY: true, + isRaw: false, + columns, + rows, + rawCalls: [], + resumed: 0, + paused: 0, + setRawMode(on) { this.isRaw = on; this.rawCalls.push(on); }, + resume() { this.resumed += 1; }, + pause() { this.paused += 1; }, + on(event, fn) { listeners.set(event, [...(listeners.get(event) || []), fn]); return this; }, + off(event, fn) { listeners.set(event, (listeners.get(event) || []).filter((f) => f !== fn)); return this; }, + emit(event, arg) { for (const fn of listeners.get(event) || []) fn(arg); }, + count(event) { return (listeners.get(event) || []).length; }, + }; +} + +/** + * A child that names the raw bytes it is handed and exits once it sees `stop`. + * + * Exits on a byte rather than after N reads because a terminal does not promise + * one read per key: two writes in quick succession arrive coalesced, and the + * first version of this counted `data` events and hung waiting for a second one + * that a working relay had already folded into the first. + */ +function keyReporter(dir, stop) { + const file = path.join(dir, "reporter.mjs"); + fs.writeFileSync(file, [ + 'process.stdin.setRawMode(true); process.stdin.resume();', + 'console.log("READY " + process.stdout.columns + "x" + process.stdout.rows);', + 'process.stdin.on("data", (b) => {', + ' console.log("KEY " + [...b].map((x) => x.toString(16).padStart(2, "0")).join(" "));', + ` if (b.includes(${stop})) process.exit(0);`, + '});', + ].join("\n")); + return file; +} + +const settle = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function waitFor(predicate, ms = 8000) { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + if (predicate()) return true; + await settle(25); + } + return false; +} + +test("ptyShellSpec runs a shell line under both script flavours", () => { + assert.deepEqual(ptyShellSpec("stty rows 1; exec vi", "/tmp/t", "util-linux"), + { cmd: "script", args: ["-q", "-e", "-f", "-c", "stty rows 1; exec vi", "/tmp/t"] }); + // BSD wants the transcript first and a real argv after, so the line needs a + // shell of its own rather than being handed to script as a command string. + assert.deepEqual(ptyShellSpec("stty rows 1; exec vi", "/tmp/t", "bsd"), + { cmd: "script", args: ["-q", "-F", "/tmp/t", "sh", "-c", "stty rows 1; exec vi"] }); + assert.equal(ptyShellSpec("exec vi", "/tmp/t", "sysv"), null); + assert.equal(ptyShellSpec("", "/tmp/t", "util-linux"), null); +}); + +test("without a local tty there is nothing to relay, so the input path declines", () => { + const notATty = { isTTY: false, setRawMode() {} }; + assert.equal(captureWithInput({ cmd: "cat" }, () => {}, { flavor: "util-linux", stdin: notATty }), null); + // …and captureSpec falls back to capturing output rather than losing both. + const spec = captureSpec({ cmd: "cat" }, () => {}, { flavor: "util-linux", input: true, stdin: notATty }); + assert.equal(spec.stdio, "inherit"); + assert.equal(spec.write("anything"), false); + spec.stop(); +}); + +test("a launch that never asked for input is untouched by any of this", () => { + const spec = captureSpec({ cmd: "gh", args: ["pr", "list"] }, undefined); + assert.equal(spec.stdio, "inherit", "the caller spawns exactly what it always spawned"); + assert.equal(spec.write("x"), false); + spec.stop(); +}); + +test("an arrow key written from outside lands on a running child as an escape sequence", + { skip: !CAPTURABLE }, async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "engine-input-")); + const stdin = fakeTty(); + const stdout = fakeTty({ columns: 100, rows: 30 }); + let seen = ""; + const launch = captureSpec( + { cmd: process.execPath, args: [keyReporter(dir, 0x0d)] }, + (chunk) => { seen += chunk; }, + { flavor: scriptFlavor(), input: true, stdin, stdout }, + ); + assert.notEqual(launch.stdio, "inherit", "the child's stdin has to be ours to write to"); + + const child = spawn(launch.cmd, launch.args, { stdio: launch.stdio }); + const exited = new Promise((resolve) => child.on("exit", resolve)); + + assert.ok(await waitFor(() => seen.includes("READY")), `child never started: ${JSON.stringify(seen)}`); + // The fifo has no geometry of its own, so a child that starts 0x0 is the + // failure this guards: full-screen engines do not survive it. + assert.match(seen, /READY 100x30/, `child got the wrong terminal size: ${JSON.stringify(seen)}`); + + assert.equal(launch.write(KEY_BYTES.down), true); + assert.equal(launch.write("\r"), true); + + await exited; + launch.stop(); + // 1b 5b 42 is ESC [ B — down — and 0d is carriage return. Assert on the + // byte stream rather than per read: the two writes land coalesced as often + // as not, and how the reads were split is the terminal's business, not + // something a program navigating a menu can tell apart. + const bytes = [...seen.matchAll(/KEY ([0-9a-f ]+)/g)].map((m) => m[1].trim()).join(" "); + assert.equal(bytes, "1b 5b 42 0d", + `the child got the wrong bytes, in the wrong order, or not at all: ${JSON.stringify(seen)}`); + fs.rmSync(dir, { recursive: true, force: true }); + }); + +test("the keyboard keeps working: local stdin is relayed into the same child", + { skip: !CAPTURABLE }, async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "engine-input-local-")); + const stdin = fakeTty(); + const stdout = fakeTty(); + let seen = ""; + const launch = captureSpec( + { cmd: process.execPath, args: [keyReporter(dir, 0x61)] }, + (chunk) => { seen += chunk; }, + { flavor: scriptFlavor(), input: true, stdin, stdout }, + ); + const child = spawn(launch.cmd, launch.args, { stdio: launch.stdio }); + const exited = new Promise((resolve) => child.on("exit", resolve)); + + assert.ok(await waitFor(() => seen.includes("READY")), "child never started"); + // Raw, or the local tty would echo every character twice and hold Enter + // back until the child had already redrawn without it. + assert.deepEqual(stdin.rawCalls, [true]); + stdin.emit("data", Buffer.from([0x61])); // someone types "a" + + await exited; + launch.stop(); + assert.match(seen, /KEY 61/, `a locally typed key never reached the child: ${JSON.stringify(seen)}`); + // The terminal has to go back the way we found it; leaving it raw looks + // like a hung pit with no echo. + assert.deepEqual(stdin.rawCalls, [true, false]); + assert.equal(stdin.count("data"), 0, "the relay must not outlive the child"); + assert.equal(stdout.count("resize"), 0, "nor the resize hook"); + fs.rmSync(dir, { recursive: true, force: true }); + }); + +test("pressKey prefers a running child over readline and over process.stdin", () => { + const written = []; + const rl = { write() { throw new Error("readline must not see a key while an engine has the terminal"); } }; + const stdin = { emit() { throw new Error("nor may the parent's own stdin"); } }; + setActiveChildInput((bytes) => { written.push(bytes); return true; }); + try { + assert.equal(activeChildInput() !== null, true); + assert.equal(pressKey("down", rl, stdin), true); + assert.equal(pressKey("enter", rl, stdin), true); + assert.deepEqual(written, ["", "\r"]); + // An unknown key is still refused outright, so a newer page cannot make an + // older CLI put something odd in front of an engine. + assert.equal(pressKey("f7", rl, stdin), false); + assert.equal(written.length, 2); + } finally { + setActiveChildInput(null); + } +}); + +test("with no child running, pressKey falls back to the pit exactly as before", () => { + setActiveChildInput(null); + const pressed = []; + const rl = { write(line, key) { pressed.push([line, key]); } }; + assert.equal(pressKey("up", rl), true); + assert.deepEqual(pressed, [[null, { name: "up" }]]); + + // And with no readline either, the synthesised event is still what drives the + // pit's raw-mode readers (the herd bar, the reader, a menu). + const bytes = []; + const stdin = { emit(event, buf) { bytes.push([event, buf.toString("latin1")]); } }; + assert.equal(pressKey("left", null, stdin), true); + assert.deepEqual(bytes, [["data", ""]]); +}); + +test("a child that has gone away refuses writes instead of throwing", { skip: !CAPTURABLE }, async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "engine-input-dead-")); + const stdin = fakeTty(); + const launch = captureSpec( + { cmd: process.execPath, args: [keyReporter(dir, 0x61)] }, + () => {}, + { flavor: scriptFlavor(), input: true, stdin, stdout: fakeTty() }, + ); + launch.stop(); + assert.equal(launch.write(""), false, "a write after stop is refused, not thrown"); + assert.doesNotThrow(() => launch.stop(), "stop is idempotent"); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// Application cursor keys +// --------------------------------------------------------------------------- +// +// A full-screen program usually asks for the SS3 form of the cursor keys on its +// way in (DECCKM, `ESC [ ? 1 h`), and a real terminal quietly obliges — which is +// why nobody meets this until something starts synthesising keys. Fed the CSI +// form in that mode, `less` does not scroll: it prints "ESC[B" on its own prompt +// line, exactly as if the characters had been typed. The first version of the +// relay did that, and only a full-screen program caught it. + +test("DECCKM is read off the child's own output", () => { + assert.equal(cursorKeyMode("nothing to see"), false); + assert.equal(cursorKeyMode(`${ESC}[?1h`, false), true); + assert.equal(cursorKeyMode(`${ESC}[?1l`, true), false); + // A chunk with no announcement leaves the mode where it was. + assert.equal(cursorKeyMode("plain output", true), true); + // Last one in the chunk wins: entering and leaving in one read is a program + // that ended up back in normal mode. + assert.equal(cursorKeyMode(`${ESC}[?1h drawing ${ESC}[?1l`, false), false); + // The neighbours must not be mistaken for it — alternate screen, mouse + // tracking and cursor visibility all live at `?1…` too. + assert.equal(cursorKeyMode(`${ESC}[?1049h`, false), false, "alternate screen is not DECCKM"); + assert.equal(cursorKeyMode(`${ESC}[?1000h`, false), false, "mouse tracking is not DECCKM"); + assert.equal(cursorKeyMode(`${ESC}[?25h`, false), false, "cursor visibility is not DECCKM"); + // And a bare mention in ordinary text, with no ESC leading it, is just text. + assert.equal(cursorKeyMode("the sequence [?1h sets it", false), false); +}); + +test("only the four cursor keys are rewritten for application mode", () => { + const hex = (b) => [...b].map((x) => x.toString(16).padStart(2, "0")).join(" "); + assert.equal(hex(toApplicationCursor(Buffer.from(`${ESC}[A`, "latin1"))), "1b 4f 41"); + assert.equal(hex(toApplicationCursor(Buffer.from(`${ESC}[B`, "latin1"))), "1b 4f 42"); + assert.equal(hex(toApplicationCursor(Buffer.from(`${ESC}[C`, "latin1"))), "1b 4f 43"); + assert.equal(hex(toApplicationCursor(Buffer.from(`${ESC}[D`, "latin1"))), "1b 4f 44"); + // Enter, ordinary text, a bare ESC and a sequence that merely looks close are + // all left exactly as they arrived. + assert.equal(hex(toApplicationCursor(Buffer.from("\r", "latin1"))), "0d"); + assert.equal(hex(toApplicationCursor(Buffer.from("yes", "latin1"))), "79 65 73"); + assert.equal(hex(toApplicationCursor(Buffer.from(`${ESC}[E`, "latin1"))), "1b 5b 45"); + assert.equal(hex(toApplicationCursor(Buffer.from(`${ESC}`, "latin1"))), "1b"); + // The input is not mutated in place; callers keep whatever they handed over. + const original = Buffer.from(`${ESC}[B`, "latin1"); + toApplicationCursor(original); + assert.equal(hex(original), "1b 5b 42"); +}); + +test("a child that asked for application cursor keys is sent the form it asked for", + { skip: !CAPTURABLE }, async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "engine-input-decckm-")); + const file = path.join(dir, "reporter.mjs"); + // Announces DECCKM the way a full-screen program does, then reports. + fs.writeFileSync(file, [ + 'process.stdin.setRawMode(true); process.stdin.resume();', + 'process.stdout.write("\u001b[?1h");', + 'console.log("READY");', + 'process.stdin.on("data", (b) => {', + ' console.log("KEY " + [...b].map((x) => x.toString(16).padStart(2, "0")).join(" "));', + ' process.exit(0);', + '});', + ].join("\n")); + + const stdin = fakeTty(); + let seen = ""; + const launch = captureSpec( + { cmd: process.execPath, args: [file] }, + (chunk) => { seen += chunk; }, + { flavor: scriptFlavor(), input: true, stdin, stdout: fakeTty() }, + ); + const child = spawn(launch.cmd, launch.args, { stdio: launch.stdio }); + const exited = new Promise((resolve) => child.on("exit", resolve)); + + assert.ok(await waitFor(() => seen.includes("READY")), "child never started"); + // Give the follower a tick to have read the announcement before we press. + assert.ok(await waitFor(() => seen.includes("[?1h")), "the mode switch never reached us"); + launch.write(KEY_BYTES.down); + + await exited; + launch.stop(); + // 1b 4f 42 is ESC O B. Arriving as 1b 5b 42 is the bug: the program prints + // the characters instead of moving. + assert.match(seen, /KEY 1b 4f 42/, `down arrived in the wrong form: ${JSON.stringify(seen)}`); + fs.rmSync(dir, { recursive: true, force: true }); + });