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 }); + });