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/LICENSE +21 -0
- package/README.md +213 -0
- package/dist/anim.js +104 -0
- package/dist/animation.js +96 -0
- package/dist/asciinema.js +125 -0
- package/dist/cli.js +648 -0
- package/dist/edits.js +527 -0
- package/dist/fonts.js +87 -0
- package/dist/input.js +136 -0
- package/dist/meta.js +58 -0
- package/dist/palette.js +111 -0
- package/dist/parse.js +659 -0
- package/dist/paths.js +100 -0
- package/dist/record-pty.js +148 -0
- package/dist/record.js +100 -0
- package/dist/server.js +978 -0
- package/dist/svg.js +767 -0
- package/dist/timing.js +112 -0
- package/dist/types.js +2 -0
- package/dist/version.js +232 -0
- package/dist/web/app.js +3312 -0
- package/dist/web/fonts/MonaSansMonoVF-wght.woff2 +0 -0
- package/dist/web/fonts/MonaSansVF-wdth-wght-opsz.woff2 +0 -0
- package/dist/web/fonts/README.md +13 -0
- package/dist/web/index.html +382 -0
- package/dist/web/logo.svg +11 -0
- package/dist/web/styles.css +925 -0
- package/dist/web/timing-model.js +115 -0
- package/dist/web/vendor/mp4-muxer.LICENSE +21 -0
- package/dist/web/vendor/mp4-muxer.js +1885 -0
- package/package.json +61 -0
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
|
+
});
|