Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .claude/skills/cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,13 @@ symmetry. It never uses the bound, which would assume its own answer.
screen, raw keys, the last frame left on the normal screen on quit) when both
stdin and stdout are terminals, and as a sequence of frames otherwise. Keep it
pure — the tests read the piped frames.
- **On Windows the desktop app's `automata` has no stdin.** It is the app's
executable run as Node, a GUI program: Electron reattaches stdout and stderr to
the console but not stdin, so `play` saw a pipe and printed every frame. Its keys
come from `keyboard()`, which opens `\\.\CONIN$` there — Electron on win32 only,
so a real pipe into plain Node still gets frames. The clips run this checkout
under plain Node and cannot show this; test it with the repo's `electron.exe`
and `ELECTRON_RUN_AS_NODE=1` from a `.cmd`, under a console.
- **A command's help is its module's `usage` plus `help.mjs`'s examples and
see-also**, coloured by `renderUsage` (headings end in `:`, flags start with
`-`, examples with `$ `). Topics (`automata help <topic>`) live there too.
Expand Down
36 changes: 32 additions & 4 deletions cli/commands/play.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// play, animate — a run you can watch.
import { writeFileSync } from 'node:fs';
import { openSync, writeFileSync } from 'node:fs';
import { ReadStream } from 'node:tty';

import { App } from '../../js/state.js';
import { readMachine, emit, CliError } from '../io.mjs';
Expand Down Expand Up @@ -154,6 +155,32 @@ export function renderFrame(run, i, view) {
return out;
}

/**
* Where play's keys come from: `{ stream, close }`, or null when there is no
* keyboard and the run should print its frames instead.
*
* Normally that is stdin, when stdin is a terminal. The desktop app's CLI is
* the app's own executable run as Node (resources/cli/automata.cmd), and on
* Windows that executable is a GUI program: Electron reattaches stdout and
* stderr to the console it was started from, but not stdin. So stdin is no
* terminal there although the user is typing at one, and play printed every
* frame. There, and only there, the console's input is opened by name.
* Anywhere else a stdin that is not a terminal is a real pipe.
*/
export function keyboard({
stdin = process.stdin, platform = process.platform, electron = process.versions.electron,
open = () => new ReadStream(openSync('\\\\.\\CONIN$', 'r+'))
} = {}) {
if (stdin.isTTY) return { stream: stdin, close() {} };
if (platform !== 'win32' || !electron) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve frame mode for redirected stdin

When the Windows desktop CLI is invoked from a console with stdin genuinely redirected (for example, automata play ... < input.txt), stdin.isTTY is false but process.versions.electron is set, so this condition opens CONIN$ and enters the interactive loop instead of emitting all frames. That changes the documented piped/redirected behavior and can leave scripts waiting for a q keystroke; only use the console fallback when stdin is actually unattached rather than treating every non-TTY Electron stdin as that case.

Useful? React with 👍 / 👎.

try {
const stream = open();
return { stream, close: () => stream.destroy() };
} catch {
return null;
}
}

const play = {
usage: `automata play <machine> <word> [options]

Expand Down Expand Up @@ -200,10 +227,10 @@ Exit: 0 accept (or a transducer finished), 1 reject, 2 cut short or no verdict.`
joiner: tokens.every(t => [...t].length === 1) ? '' : ' '
};
const code = () => (cut ? 2 : endingCode(steps.at(-1)?.final));
const interactive = isTTY && process.stdin.isTTY && styled;
const keys = isTTY && styled ? keyboard() : null;
const fps = Math.max(0.5, Number(opts.fps ?? 6));

if (!interactive) {
if (!keys) {
// Piped: every frame, in order, as text.
for (let i = 0; i < steps.length; i++) print(renderFrame(run, i, { playing: true, fps, history: !!opts.history }).join('\n') + '\n');
return code();
Expand All @@ -224,7 +251,7 @@ Exit: 0 accept (or a transducer finished), 1 reject, 2 cut short or no verdict.`
}, 1000 / view.fps);
};
process.stdout.write('\x1b[?1049h\x1b[?25l');
const stdin = process.stdin;
const stdin = keys.stream;
stdin.setRawMode(true);
stdin.resume();
return await new Promise(resolve => {
Expand All @@ -236,6 +263,7 @@ Exit: 0 accept (or a transducer finished), 1 reject, 2 cut short or no verdict.`
stdin.setRawMode(false);
stdin.pause();
stdin.removeAllListeners('data');
keys.close();
process.stdout.off('resize', draw);
process.stdout.write('\x1b[?25h\x1b[?1049l');
// The last frame seen stays on the ordinary screen.
Expand Down
24 changes: 24 additions & 0 deletions tests/cli-engine.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -462,3 +462,27 @@ test('a play frame fits the terminal it is drawn on, so its first line never scr
}
if (saved) Object.defineProperty(process.stdout, 'rows', saved); else delete process.stdout.rows;
});

test('play takes its keys from the console when the desktop app on Windows leaves stdin unattached', async () => {
// The app's executable run as Node is a GUI program on Windows: Electron
// reattaches stdout to the console but not stdin, so play printed every
// frame. There it opens the console's input; anywhere else a stdin that is
// not a terminal is a pipe, and nothing is opened.
const { keyboard } = await import('../cli/commands/play.mjs');
const tty = { isTTY: true }, pipe = {}, conin = { isTTY: true, destroyed: false, destroy() { this.destroyed = true; } };
let opened = 0;
const open = () => { opened++; return conin; };

assert.equal(keyboard({ stdin: tty, platform: 'win32', electron: '43.4.0', open }).stream, tty);
assert.equal(keyboard({ stdin: pipe, platform: 'win32', electron: undefined, open }), null);
assert.equal(keyboard({ stdin: pipe, platform: 'linux', electron: '43.4.0', open }), null);
assert.equal(opened, 0);

const keys = keyboard({ stdin: pipe, platform: 'win32', electron: '43.4.0', open });
assert.equal(keys.stream, conin);
keys.close();
assert.ok(conin.destroyed, 'the console input is closed on quit, or the process never exits');

// No console to open (a service, a detached run): frames, not a crash.
assert.equal(keyboard({ stdin: pipe, platform: 'win32', electron: '43.4.0', open: () => { throw new Error('ENOENT'); } }), null);
});
Loading