@workflow-manager/runner
Version:
CLI runner for in-memory and markdown workflow orchestration using ATEP-like envelopes
100 lines (99 loc) • 3.73 kB
JavaScript
// 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);
}
}