diff --git a/src/tui.mjs b/src/tui.mjs index ab9b08f..3f4944b 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -56,9 +56,44 @@ const mkrl = () => { // Share the persistent array so ↑/↓ recalls earlier commands even after the // interface was torn down and rebuilt around an engine session. rl.history = history; + if (!process.stdin.isTTY) queuePipedLines(rl); return rl; }; -const ask = (rl) => new Promise((res) => rl.question(PROMPT(), res)); + +// Piped stdin arrives in chunks and readline emits every line of a chunk as +// soon as it lands. A one-shot `rl.question` only captures the first of them, +// so the rest are dropped and the loop then waits on input that already went +// by: `printf '/pwd\n/pwd\n/quit\n' | moshcode` runs one command and exits +// without ever reaching /quit. Queue the lines as they arrive and hand them +// out one at a time instead. The queue lives out here so it survives the +// interface being torn down and rebuilt around a passthrough child. +const pipedLines = []; +let pipedEnded = false; +let wakeReader = null; +let stdinWatched = false; +const wake = () => { + const waiting = wakeReader; + wakeReader = null; + waiting?.(); +}; +function queuePipedLines(rl) { + rl.on("line", (line) => { pipedLines.push(line); wake(); }); + if (stdinWatched) return; + stdinWatched = true; + // Watch the stream, not the interface: `rl.close()` fires every time we tear + // the interface down around a child, which is not the end of the input. + process.stdin.on("end", () => { pipedEnded = true; wake(); }); +} + +const ask = async (rl) => { + if (process.stdin.isTTY) return new Promise((res) => rl.question(PROMPT(), res)); + process.stdout.write(PROMPT()); + for (;;) { + if (pipedLines.length) return pipedLines.shift(); + if (pipedEnded) return null; // end of input — leave like Ctrl-D does + await new Promise((res) => { wakeReader = res; }); + } +}; // Small shell-like tokenizer for TUI commands. It keeps quoted values such as // `/coinpay card pay --description "Fix the build"` as one native CLI argument diff --git a/test/tui.test.mjs b/test/tui.test.mjs index 42b9214..d4788a7 100644 --- a/test/tui.test.mjs +++ b/test/tui.test.mjs @@ -117,3 +117,25 @@ test("TUI /install rejects an Object.prototype name instead of crashing the pit" assert.match(result.stdout, /unknown engine or tool "constructor"/); assert.doesNotMatch(result.stderr, /TypeError/); }); + +// Piped stdin is delivered in chunks and readline emits every line of a chunk +// at once, so a one-shot `rl.question` used to see the first line and drop the +// rest — the loop then waited forever on input that had already gone by. Every +// command after the first was silently skipped, /quit included, and the process +// still exited 0. +test("TUI runs every command from piped stdin, not just the first", async () => { + const result = await runTui("/run --dryrun\n/run --nope\n/pwd\n/quit\n"); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /unknown option --dryrun/); + assert.match(result.stdout, /unknown option --nope/); + assert.match(result.stdout, /code hard, mosh harder/); // /quit was reached +}); + +test("TUI leaves cleanly when piped input ends without /quit", async () => { + const result = await runTui("/run --dryrun\n"); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /unknown option --dryrun/); + assert.match(result.stdout, /code hard, mosh harder/); +});