@addai/node 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tui/run.js CHANGED
@@ -2,7 +2,8 @@
2
2
  // Terminal wiring for the console UI: raw-mode keys in, frames out.
3
3
  //
4
4
  // Everything decision-shaped lives in app.ts and the screens; this file
5
- // owns only the things that need a real TTY.
5
+ // owns only the things that need a real TTY — and it owns them once, for
6
+ // every screen, so no screen has to run a terminal of its own.
6
7
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
7
8
  if (k2 === undefined) k2 = k;
8
9
  var desc = Object.getOwnPropertyDescriptor(m, k);
@@ -37,77 +38,54 @@ var __importStar = (this && this.__importStar) || (function () {
37
38
  };
38
39
  })();
39
40
  Object.defineProperty(exports, "__esModule", { value: true });
40
- exports.FEED_ROWS = void 0;
41
41
  exports.shouldRenderTui = shouldRenderTui;
42
42
  exports.runDashboard = runDashboard;
43
+ exports.runHarnessesTui = runHarnessesTui;
43
44
  const readline = __importStar(require("readline"));
44
45
  const app_1 = require("./app");
45
46
  const data_1 = require("./data");
46
47
  const dashboard_1 = require("./dashboard");
47
- Object.defineProperty(exports, "FEED_ROWS", { enumerable: true, get: function () { return dashboard_1.FEED_ROWS; } });
48
48
  const requests_1 = require("./requests");
49
49
  const transcript_1 = require("./transcript");
50
50
  const harnesses_1 = require("./harnesses");
51
+ const logs_1 = require("./logs");
51
52
  const render_1 = require("./render");
52
53
  const console_capture_1 = require("./console-capture");
53
54
  const store_1 = require("../store");
54
55
  const request_pump_1 = require("../request-pump");
56
+ const paths_1 = require("../paths");
55
57
  function shouldRenderTui(env) {
56
58
  return env.isTTY && env.stdinTTY && !env.noTui;
57
59
  }
