Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion src/tui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions test/tui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
Loading