tui-cap 0.1.0

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/cli.js ADDED
@@ -0,0 +1,648 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
36
+ var __importDefault = (this && this.__importDefault) || function (mod) {
37
+ return (mod && mod.__esModule) ? mod : { "default": mod };
38
+ };
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ const node_child_process_1 = require("node:child_process");
41
+ const promises_1 = require("node:fs/promises");
42
+ const node_path_1 = __importDefault(require("node:path"));
43
+ const parse_1 = require("./parse");
44
+ const paths_1 = require("./paths");
45
+ const svg_1 = require("./svg");
46
+ const edits_1 = require("./edits");
47
+ const record_1 = require("./record");
48
+ const server_1 = require("./server");
49
+ const asciinema_1 = require("./asciinema");
50
+ const meta_1 = require("./meta");
51
+ const fonts_1 = require("./fonts");
52
+ const version_1 = require("./version");
53
+ const HELP = `tui-cap — turn terminal output into editable, branded SVG
54
+
55
+ USAGE
56
+ tui-cap gui [options]
57
+ tui-cap render <input.ans> [options]
58
+ tui-cap asciinema <input.cast> [options]
59
+ tui-cap record -o <out.ans> -- <command...>
60
+ tui-cap update
61
+ tui-cap --version
62
+
63
+ GUI
64
+ Open a local web app to browse your captures library, flip through the frames
65
+ of a recording, tweak the look, export SVG/PNG, and edit + export a timed MP4
66
+ animation — no flag-guessing required.
67
+ tui-cap gui
68
+ --port <n> Port to serve on (default: 4787; auto-increments if busy)
69
+ --no-open Don't auto-open the browser; just print the URL
70
+
71
+ RENDER OPTIONS
72
+ -o, --out <file> Output SVG path (bare name → captures folder;
73
+ otherwise default: <input>.svg)
74
+ --cols <n> Replay width in columns (default: capture meta or 120)
75
+ --rows <n> Replay height in rows (default: capture meta or 50)
76
+ --mode <name> auto | final-frame | full-scroll (default: auto)
77
+ auto: final-frame for full-screen TUIs, else full-scroll
78
+ --max-rows <n> Cap rows kept in full-scroll (default: 1000)
79
+ --theme <name> dark | light (default: dark)
80
+ --font <stack> CSS font-family for the text
81
+ --font-url <src> Embed a font via @font-face (local file path or URL).
82
+ Local files are base64-embedded so the SVG stays
83
+ portable; matching --font names the @font-face family.
84
+ --embed-font Force embedding a local --font-url (default: on).
85
+ Use --no-embed-font to reference the path instead.
86
+ --font-size <px> Font size in px (default: 14)
87
+ --advance <ratio> Column advance as a fraction of font size (default: 0.618164).
88
+ Auto-set from --font when it's a known monospace family.
89
+ --line-height <r> Line height as a fraction of font size (default: 1.25)
90
+ --padding <px> Padding around the text (default: 24)
91
+ --title <text> Window title (default: "GitHub Copilot CLI")
92
+ --no-chrome Omit the window frame / title bar
93
+ --chrome-style <os> Title-bar layout: mac | mac-inactive | windows | windows-inactive (default: mac)
94
+ --radius <px> Window corner radius (default: 20 mac / 7 windows)
95
+
96
+ FRAME SELECTION
97
+ A full-screen TUI (like the Copilot CLI) paints into the alternate screen and
98
+ wipes it on exit, so the capture holds many frames — one per paint cycle. By
99
+ default render picks the settled full-screen frame the TUI held on, skipping
100
+ the exit teardown and never the blank post-exit screen.
101
+ --frame <n> Render frame n (1-based; -1 = last, -2 = second-last)
102
+ --list-frames List the captured frames and exit (writes no SVG)
103
+ --all-frames Write every frame as <out>-01.svg, <out>-02.svg, …
104
+
105
+ If a sibling <input>.meta.json sidecar exists (written by 'record'), its
106
+ cols/rows are used automatically unless --cols/--rows are given.
107
+
108
+ CONTENT EDITS
109
+ Recolours, rewrites, and spacing tweaks made in the GUI are saved to a sibling
110
+ <input>.edits.json sidecar. render applies them automatically (when the saved
111
+ capture size still matches), so CLI exports match the GUI.
112
+ --no-edits Ignore the .edits.json sidecar and render the raw capture
113
+
114
+ RECORD OPTIONS
115
+ -o, --out <file> Output .ans path (bare name → captures folder)
116
+ --backend <name> auto | script | pty (default: auto). auto uses the
117
+ node-pty backend for interactive keystroke capture when
118
+ node-pty is installed, and falls back to the system
119
+ script backend if it isn't (or can't start a PTY).
120
+ Only the pty backend writes the <out>.input.json
121
+ keystroke sidecar; script relies on the parser's
122
+ content-based typing detection.
123
+ --no-meta Don't write the <out>.meta.json size + .timing.json
124
+ sidecars (timing powers the GUI animation export)
125
+ --no-input Don't record keystrokes to the <out>.input.json sidecar
126
+ (keystroke timing powers the typing cursor + smoothing)
127
+ --no-gui Don't auto-launch the GUI when the recording finishes
128
+ --gui-port <n> Port for the auto-launched GUI (default: ${server_1.DEFAULT_GUI_PORT})
129
+
130
+ When a recording finishes, the GUI is opened automatically so you can scrub
131
+ frames and export SVG right away. If a GUI is already listening on the port
132
+ it's reused instead of starting a second server. Use --no-gui to opt out.
133
+
134
+ ASCIINEMA OPTIONS
135
+ -o, --out <file> Output SVG path (default: <input>.svg)
136
+ --at <seconds> Render the frame at this time (default: end of cast)
137
+ Cols/rows come from the cast header (and resize events); all RENDER
138
+ OPTIONS above also apply.
139
+
140
+ UPDATING
141
+ tui-cap checks npm for a newer release each time the GUI launches and shows a
142
+ banner with an "Update now" button. You can also update from the terminal:
143
+ tui-cap update Upgrade a global install to the latest published
144
+ version (runs npm install -g tui-cap@latest). Run
145
+ via npx? It always fetches the latest, so just re-run
146
+ npx tui-cap@latest.
147
+ tui-cap --version Print the installed version and exit.
148
+
149
+ OUTPUT LOCATION
150
+ Omit -o or pass a bare filename (no slash) and the file is written to your
151
+ captures folder: $GHCP_CAPTURE_DIR, or ~/captures by default. Pass a path
152
+ containing a slash (./shot.ans, /tmp/x.ans) to write relative to the current
153
+ directory instead.
154
+
155
+ EXAMPLES
156
+ tui-cap gui # browse + export in the browser
157
+ tui-cap record -o shot.ans -- copilot
158
+ tui-cap render shot.ans -o shot.svg --theme dark
159
+ tui-cap render shot.ans --list-frames # inspect captured frames
160
+ tui-cap render shot.ans -o f.svg --frame -2 # the second-to-last frame
161
+ tui-cap asciinema demo.cast -o demo.svg --at 4.2
162
+ # then drag the .svg into Figma or Illustrator — text stays editable
163
+ `;
164
+ function parseArgs(args) {
165
+ const positionals = [];
166
+ const flags = {};
167
+ const short = { o: 'out', h: 'help' };
168
+ // A leading '-' usually marks the next flag, but a negative number (e.g.
169
+ // `--frame -2`) is a value, not a flag.
170
+ const isValue = (s) => s !== undefined && (!s.startsWith('-') || /^-\d/.test(s));
171
+ for (let i = 0; i < args.length; i++) {
172
+ const a = args[i];
173
+ if (a === '--') {
174
+ positionals.push(...args.slice(i + 1));
175
+ break;
176
+ }
177
+ if (a.startsWith('--')) {
178
+ const key = a.slice(2);
179
+ if (key.startsWith('no-')) {
180
+ flags[key.slice(3)] = false;
181
+ continue;
182
+ }
183
+ const next = args[i + 1];
184
+ if (!isValue(next))
185
+ flags[key] = true;
186
+ else {
187
+ flags[key] = next;
188
+ i++;
189
+ }
190
+ }
191
+ else if (a.startsWith('-') && a.length === 2) {
192
+ const key = short[a[1]] ?? a[1];
193
+ const next = args[i + 1];
194
+ if (!isValue(next))
195
+ flags[key] = true;
196
+ else {
197
+ flags[key] = next;
198
+ i++;
199
+ }
200
+ }
201
+ else {
202
+ positionals.push(a);
203
+ }
204
+ }
205
+ return { positionals, flags };
206
+ }
207
+ function num(value, fallback) {
208
+ if (typeof value !== 'string')
209
+ return fallback;
210
+ const n = Number(value);
211
+ return Number.isFinite(n) ? n : fallback;
212
+ }
213
+ function str(value) {
214
+ return typeof value === 'string' ? value : undefined;
215
+ }
216
+ /** Resolve `--mode` / `--max-rows` shared by the render and asciinema commands. */
217
+ function parseModeFlags(flags) {
218
+ const validModes = ['auto', 'final-frame', 'full-scroll'];
219
+ const modeFlag = str(flags.mode);
220
+ let mode = 'auto';
221
+ if (modeFlag !== undefined) {
222
+ if (validModes.includes(modeFlag)) {
223
+ mode = modeFlag;
224
+ }
225
+ else {
226
+ console.error(`Unknown --mode "${modeFlag}"; using auto. Valid: ${validModes.join(', ')}.`);
227
+ }
228
+ }
229
+ const maxRows = flags['max-rows'] !== undefined ? num(flags['max-rows'], 1000) : undefined;
230
+ return { mode, maxRows };
231
+ }
232
+ /**
233
+ * Build RenderOptions from the shared render flags. Throws on an unreadable
234
+ * `--font-url` so the caller can surface a clear error and exit non-zero.
235
+ */
236
+ function resolveRenderOptions(flags, themeName) {
237
+ const overrides = {};
238
+ if (flags.chrome === false)
239
+ overrides.chrome = false;
240
+ const chromeStyle = str(flags['chrome-style']);
241
+ if (chromeStyle === 'windows' ||
242
+ chromeStyle === 'windows-inactive' ||
243
+ chromeStyle === 'mac' ||
244
+ chromeStyle === 'mac-inactive')
245
+ overrides.chromeStyle = chromeStyle;
246
+ if (str(flags.title) !== undefined)
247
+ overrides.title = str(flags.title);
248
+ if (str(flags.font) !== undefined)
249
+ overrides.fontFamily = str(flags.font);
250
+ if (flags['font-size'] !== undefined)
251
+ overrides.fontSize = num(flags['font-size'], 14);
252
+ if (flags.advance !== undefined)
253
+ overrides.advance = num(flags.advance, 0.618164);
254
+ if (flags['line-height'] !== undefined)
255
+ overrides.lineHeight = num(flags['line-height'], 1.25);
256
+ if (flags.padding !== undefined)
257
+ overrides.padding = num(flags.padding, 24);
258
+ if (flags.radius !== undefined)
259
+ overrides.radius = num(flags.radius, 20);
260
+ const opts = (0, svg_1.defaultRenderOptions)(themeName, overrides);
261
+ // Auto-tune the column advance for known monospace fonts, unless the user
262
+ // pinned it explicitly with --advance.
263
+ if (str(flags.font) !== undefined && flags.advance === undefined) {
264
+ const auto = (0, fonts_1.advanceForFont)(opts.fontFamily);
265
+ if (auto !== undefined)
266
+ opts.advance = auto;
267
+ }
268
+ // Embed a font face when requested so non-Figma targets resolve exact metrics.
269
+ const fontUrl = str(flags['font-url']);
270
+ if (fontUrl !== undefined) {
271
+ const embed = flags['embed-font'] !== false;
272
+ try {
273
+ opts.fontFace = (0, fonts_1.buildFontFaceCss)((0, fonts_1.primaryFamily)(opts.fontFamily), fontUrl, { embed });
274
+ }
275
+ catch (err) {
276
+ throw new Error(`Could not read font for --font-url "${fontUrl}": ${err instanceof Error ? err.message : err}`);
277
+ }
278
+ }
279
+ return opts;
280
+ }
281
+ /**
282
+ * Load the content edits that apply to a render, mirroring the server's
283
+ * `applicableEdits`: none when `--no-edits` is set or the sidecar is missing,
284
+ * and none (with a warning) when the saved capture size no longer matches —
285
+ * so stale anchors are never smeared across a re-recorded capture.
286
+ */
287
+ async function loadRenderEdits(input, cols, rows, flags) {
288
+ if (flags.edits === false)
289
+ return [];
290
+ const doc = await (0, edits_1.readEdits)(input);
291
+ if (!doc || doc.edits.length === 0)
292
+ return [];
293
+ if (!(0, edits_1.fingerprintMatches)(doc, { ansMtime: 0, cols, rows })) {
294
+ const n = doc.edits.length;
295
+ console.error(`Skipping ${n} saved content edit${n === 1 ? '' : 's'}: they were made for a ` +
296
+ `different capture size. Re-open the recording in the GUI to refresh them, ` +
297
+ `or pass --no-edits to silence this.`);
298
+ return [];
299
+ }
300
+ return doc.edits;
301
+ }
302
+ async function runRender(args) {
303
+ const { positionals, flags } = parseArgs(args);
304
+ const inputArg = positionals[0];
305
+ if (!inputArg) {
306
+ console.error('render: missing input file\n');
307
+ console.log(HELP);
308
+ process.exitCode = 1;
309
+ return;
310
+ }
311
+ const input = await (0, paths_1.resolveInput)(inputArg);
312
+ const data = await (0, promises_1.readFile)(input);
313
+ // Default the replay size to the recorded PTY size when a sidecar is present,
314
+ // so --cols/--rows become optional. Explicit flags still win.
315
+ const meta = await (0, meta_1.readMeta)(input);
316
+ const cols = num(flags.cols, meta?.cols ?? 120);
317
+ const rows = num(flags.rows, meta?.rows ?? 50);
318
+ if (meta && flags.cols === undefined && flags.rows === undefined) {
319
+ console.error(`Using capture size ${cols}×${rows} from ${node_path_1.default.basename((0, meta_1.metaPathFor)(input))}`);
320
+ }
321
+ const themeName = str(flags.theme) === 'light' ? 'light' : 'dark';
322
+ // Content edits saved by the GUI live in a sibling .edits.json sidecar. Apply
323
+ // them through the same pure Grid→Grid transform the server uses, so non-GUI
324
+ // exports match the preview. `--no-edits` opts out; a size mismatch (re-record)
325
+ // is skipped with a warning rather than smeared across new content.
326
+ const edits = await loadRenderEdits(input, cols, rows, flags);
327
+ const applyEdits = (grid) => (edits.length ? (0, edits_1.toDisplayGrid)(grid, edits) : grid);
328
+ let opts;
329
+ try {
330
+ opts = resolveRenderOptions(flags, themeName);
331
+ }
332
+ catch (err) {
333
+ console.error(err instanceof Error ? err.message : err);
334
+ process.exitCode = 1;
335
+ return;
336
+ }
337
+ const out = (0, paths_1.resolveOut)(str(flags.out), node_path_1.default.basename(input, node_path_1.default.extname(input)) + '.svg');
338
+ await (0, promises_1.mkdir)(node_path_1.default.dirname(out), { recursive: true });
339
+ // Two rendering models share this command:
340
+ // • Scrollback model (--mode / --max-rows): one tall grid via parseAnsiToGrid,
341
+ // preserving scrolled history for plain (non-alt-screen) output.
342
+ // • Frame model (default): discrete frames via extractFrames, capturing the
343
+ // live alt-screen frames of a TUI (the Copilot CLI) and powering --frame /
344
+ // --list-frames / --all-frames and the GUI scrubber.
345
+ if (flags.mode !== undefined || flags['max-rows'] !== undefined) {
346
+ const { mode, maxRows } = parseModeFlags(flags);
347
+ const grid = await (0, parse_1.parseAnsiToGrid)(data, { cols, rows, theme: themeName, mode, maxRows });
348
+ await (0, promises_1.writeFile)(out, (0, svg_1.renderSvg)(applyEdits(grid), opts), 'utf8');
349
+ console.error(`Wrote ${out}`);
350
+ return;
351
+ }
352
+ const frames = await (0, parse_1.extractFrames)(data, {
353
+ cols,
354
+ rows,
355
+ theme: themeName,
356
+ });
357
+ if (flags['list-frames'] === true) {
358
+ console.error(`${frames.length} frame${frames.length === 1 ? '' : 's'} captured:`);
359
+ frames.forEach((f, i) => {
360
+ const tag = f.inAlt ? 'full-screen' : 'normal ';
361
+ console.error(` ${String(i + 1).padStart(3)} ${tag} ${(0, parse_1.framePreview)(f.grid)}`);
362
+ });
363
+ console.error('\nRender one with --frame <n>, or every frame with --all-frames.');
364
+ return;
365
+ }
366
+ if (flags['all-frames'] === true) {
367
+ const ext = node_path_1.default.extname(out) || '.svg';
368
+ const stem = out.slice(0, out.length - ext.length);
369
+ const pad = String(frames.length).length;
370
+ for (let i = 0; i < frames.length; i++) {
371
+ const file = `${stem}-${String(i + 1).padStart(pad, '0')}${ext}`;
372
+ await (0, promises_1.writeFile)(file, (0, svg_1.renderSvg)((0, parse_1.trimBackgroundBleed)(applyEdits(frames[i].grid)), opts), 'utf8');
373
+ console.error(`Wrote ${file}`);
374
+ }
375
+ return;
376
+ }
377
+ const which = flags.frame !== undefined ? num(flags.frame, 0) : undefined;
378
+ const { grid, index } = (0, parse_1.chooseFrame)(frames, which);
379
+ await (0, promises_1.writeFile)(out, (0, svg_1.renderSvg)((0, parse_1.trimBackgroundBleed)(applyEdits(grid)), opts), 'utf8');
380
+ const suffix = frames.length > 1 ? ` (frame ${index + 1} of ${frames.length})` : '';
381
+ console.error(`Wrote ${out}${suffix}`);
382
+ }
383
+ async function runAsciinema(args) {
384
+ const { positionals, flags } = parseArgs(args);
385
+ const input = positionals[0];
386
+ if (!input) {
387
+ console.error('asciinema: missing input .cast file\n');
388
+ console.log(HELP);
389
+ process.exitCode = 1;
390
+ return;
391
+ }
392
+ const raw = await (0, promises_1.readFile)(input, 'utf8');
393
+ const at = flags.at !== undefined ? num(flags.at, NaN) : undefined;
394
+ if (at !== undefined && !Number.isFinite(at)) {
395
+ console.error(`asciinema: --at must be a number (seconds), got "${str(flags.at)}"`);
396
+ process.exitCode = 1;
397
+ return;
398
+ }
399
+ let frame;
400
+ try {
401
+ frame = (0, asciinema_1.extractFrame)(raw, at);
402
+ }
403
+ catch (err) {
404
+ console.error(err instanceof Error ? err.message : err);
405
+ process.exitCode = 1;
406
+ return;
407
+ }
408
+ // The cast header (and any resize events) carry the terminal size, so
409
+ // --cols/--rows are optional here too but still win when provided.
410
+ const cols = num(flags.cols, frame.cols);
411
+ const rows = num(flags.rows, frame.rows);
412
+ const themeName = str(flags.theme) === 'light' ? 'light' : 'dark';
413
+ const { mode, maxRows } = parseModeFlags(flags);
414
+ const grid = await (0, parse_1.parseAnsiToGrid)(Buffer.from(frame.ansi, 'utf8'), {
415
+ cols,
416
+ rows,
417
+ theme: themeName,
418
+ mode,
419
+ maxRows,
420
+ });
421
+ let opts;
422
+ try {
423
+ opts = resolveRenderOptions(flags, themeName);
424
+ }
425
+ catch (err) {
426
+ console.error(err instanceof Error ? err.message : err);
427
+ process.exitCode = 1;
428
+ return;
429
+ }
430
+ const svg = (0, svg_1.renderSvg)(grid, opts);
431
+ const out = str(flags.out) ??
432
+ node_path_1.default.join(node_path_1.default.dirname(input), node_path_1.default.basename(input, node_path_1.default.extname(input)) + '.svg');
433
+ await (0, promises_1.writeFile)(out, svg, 'utf8');
434
+ console.error(`Wrote ${out} (frame at ${at ?? 'end'}, ${cols}×${rows})`);
435
+ }
436
+ async function hasNodePty() {
437
+ // node-pty is an optional native dependency, loaded via a variable specifier
438
+ // so tsc/bundlers don't hard-require it. A successful import means it built.
439
+ const moduleName = 'node-pty';
440
+ try {
441
+ await Promise.resolve(`${moduleName}`).then(s => __importStar(require(s)));
442
+ return true;
443
+ }
444
+ catch {
445
+ return false;
446
+ }
447
+ }
448
+ /**
449
+ * Pick the record backend. An explicit `--backend script|pty` always wins.
450
+ * Otherwise (`auto`, the default) prefer node-pty for interactive keystroke
451
+ * capture — the `script` backend can't tee stdin (it requires the controlling
452
+ * TTY) — and fall back to `script` when node-pty isn't installed or there are
453
+ * no keystrokes to capture (non-interactive / `--no-input`).
454
+ */
455
+ async function resolveRecordBackend(requested, wantInput) {
456
+ if (requested === 'pty')
457
+ return 'pty';
458
+ if (requested === 'script')
459
+ return 'script';
460
+ if (wantInput && Boolean(process.stdin.isTTY) && (await hasNodePty()))
461
+ return 'pty';
462
+ return 'script';
463
+ }
464
+ async function runRecord(args) {
465
+ const { positionals, flags } = parseArgs(args);
466
+ if (positionals.length === 0) {
467
+ console.error('record: provide a command after `--`, e.g. `record -o shot.ans -- copilot`\n');
468
+ process.exitCode = 1;
469
+ return;
470
+ }
471
+ const outFlag = str(flags.out);
472
+ const defaultName = outFlag === undefined ? await (0, paths_1.nextCaptureName)() : 'capture.ans';
473
+ const out = (0, paths_1.resolveOut)(outFlag, defaultName);
474
+ await (0, promises_1.mkdir)(node_path_1.default.dirname(out), { recursive: true });
475
+ const requested = str(flags.backend);
476
+ const explicitPty = requested === 'pty';
477
+ const backend = await resolveRecordBackend(requested, flags.input !== false);
478
+ const recordOpts = { meta: flags.meta !== false, input: flags.input !== false };
479
+ try {
480
+ let code;
481
+ if (backend === 'pty') {
482
+ try {
483
+ console.error(`Recording to ${out} via pty — exit the program to finish.`);
484
+ const { recordSessionPty } = await Promise.resolve().then(() => __importStar(require('./record-pty')));
485
+ code = await recordSessionPty(positionals, out, recordOpts);
486
+ }
487
+ catch (ptyErr) {
488
+ // An explicit `--backend pty` should surface the failure; an
489
+ // auto-selected pty (node-pty present but unable to start a PTY in this
490
+ // environment) falls back to `script` so recording still works.
491
+ if (explicitPty)
492
+ throw ptyErr;
493
+ const why = ptyErr instanceof Error ? ptyErr.message.split('\n')[0] : String(ptyErr);
494
+ console.error(` pty backend couldn't start (${why}); falling back to script.`);
495
+ console.error(`Recording to ${out} via script — exit the program to finish.`);
496
+ code = await (0, record_1.recordSession)(positionals, out, recordOpts);
497
+ }
498
+ }
499
+ else {
500
+ console.error(`Recording to ${out} via script — exit the program to finish.`);
501
+ code = await (0, record_1.recordSession)(positionals, out, recordOpts);
502
+ }
503
+ console.error(`\nSaved ${out}.`);
504
+ process.exitCode = code;
505
+ await maybeLaunchGui(flags);
506
+ }
507
+ catch (err) {
508
+ console.error(err instanceof Error ? err.message : err);
509
+ process.exitCode = 1;
510
+ }
511
+ }
512
+ /**
513
+ * After a recording finishes, make the GUI available so the capture can be
514
+ * scrubbed and exported immediately. Reuses an already-running server (detected
515
+ * by a quick port probe) so repeated recordings don't spawn duplicates, and
516
+ * otherwise launches `gui` as a detached child that outlives this process.
517
+ * Opt out with --no-gui; choose the port with --gui-port.
518
+ */
519
+ async function maybeLaunchGui(flags) {
520
+ if (flags.gui === false)
521
+ return;
522
+ const host = '127.0.0.1';
523
+ const port = flags['gui-port'] !== undefined ? num(flags['gui-port'], server_1.DEFAULT_GUI_PORT) : server_1.DEFAULT_GUI_PORT;
524
+ const url = `http://${host}:${port}`;
525
+ if (await (0, server_1.isPortInUse)(port, host)) {
526
+ console.error(` GUI already running → ${url} (open it to view this capture)`);
527
+ return;
528
+ }
529
+ // Re-exec ourselves as a detached `gui` process so the server keeps running
530
+ // after this record command exits and frees the terminal. The child opens the
531
+ // browser once it has bound, avoiding a race on the port.
532
+ let child;
533
+ try {
534
+ child = (0, node_child_process_1.spawn)(process.execPath, [process.argv[1], 'gui', '--port', String(port)], {
535
+ detached: true,
536
+ stdio: 'ignore',
537
+ });
538
+ }
539
+ catch (err) {
540
+ console.error(` Could not launch GUI: ${err instanceof Error ? err.message : err}`);
541
+ console.error(` Start it yourself with: tui-cap gui`);
542
+ return;
543
+ }
544
+ child.on('error', () => {
545
+ /* surfaced via the waitForPort check below */
546
+ });
547
+ child.unref();
548
+ const up = await (0, server_1.waitForPort)(port, host);
549
+ if (up)
550
+ console.error(` Launched GUI → ${url}`);
551
+ else
552
+ console.error(` Starting GUI at ${url} … (give it a moment, then refresh)`);
553
+ }
554
+ async function runGui(args) {
555
+ const { flags } = parseArgs(args);
556
+ const port = flags.port !== undefined ? num(flags.port, server_1.DEFAULT_GUI_PORT) : undefined;
557
+ const open = flags.open !== false;
558
+ const { url } = await (0, server_1.startServer)({ port, open });
559
+ console.error(`\n GHCP Capture GUI → ${url}`);
560
+ console.error(` Library: ${(0, paths_1.capturesDir)()}`);
561
+ console.error(` Press Ctrl+C to stop.\n`);
562
+ // Surface a newer release without delaying startup; the server is already up.
563
+ void noteUpdateIfAvailable();
564
+ }
565
+ /**
566
+ * Fire-and-forget check that prints a one-line note when a newer version is
567
+ * published. Best-effort: any network/parse failure is swallowed so it never
568
+ * interferes with the running GUI.
569
+ */
570
+ async function noteUpdateIfAvailable() {
571
+ try {
572
+ const info = await (0, version_1.checkForUpdate)();
573
+ if (!info.updateAvailable || !info.latest)
574
+ return;
575
+ console.error(` ⬆ Update available: v${info.current} → v${info.latest}. ` +
576
+ `Run 'tui-cap update' or use the button in the GUI.\n`);
577
+ }
578
+ catch {
579
+ // offline / registry hiccup — stay quiet
580
+ }
581
+ }
582
+ /**
583
+ * `tui-cap update` — upgrade a global install to the latest published version.
584
+ * Skips the work when already current, and points npx users at the re-run they
585
+ * actually need (there's nothing persistent to upgrade).
586
+ */
587
+ async function runUpdateCmd() {
588
+ const current = (0, version_1.currentVersion)();
589
+ const kind = (0, version_1.installKind)();
590
+ const latest = await fetchLatestForUpdate();
591
+ if (latest && !(0, version_1.isNewer)(latest, current)) {
592
+ console.error(`tui-cap is already up to date (v${current}).`);
593
+ return;
594
+ }
595
+ if (kind === 'npx') {
596
+ console.error(`You're running tui-cap via npx, which fetches the latest on demand.\n` +
597
+ ` Re-run: npx tui-cap@latest`);
598
+ return;
599
+ }
600
+ const target = latest ? `v${latest}` : 'the latest version';
601
+ console.error(`Updating tui-cap (v${current}) → ${target} …`);
602
+ console.error(` ${(0, version_1.updateCommand)()}\n`);
603
+ const result = await (0, version_1.runSelfUpdate)();
604
+ if (result.ok) {
605
+ console.error(`\n✓ Updated to ${target}. Restart any running 'tui-cap gui' to use it.`);
606
+ }
607
+ else {
608
+ console.error(`\n✗ Update failed. Run it yourself:\n ${(0, version_1.updateCommand)()}`);
609
+ const tail = result.output.split('\n').filter(Boolean).slice(-6).join('\n');
610
+ if (tail)
611
+ console.error(`\n${tail}`);
612
+ process.exitCode = 1;
613
+ }
614
+ }
615
+ /** Latest version lookup for the update command, tolerant of being offline. */
616
+ async function fetchLatestForUpdate() {
617
+ const info = await (0, version_1.checkForUpdate)().catch(() => null);
618
+ return info?.latest ?? null;
619
+ }
620
+ async function main() {
621
+ const argv = process.argv.slice(2);
622
+ const cmd = argv[0];
623
+ if (!cmd || cmd === '-h' || cmd === '--help' || cmd === 'help') {
624
+ console.log(HELP);
625
+ return;
626
+ }
627
+ if (cmd === '-v' || cmd === '--version' || cmd === 'version') {
628
+ console.log((0, version_1.currentVersion)());
629
+ return;
630
+ }
631
+ if (cmd === 'gui')
632
+ return runGui(argv.slice(1));
633
+ if (cmd === 'render')
634
+ return runRender(argv.slice(1));
635
+ if (cmd === 'asciinema')
636
+ return runAsciinema(argv.slice(1));
637
+ if (cmd === 'record')
638
+ return runRecord(argv.slice(1));
639
+ if (cmd === 'update')
640
+ return runUpdateCmd();
641
+ console.error(`Unknown command: ${cmd}\n`);
642
+ console.log(HELP);
643
+ process.exitCode = 1;
644
+ }
645
+ main().catch((err) => {
646
+ console.error(err instanceof Error ? err.message : err);
647
+ process.exitCode = 1;
648
+ });