58
- async function runDashboard(opts) {
59
- const pairing = (0, store_1.readPairing)();
60
- const data = (0, data_1.createDataLayer)({ token: pairing?.daemonToken ?? '' });
61
- // In daemon mode the pump is running in THIS process and logs straight to
62
- // the stdout we are painting on. Capture it for as long as we own the
63
- // screen including while the harness screen is up and replay on exit.
64
- const logs = (0, console_capture_1.captureConsole)();
65
- const state = {
66
- self: null, stats: null, requests: [], sel: 0, spin: 0,
67
- pid: opts.pid, startedAt: opts.startedAt,
68
- inflight: 0, paired: (0, store_1.isPaired)(), viewerMode: opts.viewerMode,
69
- offline: false, now: Date.now(), version: opts.version,
70
- lastLog: null, logCount: 0,
71
- };
60
+ /** How often the poll scheduler looks for work. Screens set their own rate. */
61
+ const TICK_MS = 250;
62
+ /** Spinner cadence. Cheap now that frames are painted as a diff. */
63
+ const SPIN_MS = 120;
64
+ /**
65
+ * Own the terminal on behalf of a screen stack.
66
+ *
67
+ * `build` gets the host so it can wire navigation, and the suspend hook so a
68
+ * screen can hand the TTY to a vendor CLI. It returns the root screen.
69
+ */
70
+ function createConsole(build, hooks = {}) {
71
+ const painter = (0, render_1.createPainter)(s => process.stdout.write(s));
72
72
  let quitting = false;
73
73
  let inputAttached = false;
74
- // While the harness screen owns the terminal, this one must go completely
75
- // silent. Its poll and spin timers keep firing otherwise and repaint the
76
- // dashboard straight over the harness panel the screens flicker against
77
- // each other and the harness rows never survive a frame.
74
+ // Building the root screen redraws, and that happens before run() has
75
+ // opened the alt screen. Painting then would scribble a frame onto the
76
+ // user's real terminal and worse, leave the painter believing that
77
+ // frame is already on screen, so the first alt-screen paint would be a
78
+ // no-op diff against a screen that is actually blank.
79
+ let started = false;
80
+ // While a child process owns the terminal, this app must go completely
81
+ // silent: its timers keep firing otherwise and repaint straight over the
82
+ // child's output.
78
83
  let suspended = false;
79
84
  const app = (0, app_1.createApp)({ id: 'boot', title: '+Ai Node', render: () => [] }, { onQuit: () => teardown(), onRedraw: () => draw() });
80
- const openTranscript = (r) => app.push((0, transcript_1.createTranscriptScreen)({ data, host: app, request: r }));
81
- app.replace((0, dashboard_1.createDashboardScreen)({
82
- data, host: app, state,
83
- openTranscript,
84
- openRequests: () => app.push((0, requests_1.createRequestsScreen)({ data, host: app, openTranscript })),
85
- openHarnesses: () => { void openHarnesses(); },
86
- }));
87
- // The harness screen owns the whole terminal (it hands the TTY to CLIs
88
- // for login), so we detach entirely and rebuild our input when it exits.
89
- async function openHarnesses() {
90
- suspended = true;
91
- detachInput();
92
- (0, render_1.altOff)();
93
- try {
94
- await (0, harnesses_1.runHarnessesTui)({ embedded: true });
95
- }
96
- finally {
97
- suspended = false;
98
- (0, render_1.altOn)();
99
- attachInput();
100
- draw();
101
- }
102
- }
103
85
  function draw() {
104
- if (quitting || suspended)
86
+ if (!started || quitting || suspended)
105
87
  return;
106
- const latest = logs.last();
107
- state.lastLog = latest ? latest.text.replace(/\s+/g, ' ') : null;
108
- state.logCount = logs.count();
109
- (0, render_1.home)();
110
- process.stdout.write(app.frame((0, render_1.termWidth)()).join('\n') + '\n');
88
+ painter.paint(app.frame((0, render_1.termWidth)(), (0, render_1.termHeight)()));
111
89
  }
112
90
  function onKeypress(_s, key) {
113
91
  void app.handleKey(key);
@@ -131,48 +109,131 @@ async function runDashboard(opts) {
131
109
  process.stdin.pause();
132
110
  inputAttached = false;
133
111
  }
112
+ let pollTimer = null;
113
+ let spinTimer = null;
134
114
  function teardown() {
135
115
  if (quitting)
136
116
  return;
137
117
  quitting = true;
138
- clearInterval(pollTimer);
139
- clearInterval(spinTimer);
118
+ if (pollTimer)
119
+ clearInterval(pollTimer);
120
+ if (spinTimer)
121
+ clearInterval(spinTimer);
140
122
  detachInput();
141
123
  (0, render_1.altOff)();
142
- // Give the operator back everything the daemon said while the UI had
143
- // the screen — otherwise running under the dashboard swallows the log.
144
- logs.restoreAndReplay();
124
+ hooks.onTeardown?.();
145
125
  process.exit(0);
146
126
  }
147
- // Each screen declares its own cadence via pollMs(); this ticks often
148
- // enough to honour the shortest one without polling on every tick.
149
- let lastPoll = 0;
150
- const pollTimer = setInterval(() => {
151
- if (suspended)
152
- return;
153
- const screen = app.current();
154
- const due = screen.pollMs?.() ?? 5000;
155
- if (Date.now() - lastPoll < due)
156
- return;
157
- lastPoll = Date.now();
158
- state.inflight = (0, request_pump_1.inflightCount)();
159
- void screen.poll?.().catch(() => { });
160
- }, 250);
161
- // Redraw on a spin tick only while something is actually moving, so an
162
- // idle node isn't repainting the terminal ten times a second.
163
- const spinTimer = setInterval(() => {
164
- if (suspended)
165
- return;
166
- state.spin++;
167
- if (state.stats?.active || state.inflight > 0)
127
+ const suspend = async (fn) => {
128
+ suspended = true;
129
+ detachInput();
130
+ (0, render_1.altOff)();
131
+ try {
132
+ await fn();
133
+ }
134
+ finally {
135
+ suspended = false;
136
+ (0, render_1.altOn)();
137
+ attachInput();
138
+ // The child scribbled all over the screen; the diff from before it ran
139
+ // is meaningless now.
140
+ painter.invalidate();
168
141
  draw();
169
- }, 120);
170
- // Resizing must not leave half-width panels behind.
171
- process.stdout.on('resize', draw);
172
- (0, render_1.altOn)();
173
- attachInput();
174
- state.inflight = (0, request_pump_1.inflightCount)();
175
- await app.current().poll?.();
176
- draw();
177
- await new Promise(() => { });
142
+ }
143
+ };
144
+ app.replace(build(app, suspend));
145
+ return {
146
+ host: app,
147
+ suspend,
148
+ async run() {
149
+ // Each screen declares its own cadence and carries its own clock, so a
150
+ // screen that has just been opened is polled at the next tick rather
151
+ // than waiting out the previous screen's interval.
152
+ pollTimer = setInterval(() => {
153
+ if (suspended)
154
+ return;
155
+ const screen = app.duePoll(Date.now());
156
+ if (!screen)
157
+ return;
158
+ void screen.poll?.().catch(() => { });
159
+ }, TICK_MS);
160
+ let n = 0;
161
+ spinTimer = setInterval(() => {
162
+ if (suspended)
163
+ return;
164
+ n++;
165
+ let wants = false;
166
+ try {
167
+ wants = app.current().tick?.(n) === true;
168
+ }
169
+ catch {
170
+ wants = false;
171
+ }
172
+ if (wants)
173
+ draw();
174
+ }, SPIN_MS);
175
+ // Resizing must not leave half-width panels behind.
176
+ process.stdout.on('resize', () => { painter.invalidate(); draw(); });
177
+ (0, render_1.altOn)();
178
+ attachInput();
179
+ started = true;
180
+ painter.invalidate();
181
+ draw();
182
+ await new Promise(() => { });
183
+ },
184
+ };
185
+ }
186
+ async function runDashboard(opts) {
187
+ const pairing = (0, store_1.readPairing)();
188
+ const data = (0, data_1.createDataLayer)({ token: pairing?.daemonToken ?? '' });
189
+ // In daemon mode the pump is running in THIS process and logs straight to
190
+ // the stdout we are painting on. Capture it for as long as we own the
191
+ // screen; the Logs screen reads the buffer and every line still reaches
192
+ // the daemon log file, so nothing is lost by watching.
193
+ const logs = (0, console_capture_1.captureConsole)({ logFile: paths_1.RUNTIME_LOG_FILE });
194
+ const state = {
195
+ self: null, stats: null, recent: [], sel: 0, spin: 0,
196
+ pid: opts.pid, startedAt: opts.startedAt,
197
+ inflight: 0, paired: (0, store_1.isPaired)(), viewerMode: opts.viewerMode,
198
+ offline: false, now: Date.now(), version: opts.version,
199
+ logCount: 0,
200
+ };
201
+ const ui = createConsole((host, suspend) => {
202
+ const openTranscript = (r) => host.push((0, transcript_1.createTranscriptScreen)({ data, host, request: r }));
203
+ return (0, dashboard_1.createDashboardScreen)({
204
+ data, host, state,
205
+ openTranscript,
206
+ openRequests: () => host.push((0, requests_1.createRequestsScreen)({ data, host, openTranscript })),
207
+ openHarnesses: () => host.push((0, harnesses_1.createHarnessesScreen)({ host, suspend })),
208
+ openLogs: () => host.push((0, logs_1.createLogsScreen)({
209
+ host,
210
+ logs,
211
+ emptyHint: opts.viewerMode
212
+ ? 'the daemon runs in another process — its log is in ~/.ainode/daemon.log'
213
+ : undefined,
214
+ })),
215
+ });
216
+ }, {
217
+ onTeardown: () => {
218
+ logs.restore();
219
+ if (logs.count()) {
220
+ console.log(`${logs.count()} daemon log lines this session — ${paths_1.RUNTIME_LOG_FILE}`);
221
+ }
222
+ },
223
+ });
224
+ // The daemon's own numbers don't come from an RPC.
225
+ setInterval(() => {
226
+ state.inflight = (0, request_pump_1.inflightCount)();
227
+ state.logCount = logs.count();
228
+ }, 1000).unref();
229
+ await ui.run();
230
+ }
231
+ /** `ainode harnesses` — straight to the harness screen. */
232
+ async function runHarnessesTui() {
233
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
234
+ await (0, harnesses_1.plainStatus)();
235
+ return;
236
+ }
237
+ const ui = createConsole((host, suspend) => (0, harnesses_1.createHarnessesScreen)({ host, suspend }));
238
+ await ui.run();
178
239
  }
@@ -1,5 +1,5 @@
1
+ import { type AppHost, type Screen } from './app';
1
2
  import type { DataLayer, RequestRow } from './data';
2
- import type { AppHost, Screen } from './app';
3
3
  export interface Message {
4
4
  role: 'user' | 'assistant' | 'thinking' | 'tool_use' | 'tool_result';
5
5
  text?: string;
@@ -15,6 +15,7 @@ export interface TranscriptState {
15
15
  scroll: number;
16
16
  follow: boolean;
17
17
  loading: boolean;
18
+ /** Rows the last render could fit — what a PgUp/PgDn moves by. */
18
19
  viewportRows: number;
19
20
  }
20
21
  interface RawEvent {
@@ -30,7 +31,7 @@ export declare function wrapText(s: string, width: number): string[];
30
31
  */
31
32
  export declare function messagesFromEvents(events: RawEvent[], prompt: string | null): Message[];
32
33
  export declare function messageLines(m: Message, width: number): string[];
33
- export declare function renderTranscript(st: TranscriptState, width: number): string[];
34
+ export declare function renderTranscript(st: TranscriptState, width: number, height: number): string[];
34
35
  export declare function createTranscriptScreen(deps: {
35
36
  data: DataLayer;
36
37
  host: AppHost;
@@ -14,7 +14,8 @@ exports.messageLines = messageLines;
14
14
  exports.renderTranscript = renderTranscript;
15
15
  exports.createTranscriptScreen = createTranscriptScreen;
16
16
  const render_1 = require("./render");
17
- const ACTIVE = new Set(['pending', 'starting', 'running']);
17
+ const request_row_1 = require("./request-row");
18
+ const app_1 = require("./app");
18
19
  function wrapText(s, width) {
19
20
  const out = [];
20
21
  for (const paragraph of String(s ?? '').split('\n')) {
@@ -141,14 +142,18 @@ function messageLines(m, width) {
141
142
  const wrapped = wrapText(m.text ?? '', body);
142
143
  return wrapped.map((l, i) => ` ${i === 0 ? head(tag, colour) : ' '.repeat(label)} ${m.role === 'thinking' ? (0, render_1.grey)(l) : l}`);
143
144
  }
144
- function renderTranscript(st, width) {
145
+ /** Lines the screen spends on things that are not transcript body. */
146
+ const CHROME = 4;
147
+ function renderTranscript(st, width, height) {
145
148
  const r = st.request;
146
149
  const started = new Date(r.picked_up_at ?? r.created_at).getTime();
147
150
  const dur = r.finished_at
148
151
  ? (0, render_1.fmtDuration)(new Date(r.finished_at).getTime() - started)
149
152
  : (0, render_1.fmtDuration)(Date.now() - started);
153
+ const rows = Math.max(1, height - CHROME);
154
+ st.viewportRows = rows;
150
155
  const out = [];
151
- out.push(` ${(0, render_1.cyan)('▸')} ${(0, render_1.bold)(r.entity_name ?? '—')} ${(0, render_1.dim)('·')} ${(0, render_1.dim)(`${r.agent}/${r.mode}`)} ${(0, render_1.dim)('·')} ${(0, render_1.dim)(r.status)} ${(0, render_1.dim)(dur)}`);
156
+ out.push(` ${(0, render_1.cyan)('▸')} ${(0, render_1.bold)(r.entity_name ?? '—')} ${(0, render_1.dim)('·')} ${(0, render_1.dim)(`${r.agent}/${r.mode}`)} ${(0, render_1.dim)('·')} ${(0, request_row_1.statusColour)(r.status)((0, request_row_1.statusLabel)(r.status))} ${(0, render_1.dim)(dur)}`);
152
157
  out.push('');
153
158
  if (st.loading && st.messages.length === 0) {
154
159
  out.push((0, render_1.dim)(' loading transcript…'));
@@ -158,28 +163,40 @@ function renderTranscript(st, width) {
158
163
  }
159
164
  else {
160
165
  const all = st.messages.flatMap(m => messageLines(m, width));
161
- const rows = Math.max(1, st.viewportRows);
166
+ const maxScroll = Math.max(0, all.length - rows);
162
167
  const start = st.follow
163
- ? Math.max(0, all.length - rows)
164
- : Math.min(st.scroll, Math.max(0, all.length - 1));
168
+ ? maxScroll
169
+ : Math.min(st.scroll, maxScroll);
170
+ st.scroll = start;
165
171
  out.push(...all.slice(start, start + rows));
166
172
  }
167
- out.push('');
168
- const live = ACTIVE.has(r.status) && st.follow ? (0, render_1.cyan)('following') : '';
169
- out.push((0, render_1.dim)(' ↑↓ PgUp PgDn scroll G follow q back ') + live);
170
- return out.map(l => (0, render_1.truncate)(l, width));
173
+ while (out.length < height - 1)
174
+ out.push('');
175
+ const live = request_row_1.ACTIVE.has(r.status) && st.follow ? ` ${(0, render_1.cyan)('following')}` : '';
176
+ out.push((0, app_1.footerHint)([
177
+ { keys: '↑↓ PgUp PgDn', label: 'scroll' },
178
+ { keys: 'G', label: 'follow' },
179
+ { keys: 'q', label: 'back' },
180
+ ]) + live);
181
+ return out.map(l => (0, render_1.truncate)(l, width)).slice(0, height);
171
182
  }
172
183
  function createTranscriptScreen(deps) {
173
184
  const st = {
174
185
  request: deps.request, messages: [], scroll: 0,
175
186
  follow: true, loading: true,
176
- viewportRows: Math.max(8, (process.stdout.rows ?? 30) - 10),
187
+ viewportRows: 20,
177
188
  };
178
189
  return {
179
190
  id: `transcript:${deps.request.id}`,
180
191
  title: 'Transcript',
181
- render: (width) => renderTranscript(st, width),
182
- pollMs: () => (ACTIVE.has(st.request.status) ? 1000 : 15_000),
192
+ render: (width, height) => renderTranscript(st, width, height),
193
+ pollMs: () => (request_row_1.ACTIVE.has(st.request.status) ? 1000 : 15_000),
194
+ keys: () => [
195
+ { keys: '↑↓ / jk', label: 'scroll a line' },
196
+ { keys: 'PgUp/PgDn', label: 'scroll a screenful' },
197
+ { keys: 'g', label: 'jump to the start' },
198
+ { keys: 'G', label: 'jump to the end and follow the tail' },
199
+ ],
183
200
  async poll() {
184
201
  const detail = await deps.data.detail(deps.request.id);
185
202
  st.loading = false;
@@ -187,8 +204,15 @@ function createTranscriptScreen(deps) {
187
204
  deps.host.redraw();
188
205
  return;
189
206
  }
190
- if (detail.request)
191
- st.request = detail.request;
207
+ // Merge, don't replace: the detail payload's row carries live status and
208
+ // timings but not the joined entity name, so replacing wholesale blanked
209
+ // the name out of the header the moment the transcript loaded.
210
+ if (detail.request) {
211
+ st.request = {
212
+ ...st.request,
213
+ ...Object.fromEntries(Object.entries(detail.request).filter(([, v]) => v !== null && v !== undefined)),
214
+ };
215
+ }
192
216
  st.messages = messagesFromEvents(detail.events ?? [], st.request.prompt);
193
217
  deps.host.redraw();
194
218
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addai/node",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Daemon that pairs a machine with your +Ai account and runs Claude / Codex / Kimi / Gemini agents on its behalf. Reachable via Supabase from Vault, Entity Studio, or any other +Ai surface.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // Drive the console UI in a real pty and dump the frames a user would see.
3
+ //
4
+ // Unit tests assert on the pure render functions; this drives the actual
5
+ // binary — alt screen, raw keys, live data, the lot. Every defect in the
6
+ // 2026-07-30 console pass was found with this and none of them were visible
7
+ // from the code.
8
+ //
9
+ // node scripts/probe-tui.mjs "w4000,shome,ka,w2500,sactivity"
10
+ //
11
+ // Script steps, comma separated:
12
+ // w<ms> wait
13
+ // k<key> send a key: a literal, or CR UP DOWN LEFT RIGHT ESC TAB
14
+ // s<label> snapshot the screen under that label
15
+ //
16
+ // Frames are painted with cursor addressing, so the raw pty stream is
17
+ // replayed through a tiny terminal model to reconstruct what is on screen.
18
+
19
+ import { spawn } from 'node:child_process';
20
+ import { createRequire } from 'node:module';
21
+ import * as path from 'node:path';
22
+ import * as url from 'node:url';
23
+
24
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
25
+ const root = path.resolve(here, '..');
26
+ const require = createRequire(import.meta.url);
27
+
28
+ let pty;
29
+ try {
30
+ pty = require('node-pty');
31
+ } catch {
32
+ console.error('node-pty is not installed — run npm install first');
33
+ process.exit(1);
34
+ }
35
+
36
+ const COLS = Number(process.env.PROBE_COLS ?? 120);
37
+ const ROWS = Number(process.env.PROBE_ROWS ?? 40);
38
+ const steps = (process.argv[2] ?? 'w4000,shome').split(',').filter(Boolean);
39
+
40
+ const KEYS = {
41
+ CR: '\r', ESC: '\x1b', TAB: '\t',
42
+ UP: '\x1b[A', DOWN: '\x1b[B', RIGHT: '\x1b[C', LEFT: '\x1b[D',
43
+ PGUP: '\x1b[5~', PGDN: '\x1b[6~',
44
+ };
45
+
46
+ /** Just enough terminal to reconstruct a screen from cursor-addressed writes. */
47
+ function createScreen(rows, cols) {
48
+ let grid = Array.from({ length: rows }, () => '');
49
+ let row = 0;
50
+ let col = 0;
51
+
52
+ const put = text => {
53
+ if (!text) return;
54
+ const line = grid[row] ?? '';
55
+ grid[row] = (line.padEnd(col, ' ')).slice(0, col) + text;
56
+ col += text.length;
57
+ };
58
+
59
+ // A pty delivers bytes, not frames: an escape sequence is routinely split
60
+ // across two chunks. Anything that looks like the start of one is held
61
+ // back until the rest arrives, or it lands in the output as literal text.
62
+ let pending = '';
63
+
64
+ return {
65
+ write(raw) {
66
+ const chunk = pending + raw;
67
+ pending = '';
68
+ const dangling = chunk.match(/\x1b(\[[0-9;]*|\][^\x07]*|[()]?)?$/);
69
+ const body = dangling ? chunk.slice(0, dangling.index) : chunk;
70
+ if (dangling) pending = dangling[0];
71
+
72
+ // Split into escape sequences and printable runs.
73
+ const re = /\x1b\[([0-9;]*)([A-Za-z])|\x1b\][^\x07]*\x07|\x1b[()][A-Za-z0-9]|[^\x1b]+/g;
74
+ let m;
75
+ while ((m = re.exec(body)) !== null) {
76
+ const [seq, params, cmd] = m;
77
+ if (cmd === undefined) {
78
+ if (seq.startsWith('\x1b')) continue; // OSC / charset — ignore
79
+ for (const part of seq.split(/(\r\n|\n|\r)/)) {
80
+ if (part === '\n' || part === '\r\n') { row = Math.min(rows - 1, row + 1); col = 0; }
81
+ else if (part === '\r') { col = 0; }
82
+ else put(part);
83
+ }
84
+ continue;
85
+ }
86
+ const n = params.split(';').map(x => Number(x || 0));
87
+ if (cmd === 'H' || cmd === 'f') { row = Math.max(0, (n[0] || 1) - 1); col = Math.max(0, (n[1] || 1) - 1); }
88
+ else if (cmd === 'J') { if ((n[0] ?? 0) === 2) { grid = Array.from({ length: rows }, () => ''); row = 0; col = 0; } }
89
+ else if (cmd === 'K') { grid[row] = (grid[row] ?? '').slice(0, col); }
90
+ else if (cmd === 'A') { row = Math.max(0, row - (n[0] || 1)); }
91
+ else if (cmd === 'B') { row = Math.min(rows - 1, row + (n[0] || 1)); }
92
+ // m (colour), h/l (modes) do not move the cursor or change content
93
+ }
94
+ },
95
+ snapshot() {
96
+ return grid.map(l => l.replace(/\s+$/, '')).join('\n').replace(/\n{3,}$/, '\n');
97
+ },
98
+ };
99
+ }
100
+
101
+ const screen = createScreen(ROWS, COLS);
102
+ const child = pty.spawn(process.execPath, ['dist/cli.js'], {
103
+ name: 'xterm-256color', cols: COLS, rows: ROWS, cwd: root,
104
+ env: { ...process.env, TERM: 'xterm-256color' },
105
+ });
106
+ child.onData(d => screen.write(d));
107
+
108
+ const wait = ms => new Promise(r => setTimeout(r, ms));
109
+ const strip = s => s.replace(/\x1b\[[0-9;]*m/g, '');
110
+
111
+ for (const step of steps) {
112
+ const kind = step[0];
113
+ const arg = step.slice(1);
114
+ if (kind === 'w') await wait(Number(arg));
115
+ else if (kind === 'k') child.write(KEYS[arg] ?? arg);
116
+ else if (kind === 's') {
117
+ process.stdout.write(`\n===== ${arg} =====\n${strip(screen.snapshot())}\n`);
118
+ }
119
+ }
120
+
121
+ child.kill();
122
+ process.exit(0);