From 922113e0cfba446fe45c997faa8a09b26767f3ed Mon Sep 17 00:00:00 2001 From: Shreyan C Date: Sat, 3 Oct 2026 11:22:30 +0530 Subject: [PATCH] fix(cli): play animates in the desktop app's automata on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installed `automata` is the app's executable run as Node, and on Windows that is a GUI program: Electron reattaches stdout and stderr to the console but not stdin. play saw a stdin that was no terminal, took it for a pipe, and printed every frame instead of playing them. Its keys now come from keyboard(), which opens the console's input by name (\.\CONIN$) in exactly that case — Electron on win32 with no terminal on stdin. Anywhere else a non-terminal stdin is a real pipe and still gets frames; if no console can be opened, it falls back to frames rather than failing. The console stream is closed on quit, or the process would never exit. The clips never caught it: they run this checkout under plain Node. --- .claude/skills/cli/SKILL.md | 7 +++++++ cli/commands/play.mjs | 36 ++++++++++++++++++++++++++++++++---- tests/cli-engine.test.js | 24 ++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/.claude/skills/cli/SKILL.md b/.claude/skills/cli/SKILL.md index 25a360c..8114faf 100644 --- a/.claude/skills/cli/SKILL.md +++ b/.claude/skills/cli/SKILL.md @@ -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 `) live there too. diff --git a/cli/commands/play.mjs b/cli/commands/play.mjs index 105f5bf..02ed955 100644 --- a/cli/commands/play.mjs +++ b/cli/commands/play.mjs @@ -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'; @@ -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; + try { + const stream = open(); + return { stream, close: () => stream.destroy() }; + } catch { + return null; + } +} + const play = { usage: `automata play [options] @@ -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(); @@ -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 => { @@ -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. diff --git a/tests/cli-engine.test.js b/tests/cli-engine.test.js index 2e4edf7..e358994 100644 --- a/tests/cli-engine.test.js +++ b/tests/cli-engine.test.js @@ -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); +});