UNPKG

@workflow-manager/runner

Version:

CLI runner for in-memory and markdown workflow orchestration using ATEP-like envelopes

100 lines (99 loc) 3.73 kB
// Terminal session manager for the `wfm run --ui` TUI: owns entering/leaving // the alt screen, raw-mode keyboard input, resize notifications, and a single // buffered full-repaint write. No timers, no rendering/layout logic here. import * as readline from "node:readline"; import { ALT_SCREEN_ENTER, ALT_SCREEN_LEAVE, CLEAR_LINE_END, CLEAR_SCREEN, CURSOR_HIDE, CURSOR_HOME, CURSOR_SHOW, } from "./ansi.js"; const SIGNALS_TO_RESTORE_ON = ["SIGINT", "SIGTERM"]; export class TerminalScreen { stdout; stdin; onKey; onResize; started = false; stopped = false; wasRawMode = false; handleKeypress = (str, key) => { this.onKey(key ?? { sequence: str }); }; handleResize = () => { const { columns, rows } = this.size(); this.onResize(columns, rows); }; handleSignal = () => { this.restore(); // Conservative: restore the terminal only. Do not call process.exit and // do not swallow the signal for other listeners — re-emitting here would // recurse into every listener (including this one) on this signal, so we // simply return and let Node's normal signal delivery / other installed // listeners proceed. If this is the only SIGINT/SIGTERM listener, the // process's default disposition (terminate) still applies once all // listeners for the event have run. }; constructor(options) { this.stdout = options.stdout; this.stdin = options.stdin; this.onKey = options.onKey; this.onResize = options.onResize; } start() { if (this.started) { return; } this.started = true; this.stopped = false; this.stdout.write(ALT_SCREEN_ENTER + CURSOR_HIDE + CLEAR_SCREEN + CURSOR_HOME); if (this.stdin.isTTY) { this.wasRawMode = true; this.stdin.setRawMode(true); } readline.emitKeypressEvents(this.stdin); this.stdin.resume(); this.stdin.on("keypress", this.handleKeypress); this.stdout.on("resize", this.handleResize); process.once("exit", this.exitRestore); for (const signal of SIGNALS_TO_RESTORE_ON) { process.on(signal, this.handleSignal); } } paint(rows) { const body = rows.map((row) => row + CLEAR_LINE_END).join("\r\n"); this.stdout.write(CURSOR_HOME + body); } size() { // Some PTYs report a 0x0 winsize; treat any non-positive dimension as unknown. const columns = this.stdout.columns; const rows = this.stdout.rows; return { columns: columns && columns > 0 ? columns : 80, rows: rows && rows > 0 ? rows : 24, }; } stop() { this.restore(); for (const signal of SIGNALS_TO_RESTORE_ON) { process.removeListener(signal, this.handleSignal); } } // Bound reference so `process.once("exit", ...)` can be a no-op re-run of // restore() without re-registering listener removal (exit handlers cannot // usefully remove listeners on a dying process, and restore() is safe to // call multiple times). exitRestore = () => { this.restore(); }; restore() { if (this.stopped) { return; } this.stopped = true; this.started = false; this.stdin.removeListener("keypress", this.handleKeypress); this.stdout.removeListener("resize", this.handleResize); if (this.wasRawMode) { this.stdin.setRawMode(false); this.wasRawMode = false; } this.stdin.pause(); this.stdout.write(CURSOR_SHOW + ALT_SCREEN_LEAVE); } }