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/parse.js
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseAnsiToGrid = parseAnsiToGrid;
|
|
4
|
+
exports.extractFrames = extractFrames;
|
|
5
|
+
exports.chooseFrame = chooseFrame;
|
|
6
|
+
exports.trimBackgroundBleed = trimBackgroundBleed;
|
|
7
|
+
exports.framePreview = framePreview;
|
|
8
|
+
const headless_1 = require("@xterm/headless");
|
|
9
|
+
const palette_1 = require("./palette");
|
|
10
|
+
const timing_1 = require("./timing");
|
|
11
|
+
const animation_1 = require("./animation");
|
|
12
|
+
// Alternate-screen enter (DECSET 1049, also 47/1047). A stream that switches to
|
|
13
|
+
// the alternate buffer is a full-screen TUI: it owns the whole window and
|
|
14
|
+
// repaints in place, so only the final frame is meaningful.
|
|
15
|
+
const ALT_SCREEN_ENTER = /\x1b\[\?(?:1049|1047|47)h/;
|
|
16
|
+
function resolveMode(requested, text) {
|
|
17
|
+
if (requested !== 'auto')
|
|
18
|
+
return requested;
|
|
19
|
+
return ALT_SCREEN_ENTER.test(text) ? 'final-frame' : 'full-scroll';
|
|
20
|
+
}
|
|
21
|
+
function rowHasContent(cells) {
|
|
22
|
+
return cells.some((c) => c.char.trim() !== '' || c.bg !== null);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Replay a raw ANSI/VT byte stream through a headless terminal emulator and
|
|
26
|
+
* read back a single on-screen grid as styled cells.
|
|
27
|
+
*
|
|
28
|
+
* `final-frame` returns the visible window at the end of the stream;
|
|
29
|
+
* `full-scroll` returns the whole buffer (viewport + scrollback). For
|
|
30
|
+
* multi-frame extraction of a full-screen TUI (every paint cycle), use
|
|
31
|
+
* {@link extractFrames} instead.
|
|
32
|
+
*/
|
|
33
|
+
async function parseAnsiToGrid(data, opts) {
|
|
34
|
+
const text = typeof data === 'string' ? data : data.toString('utf8');
|
|
35
|
+
const theme = opts.theme ?? 'dark';
|
|
36
|
+
const warn = opts.warn ?? ((m) => console.error(m));
|
|
37
|
+
const mode = resolveMode(opts.mode ?? 'auto', text);
|
|
38
|
+
const fullScroll = mode === 'full-scroll';
|
|
39
|
+
const maxRows = Math.max(1, Math.floor(opts.maxRows ?? 1000));
|
|
40
|
+
// full-scroll keeps up to `maxRows` total lines (viewport + scrollback); the
|
|
41
|
+
// emulator caps total buffer height at `scrollback + rows`, so size the
|
|
42
|
+
// scrollback so the buffer holds at most `maxRows` lines.
|
|
43
|
+
const scrollback = fullScroll ? Math.max(0, maxRows - opts.rows) : 0;
|
|
44
|
+
const term = new headless_1.Terminal({
|
|
45
|
+
cols: opts.cols,
|
|
46
|
+
rows: opts.rows,
|
|
47
|
+
allowProposedApi: true,
|
|
48
|
+
scrollback,
|
|
49
|
+
});
|
|
50
|
+
// Count lines that scroll off the top so we can detect (and report) loss.
|
|
51
|
+
// In final-frame each scrolled line is gone for good; in full-scroll a line is
|
|
52
|
+
// only lost once it scrolls past the (sized) scrollback.
|
|
53
|
+
let scrolledLines = 0;
|
|
54
|
+
const disposeScroll = term.onScroll(() => {
|
|
55
|
+
scrolledLines += 1;
|
|
56
|
+
});
|
|
57
|
+
await new Promise((resolve) => term.write(text, () => resolve()));
|
|
58
|
+
// buffer.active is whichever buffer is in use at the end of the stream — the
|
|
59
|
+
// alternate buffer for a full-screen TUI, the normal buffer otherwise.
|
|
60
|
+
const buffer = term.buffer.active;
|
|
61
|
+
let readStart;
|
|
62
|
+
let readEnd;
|
|
63
|
+
if (fullScroll) {
|
|
64
|
+
// Read the whole buffer (scrollback + viewport); the emulator already
|
|
65
|
+
// dropped anything older than the cap.
|
|
66
|
+
readStart = 0;
|
|
67
|
+
readEnd = buffer.length;
|
|
68
|
+
const total = scrolledLines + opts.rows;
|
|
69
|
+
if (scrolledLines > scrollback) {
|
|
70
|
+
// More scrolled than the scrollback could hold → oldest rows truncated.
|
|
71
|
+
warn(`Warning: captured ~${total} rows but --max-rows is ${maxRows}; kept the ` +
|
|
72
|
+
`most recent ${maxRows} (oldest ~${total - maxRows} truncated). ` +
|
|
73
|
+
`Increase --max-rows to capture everything.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
// final-frame: just the visible window.
|
|
78
|
+
readStart = 0;
|
|
79
|
+
readEnd = opts.rows;
|
|
80
|
+
// Don't silently drop content (v0.1 bug F1). If we're on the normal buffer
|
|
81
|
+
// and lines scrolled past the top, they were truncated — warn so it's never
|
|
82
|
+
// quiet. The alternate buffer (full-screen TUI) intentionally shows only the
|
|
83
|
+
// final frame, so suppress the warning there.
|
|
84
|
+
if (scrolledLines > 0 && buffer.type !== 'alternate') {
|
|
85
|
+
warn(`Warning: ${scrolledLines} line(s) scrolled past the top of the ` +
|
|
86
|
+
`${opts.rows}-row capture window and were not captured. Use ` +
|
|
87
|
+
`--mode full-scroll (or increase --rows) to capture everything.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
disposeScroll.dispose();
|
|
91
|
+
const lines = [];
|
|
92
|
+
for (let y = readStart; y < readEnd; y++) {
|
|
93
|
+
lines.push(readLine(buffer.getLine(y), opts.cols, theme));
|
|
94
|
+
}
|
|
95
|
+
// full-scroll grids end at real content; trim trailing blank rows so the SVG
|
|
96
|
+
// (and the row count) reflect the captured output, not the empty tail of the
|
|
97
|
+
// viewport. final-frame keeps a fixed window.
|
|
98
|
+
if (fullScroll) {
|
|
99
|
+
while (lines.length > 1 && !rowHasContent(lines[lines.length - 1])) {
|
|
100
|
+
lines.pop();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
term.dispose();
|
|
104
|
+
return { cols: opts.cols, rows: lines.length, lines };
|
|
105
|
+
}
|
|
106
|
+
// Matches the screen-replacing control sequences we cut frames on:
|
|
107
|
+
// ESC[?1049h / ?1047h / ?47h enter alternate buffer (group 1 = 'h')
|
|
108
|
+
// ESC[?1049l / ?1047l / ?47l leave alternate buffer (group 1 = 'l')
|
|
109
|
+
// ESC[?25h show cursor (paint done) (group 2)
|
|
110
|
+
// ESC[?2026l end synchronized update (group 3)
|
|
111
|
+
// ESC[<row;col>H cursor position (group 4 = params)
|
|
112
|
+
// ESC[<n>J erase in display (group 5 = params)
|
|
113
|
+
const SEQ = /\x1b\[(?:\?(?:1049|1047|47)([hl])|(\?25h)|(\?2026l)|([0-9;]*)H|([0-9;]*)J)/g;
|
|
114
|
+
function isHome(params) {
|
|
115
|
+
const row = params.split(';')[0];
|
|
116
|
+
return row === '' || row === '1';
|
|
117
|
+
}
|
|
118
|
+
function findBoundaries(text) {
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const m of text.matchAll(SEQ)) {
|
|
121
|
+
const start = m.index;
|
|
122
|
+
if (m[1]) {
|
|
123
|
+
out.push({ start, kind: m[1] === 'h' ? 'enter' : 'leave' });
|
|
124
|
+
}
|
|
125
|
+
else if (m[2]) {
|
|
126
|
+
// Cursor-show marks the end of a paint cycle: TUIs hide the cursor,
|
|
127
|
+
// repaint in place, then show it again. Snapshotting here recovers one
|
|
128
|
+
// frame per repaint, giving the timeline fine temporal detail. Identical
|
|
129
|
+
// consecutive frames are collapsed later, so static screens stay compact.
|
|
130
|
+
out.push({ start, kind: 'paint' });
|
|
131
|
+
}
|
|
132
|
+
else if (m[3]) {
|
|
133
|
+
// End of a synchronized update (DECRST 2026 — BSU `?2026h` … ESU `?2026l`).
|
|
134
|
+
// Ink-style TUIs (the Copilot CLI) wrap every atomic redraw in a sync block
|
|
135
|
+
// and keep the cursor *hidden* for the whole duration of an interaction —
|
|
136
|
+
// opening a menu, arrowing through it, and closing it all happen with the
|
|
137
|
+
// cursor down, so the `?25h` paint signal never fires while those screens
|
|
138
|
+
// are visible. Cutting a frame at each ESU recovers one snapshot per atomic
|
|
139
|
+
// update, which is what makes submenu navigation (e.g. /agents, /delegate)
|
|
140
|
+
// and in-tab list highlighting show up on the timeline at all. Identical
|
|
141
|
+
// consecutive frames are collapsed later, so static screens stay compact.
|
|
142
|
+
out.push({ start, kind: 'flush' });
|
|
143
|
+
}
|
|
144
|
+
else if (m[4] !== undefined) {
|
|
145
|
+
// Cursor-home (`ESC[H`, `ESC[1;1H`) marks an in-place repaint: the TUI
|
|
146
|
+
// homes the cursor and redraws. Snapshotting here recovers one frame per
|
|
147
|
+
// repaint, which is what makes tab switches and other full-redraws show up
|
|
148
|
+
// on the timeline. Always on — identical consecutive frames are collapsed
|
|
149
|
+
// later, so static screens stay compact.
|
|
150
|
+
if (isHome(m[4]))
|
|
151
|
+
out.push({ start, kind: 'repaint' });
|
|
152
|
+
}
|
|
153
|
+
else if (m[5] !== undefined) {
|
|
154
|
+
// Only full-screen erases reset a frame; partial erases (0J/1J) don't.
|
|
155
|
+
if (m[5] === '2' || m[5] === '3')
|
|
156
|
+
out.push({ start, kind: 'clear' });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
function readGrid(term, cols, rows, theme) {
|
|
162
|
+
const buffer = term.buffer.active;
|
|
163
|
+
const lines = [];
|
|
164
|
+
for (let y = 0; y < rows; y++) {
|
|
165
|
+
lines.push(readLine(buffer.getLine(y), cols, theme));
|
|
166
|
+
}
|
|
167
|
+
return { cols, rows, lines };
|
|
168
|
+
}
|
|
169
|
+
function isBlank(grid) {
|
|
170
|
+
return grid.lines.every((line) => line.every((c) => c.char.trim() === ''));
|
|
171
|
+
}
|
|
172
|
+
function signature(grid) {
|
|
173
|
+
return grid.lines
|
|
174
|
+
.map((line) => line
|
|
175
|
+
.map((c) => {
|
|
176
|
+
// Style flags that change a cell's visible appearance. Folding them
|
|
177
|
+
// into the signature keeps highlight-only moves — a selected menu row
|
|
178
|
+
// that changes only its background fill or flips to reverse-video,
|
|
179
|
+
// with the same text and foreground — from collapsing into the
|
|
180
|
+
// previous frame, so list navigation actually shows up on the timeline.
|
|
181
|
+
const flags = (c.bold ? 'b' : '') +
|
|
182
|
+
(c.italic ? 'i' : '') +
|
|
183
|
+
(c.underline ? 'u' : '') +
|
|
184
|
+
(c.dim ? 'd' : '') +
|
|
185
|
+
(c.strike ? 's' : '') +
|
|
186
|
+
(c.inverse ? 'r' : '');
|
|
187
|
+
return `${c.col}:${c.char}:${c.fg ?? ''}:${c.bg ?? ''}:${flags}`;
|
|
188
|
+
})
|
|
189
|
+
.join(''))
|
|
190
|
+
.join('\n');
|
|
191
|
+
}
|
|
192
|
+
// The Copilot CLI prompt input box draws its content on a row that begins with
|
|
193
|
+
// a heavy vertical bar (U+2503 "┃"), e.g. "┃ my prompt", and wraps that row (or
|
|
194
|
+
// rows) in a heavy border whose left edge caps are "╻" (U+257B) just above the
|
|
195
|
+
// top content row and "╹" (U+2579) just below the bottom one.
|
|
196
|
+
//
|
|
197
|
+
// Two things together identify a *genuine editable prompt* and reject the
|
|
198
|
+
// look-alikes that also begin a row with "┃":
|
|
199
|
+
// 1. The caps: a stray box right-border "┃" on an otherwise-blank row has no
|
|
200
|
+
// ╻/╹ around it, so it is skipped.
|
|
201
|
+
// 2. Flush to column 0: the prompt input always spans the full terminal width
|
|
202
|
+
// with its border at column 0, whereas selected issue/PR list rows are
|
|
203
|
+
// drawn in the *same* heavy box but inset by one column (marker at col 1).
|
|
204
|
+
// Requiring the marker at column 0 keeps the caret off those menu items.
|
|
205
|
+
const INPUT_MARKER = '\u2503';
|
|
206
|
+
const INPUT_CAP_TOP = '\u257b'; // ╻ — left-edge cap above the prompt content
|
|
207
|
+
const INPUT_CAP_BOTTOM = '\u2579'; // ╹ — left-edge cap below the prompt content
|
|
208
|
+
/** The first non-blank cell of a grid row (its glyph + visual column), or null
|
|
209
|
+
* when the row is blank. */
|
|
210
|
+
function firstCell(line) {
|
|
211
|
+
for (const cell of line) {
|
|
212
|
+
if (cell.char.trim() !== '')
|
|
213
|
+
return { char: cell.char, col: cell.col };
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
/** Locate the prompt input box on a frame, or null if none is present. Only a
|
|
218
|
+
* genuine editable prompt counts: the run of "┃" content rows must be wrapped
|
|
219
|
+
* by the box's heavy left-edge caps (╻ above, ╹ below) *and* sit flush at
|
|
220
|
+
* column 0. That rejects stray box right-borders (no caps) and selected
|
|
221
|
+
* issue/PR list rows (drawn in the same box but inset one column). The last
|
|
222
|
+
* matching row wins so a prompt wrapped onto multiple "┃" rows resolves to its
|
|
223
|
+
* final line. */
|
|
224
|
+
function findInputBox(grid) {
|
|
225
|
+
let found = null;
|
|
226
|
+
for (let r = 0; r < grid.lines.length; r++) {
|
|
227
|
+
const line = grid.lines[r];
|
|
228
|
+
let first = -1;
|
|
229
|
+
for (let c = 0; c < line.length; c++) {
|
|
230
|
+
if (line[c].char.trim() !== '') {
|
|
231
|
+
first = c;
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (first < 0 || line[first].char !== INPUT_MARKER)
|
|
236
|
+
continue;
|
|
237
|
+
// A real prompt input sits flush at the left edge; selected issue/PR list
|
|
238
|
+
// rows use the same heavy box but are inset one column. Skip anything that
|
|
239
|
+
// isn't flush at column 0.
|
|
240
|
+
if (line[first].col !== 0)
|
|
241
|
+
continue;
|
|
242
|
+
// Walk the contiguous run of flush "┃"-led rows this row belongs to, then
|
|
243
|
+
// require the rows just outside it to be the prompt box's heavy caps, also
|
|
244
|
+
// flush at column 0. Without both caps this "┃" is a menu/border edge.
|
|
245
|
+
const isFlushMarkerRow = (l) => {
|
|
246
|
+
const fc = firstCell(l);
|
|
247
|
+
return fc !== null && fc.char === INPUT_MARKER && fc.col === 0;
|
|
248
|
+
};
|
|
249
|
+
let top = r;
|
|
250
|
+
while (top - 1 >= 0 && isFlushMarkerRow(grid.lines[top - 1]))
|
|
251
|
+
top--;
|
|
252
|
+
let bottom = r;
|
|
253
|
+
while (bottom + 1 < grid.lines.length && isFlushMarkerRow(grid.lines[bottom + 1])) {
|
|
254
|
+
bottom++;
|
|
255
|
+
}
|
|
256
|
+
const aboveCap = top - 1 >= 0 ? firstCell(grid.lines[top - 1]) : null;
|
|
257
|
+
const belowCap = bottom + 1 < grid.lines.length ? firstCell(grid.lines[bottom + 1]) : null;
|
|
258
|
+
const cappedAbove = aboveCap?.char === INPUT_CAP_TOP && aboveCap.col === 0;
|
|
259
|
+
const cappedBelow = belowCap?.char === INPUT_CAP_BOTTOM && belowCap.col === 0;
|
|
260
|
+
if (!cappedAbove || !cappedBelow)
|
|
261
|
+
continue;
|
|
262
|
+
let last = -1;
|
|
263
|
+
for (let c = line.length - 1; c >= 0; c--) {
|
|
264
|
+
if (line[c].char.trim() !== '') {
|
|
265
|
+
last = c;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const markerCol = line[first].col;
|
|
270
|
+
const hasText = last > first;
|
|
271
|
+
// Where the caret rests. While composing a slash command the CLI appends an
|
|
272
|
+
// autocomplete *ghost* suggestion after the typed text, rendered in the
|
|
273
|
+
// theme's muted foreground (the typed text keeps the prompt's normal fg). In
|
|
274
|
+
// the real UI the caret stays at the end of what the user actually typed,
|
|
275
|
+
// overlapping the first ghost glyph — so peel back any trailing run whose
|
|
276
|
+
// foreground differs from the typed text and rest the caret at its start.
|
|
277
|
+
let caretCol;
|
|
278
|
+
if (!hasText) {
|
|
279
|
+
caretCol = markerCol + 2;
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
const content = [];
|
|
283
|
+
for (let c = first + 1; c <= last; c++) {
|
|
284
|
+
if (line[c].char.trim() !== '')
|
|
285
|
+
content.push(line[c]);
|
|
286
|
+
}
|
|
287
|
+
const typedFg = content[0].fg;
|
|
288
|
+
let k = content.length - 1;
|
|
289
|
+
while (k >= 1 && content[k].fg !== typedFg)
|
|
290
|
+
k--;
|
|
291
|
+
caretCol =
|
|
292
|
+
k === content.length - 1
|
|
293
|
+
? content[k].col + content[k].width // no ghost — rest just past the text
|
|
294
|
+
: content[k + 1].col; // ghost present — rest over its first glyph
|
|
295
|
+
}
|
|
296
|
+
found = { row: r, caretCol, hasText };
|
|
297
|
+
}
|
|
298
|
+
return found;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Tag which frames are user typing, how many characters landed on each, and
|
|
302
|
+
* where the prompt caret sits.
|
|
303
|
+
*
|
|
304
|
+
* With keystroke data (`input`, on the same clock as the frame start times) each
|
|
305
|
+
* burst is attributed to the first frame that appeared at or after it — the
|
|
306
|
+
* paint that echoed those characters — so that frame's `typedChars` grows and
|
|
307
|
+
* `isTyping` is set. Without keystroke data, and when `detectFromContent` is on,
|
|
308
|
+
* typing is inferred from the input box gaining characters frame-to-frame.
|
|
309
|
+
*
|
|
310
|
+
* The typing `caret` is independent of the typing flags: it marks where the
|
|
311
|
+
* prompt cursor sits, and is placed on **every** frame that has an input box —
|
|
312
|
+
* tracking the end of any typed text, or resting at the start of an empty
|
|
313
|
+
* prompt — mirroring the real CLI, where the caret is present whenever a prompt
|
|
314
|
+
* field is open and only vanishes in menus that have none. Whether the cursor is
|
|
315
|
+
* actually drawn is a render-time choice (the "cursor" toggle).
|
|
316
|
+
*/
|
|
317
|
+
function tagTypingFrames(frames, input, detectFromContent) {
|
|
318
|
+
for (const f of frames) {
|
|
319
|
+
f.isTyping = false;
|
|
320
|
+
f.typedChars = 0;
|
|
321
|
+
}
|
|
322
|
+
const n = frames.length;
|
|
323
|
+
const haveTimes = n > 0 && frames[0].startMs !== undefined;
|
|
324
|
+
if (input && input.length && haveTimes) {
|
|
325
|
+
let j = 0;
|
|
326
|
+
for (const [t, count] of input) {
|
|
327
|
+
while (j < n && frames[j].startMs < t)
|
|
328
|
+
j++;
|
|
329
|
+
const k = j < n ? j : n - 1;
|
|
330
|
+
frames[k].typedChars = (frames[k].typedChars ?? 0) + count;
|
|
331
|
+
frames[k].isTyping = true;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
else if (detectFromContent) {
|
|
335
|
+
let prevCaretCol = -1;
|
|
336
|
+
let prevHadText = false;
|
|
337
|
+
for (const f of frames) {
|
|
338
|
+
const box = findInputBox(f.grid);
|
|
339
|
+
if (box && box.hasText) {
|
|
340
|
+
if (prevHadText && box.caretCol > prevCaretCol) {
|
|
341
|
+
f.typedChars = box.caretCol - prevCaretCol;
|
|
342
|
+
f.isTyping = true;
|
|
343
|
+
}
|
|
344
|
+
prevCaretCol = box.caretCol;
|
|
345
|
+
prevHadText = true;
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
prevHadText = false;
|
|
349
|
+
prevCaretCol = -1;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// Place the prompt cursor wherever an input box is open — not only while
|
|
354
|
+
// typing. The block then tracks the end of typed text and rests in the empty
|
|
355
|
+
// prompt after a submit, and is absent on frames with no prompt field.
|
|
356
|
+
for (const f of frames) {
|
|
357
|
+
const box = findInputBox(f.grid);
|
|
358
|
+
if (box)
|
|
359
|
+
f.caret = { row: box.row, col: box.caretCol };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Replay a raw ANSI/VT byte stream and recover every meaningful on-screen frame.
|
|
364
|
+
*
|
|
365
|
+
* The Copilot CLI (like most full-screen TUIs) paints into the alternate screen
|
|
366
|
+
* buffer and discards it on exit, so reading the terminal once at the end only
|
|
367
|
+
* yields the post-exit shell screen. Instead we feed the stream in segments and
|
|
368
|
+
* snapshot the screen just *before* each sequence that wipes or replaces it,
|
|
369
|
+
* preserving the live TUI frames. Blank and consecutive-identical frames are
|
|
370
|
+
* dropped. The result always has at least one frame.
|
|
371
|
+
*/
|
|
372
|
+
async function extractFrames(data, opts) {
|
|
373
|
+
const text = typeof data === 'string' ? data : data.toString('utf8');
|
|
374
|
+
const theme = opts.theme ?? 'dark';
|
|
375
|
+
const term = new headless_1.Terminal({
|
|
376
|
+
cols: opts.cols,
|
|
377
|
+
rows: opts.rows,
|
|
378
|
+
allowProposedApi: true,
|
|
379
|
+
scrollback: 0,
|
|
380
|
+
});
|
|
381
|
+
const write = (chunk) => new Promise((resolve) => {
|
|
382
|
+
if (chunk.length === 0)
|
|
383
|
+
return resolve();
|
|
384
|
+
term.write(chunk, () => resolve());
|
|
385
|
+
});
|
|
386
|
+
const boundaries = findBoundaries(text);
|
|
387
|
+
const raw = [];
|
|
388
|
+
let inAlt = false;
|
|
389
|
+
let cursor = 0;
|
|
390
|
+
let byteOffset = 0;
|
|
391
|
+
for (const b of boundaries) {
|
|
392
|
+
// Write everything up to (but not including) the boundary, then snapshot the
|
|
393
|
+
// screen as it stands right before it gets wiped or swapped away.
|
|
394
|
+
const seg = text.slice(cursor, b.start);
|
|
395
|
+
await write(seg);
|
|
396
|
+
byteOffset += Buffer.byteLength(seg, 'utf8');
|
|
397
|
+
raw.push({ grid: readGrid(term, opts.cols, opts.rows, theme), inAlt, endByte: byteOffset });
|
|
398
|
+
cursor = b.start;
|
|
399
|
+
if (b.kind === 'enter')
|
|
400
|
+
inAlt = true;
|
|
401
|
+
else if (b.kind === 'leave')
|
|
402
|
+
inAlt = false;
|
|
403
|
+
}
|
|
404
|
+
const tail = text.slice(cursor);
|
|
405
|
+
await write(tail);
|
|
406
|
+
byteOffset += Buffer.byteLength(tail, 'utf8');
|
|
407
|
+
raw.push({ grid: readGrid(term, opts.cols, opts.rows, theme), inAlt, endByte: byteOffset });
|
|
408
|
+
term.dispose();
|
|
409
|
+
// Drop blanks and collapse runs of identical screens (in-place repaints).
|
|
410
|
+
const kept = [];
|
|
411
|
+
let lastSig = '';
|
|
412
|
+
for (const f of raw) {
|
|
413
|
+
if (isBlank(f.grid))
|
|
414
|
+
continue;
|
|
415
|
+
const sig = signature(f.grid);
|
|
416
|
+
if (sig === lastSig) {
|
|
417
|
+
// Same screen as the kept one; keep the richer alt tag if this is alt.
|
|
418
|
+
if (f.inAlt && kept.length)
|
|
419
|
+
kept[kept.length - 1].inAlt = true;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
lastSig = sig;
|
|
423
|
+
kept.push(f);
|
|
424
|
+
}
|
|
425
|
+
if (kept.length === 0) {
|
|
426
|
+
// Everything was blank — return the final screen so callers never get [].
|
|
427
|
+
kept.push(raw[raw.length - 1]);
|
|
428
|
+
}
|
|
429
|
+
// Attach timing when capture checkpoints were provided. A kept frame's
|
|
430
|
+
// appearance time is the moment its content finished streaming in (its end
|
|
431
|
+
// byte offset). Because we keep the *first* of a collapsed run and drop
|
|
432
|
+
// blanks, durations computed from consecutive kept starts automatically fold
|
|
433
|
+
// held/blank gaps into the preceding visible frame.
|
|
434
|
+
if (opts.timing && opts.timing.length) {
|
|
435
|
+
const starts = kept.map((f) => (0, timing_1.timeAtOffset)(opts.timing, f.endByte));
|
|
436
|
+
const endMs = (0, timing_1.timeAtOffset)(opts.timing, byteOffset);
|
|
437
|
+
const durations = (0, animation_1.frameDurations)(starts, endMs);
|
|
438
|
+
for (let i = 0; i < kept.length; i++) {
|
|
439
|
+
kept[i].startMs = starts[i];
|
|
440
|
+
kept[i].durationMs = durations[i];
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// Strip the internal byte-offset bookkeeping from the public result.
|
|
444
|
+
const out = kept.map((f) => {
|
|
445
|
+
const frame = { grid: f.grid, inAlt: f.inAlt };
|
|
446
|
+
if (f.startMs !== undefined)
|
|
447
|
+
frame.startMs = f.startMs;
|
|
448
|
+
if (f.durationMs !== undefined)
|
|
449
|
+
frame.durationMs = f.durationMs;
|
|
450
|
+
return frame;
|
|
451
|
+
});
|
|
452
|
+
// Tag user-typing frames and place the prompt caret. Keystroke data (when
|
|
453
|
+
// present) is correlated to the paints it produced; otherwise typing is
|
|
454
|
+
// inferred from input-box growth unless that fallback is disabled.
|
|
455
|
+
tagTypingFrames(out, opts.input, opts.detectTypingFromContent ?? true);
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
function isBlankCell(c) {
|
|
459
|
+
return c.char.trim() === '' && c.bg === null && !c.inverse;
|
|
460
|
+
}
|
|
461
|
+
/** Count cells that carry visible content (a glyph, a background, or inverse). */
|
|
462
|
+
function richness(grid) {
|
|
463
|
+
let n = 0;
|
|
464
|
+
for (const line of grid.lines)
|
|
465
|
+
for (const c of line)
|
|
466
|
+
if (!isBlankCell(c))
|
|
467
|
+
n++;
|
|
468
|
+
return n;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Pick a frame from {@link extractFrames}. With no `which`, returns the last
|
|
472
|
+
* *settled* full-screen (alternate-buffer) frame: starting at the final alt
|
|
473
|
+
* frame it walks back to the last local content peak, stepping over the exit
|
|
474
|
+
* "teardown" tail (the quit prompt and half-erased screen) so the default is
|
|
475
|
+
* the screen the user held on, not the frame mid-exit. Falls back to the last
|
|
476
|
+
* frame for plain, non-alt streams. `which` is 1-based; negative counts from
|
|
477
|
+
* the end (-1 = last).
|
|
478
|
+
*/
|
|
479
|
+
function chooseFrame(frames, which) {
|
|
480
|
+
if (frames.length === 0) {
|
|
481
|
+
return { grid: { cols: 0, rows: 0, lines: [] }, index: 0 };
|
|
482
|
+
}
|
|
483
|
+
let index;
|
|
484
|
+
if (which === undefined) {
|
|
485
|
+
const altIdx = [];
|
|
486
|
+
for (let i = 0; i < frames.length; i++)
|
|
487
|
+
if (frames[i].inAlt)
|
|
488
|
+
altIdx.push(i);
|
|
489
|
+
if (altIdx.length) {
|
|
490
|
+
// From the final alt frame, walk back to the last local content peak.
|
|
491
|
+
let p = altIdx.length - 1;
|
|
492
|
+
while (p > 0 &&
|
|
493
|
+
richness(frames[altIdx[p - 1]].grid) > richness(frames[altIdx[p]].grid)) {
|
|
494
|
+
p--;
|
|
495
|
+
}
|
|
496
|
+
// Only fall back to that peak if the capture ended collapsed — i.e. the
|
|
497
|
+
// TUI was torn down on exit (a `ctrl+c again to exit` prompt, then a
|
|
498
|
+
// half-erased screen). For a clean final paint, keep the last frame.
|
|
499
|
+
const last = altIdx.length - 1;
|
|
500
|
+
const collapsed = richness(frames[altIdx[last]].grid) <
|
|
501
|
+
richness(frames[altIdx[p]].grid) * 0.5;
|
|
502
|
+
index = altIdx[collapsed ? p : last];
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
index = frames.length - 1;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
else if (which < 0) {
|
|
509
|
+
index = frames.length + which;
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
index = which - 1;
|
|
513
|
+
}
|
|
514
|
+
index = Math.max(0, Math.min(frames.length - 1, index));
|
|
515
|
+
return { grid: frames[index].grid, index };
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Strip erase-to-end-of-line background "bleed" from a frame.
|
|
519
|
+
*
|
|
520
|
+
* Terminals resolve `ESC[K` (and trailing SGR background fills) against the
|
|
521
|
+
* *physical* terminal width, which is almost always wider than a TUI's actual
|
|
522
|
+
* content. The Copilot CLI input box is the canonical example: its border rows
|
|
523
|
+
* stop at the content width, but the focused interior row paints its background
|
|
524
|
+
* to the real right edge. Replayed in a wide terminal (the GUI defaults to 120
|
|
525
|
+
* cols) that bar overshoots the rest of the UI, so the prompt input looks too
|
|
526
|
+
* long.
|
|
527
|
+
*
|
|
528
|
+
* This clamps the grid to its inked content width — the rightmost *visible*
|
|
529
|
+
* glyph anywhere in the grid — dropping trailing cells that carry only a
|
|
530
|
+
* background. Background bars that sit within the inked width (selection
|
|
531
|
+
* highlights, status bars, the input-box interior up to its border) are
|
|
532
|
+
* untouched; only the erase-bleed past the content is removed. Returns a new
|
|
533
|
+
* grid and never mutates the input; it is a no-op when content already fills
|
|
534
|
+
* the width (so it can't shrink a legitimately full-width screen).
|
|
535
|
+
*/
|
|
536
|
+
function trimBackgroundBleed(grid) {
|
|
537
|
+
let inkedCols = 0;
|
|
538
|
+
for (const row of grid.lines) {
|
|
539
|
+
for (const cell of row) {
|
|
540
|
+
if (cell.char.trim() !== '') {
|
|
541
|
+
inkedCols = Math.max(inkedCols, cell.col + Math.max(1, cell.width));
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (inkedCols <= 0 || inkedCols >= grid.cols)
|
|
546
|
+
return grid;
|
|
547
|
+
const lines = grid.lines.map((row) => row.filter((cell) => cell.col < inkedCols));
|
|
548
|
+
return { cols: inkedCols, rows: grid.rows, lines };
|
|
549
|
+
}
|
|
550
|
+
/** A short one-line text preview of a frame, for listing. */
|
|
551
|
+
function framePreview(grid) {
|
|
552
|
+
for (const line of grid.lines) {
|
|
553
|
+
const text = line
|
|
554
|
+
.map((c) => c.char)
|
|
555
|
+
.join('')
|
|
556
|
+
.replace(/\s+/g, ' ')
|
|
557
|
+
.trim();
|
|
558
|
+
if (text)
|
|
559
|
+
return text.length > 56 ? `${text.slice(0, 55)}…` : text;
|
|
560
|
+
}
|
|
561
|
+
return '(blank)';
|
|
562
|
+
}
|
|
563
|
+
function readLine(line, cols, theme) {
|
|
564
|
+
const cells = [];
|
|
565
|
+
if (!line)
|
|
566
|
+
return cells;
|
|
567
|
+
for (let x = 0; x < cols; x++) {
|
|
568
|
+
const cell = line.getCell(x);
|
|
569
|
+
if (!cell)
|
|
570
|
+
continue;
|
|
571
|
+
const width = cell.getWidth();
|
|
572
|
+
// Width 0 is the trailing half of a wide glyph; skip it (the leading
|
|
573
|
+
// cell already carries the character and a width of 2).
|
|
574
|
+
if (width === 0)
|
|
575
|
+
continue;
|
|
576
|
+
let char = cell.getChars();
|
|
577
|
+
if (char === '')
|
|
578
|
+
char = ' ';
|
|
579
|
+
const underline = cell.isUnderline() !== 0;
|
|
580
|
+
const ext = underline ? readUnderline(cell, theme) : undefined;
|
|
581
|
+
cells.push({
|
|
582
|
+
char,
|
|
583
|
+
col: x,
|
|
584
|
+
width,
|
|
585
|
+
fg: resolveColor(cell, 'fg', theme),
|
|
586
|
+
bg: resolveColor(cell, 'bg', theme),
|
|
587
|
+
bold: cell.isBold() !== 0,
|
|
588
|
+
italic: cell.isItalic() !== 0,
|
|
589
|
+
underline,
|
|
590
|
+
underlineStyle: ext?.style,
|
|
591
|
+
underlineColor: ext?.color ?? null,
|
|
592
|
+
dim: cell.isDim() !== 0,
|
|
593
|
+
strike: cell.isStrikethrough() !== 0,
|
|
594
|
+
inverse: cell.isInverse() !== 0,
|
|
595
|
+
invisible: cell.isInvisible() !== 0,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
return cells;
|
|
599
|
+
}
|
|
600
|
+
// Extended-underline maps style index → name. xterm reports a plain `CSI 4 m`
|
|
601
|
+
// as style 1, then 2..5 for double/curly/dotted/dashed (from `CSI 4 : n m`).
|
|
602
|
+
const UNDERLINE_STYLES = [
|
|
603
|
+
'single', // 0 — shouldn't occur while underlined; guarded to single
|
|
604
|
+
'single', // 1
|
|
605
|
+
'double', // 2
|
|
606
|
+
'curly', // 3
|
|
607
|
+
'dotted', // 4
|
|
608
|
+
'dashed', // 5
|
|
609
|
+
];
|
|
610
|
+
// xterm color packing: the mode lives in bits 24–25, the value in the low bits.
|
|
611
|
+
const CM_MASK = 0x03000000;
|
|
612
|
+
const CM_P16 = 0x01000000;
|
|
613
|
+
const CM_P256 = 0x02000000;
|
|
614
|
+
const CM_RGB = 0x03000000;
|
|
615
|
+
function decodePackedColor(packed, theme) {
|
|
616
|
+
const mode = packed & CM_MASK;
|
|
617
|
+
if (mode === CM_RGB)
|
|
618
|
+
return (0, palette_1.packedRgbToHex)(packed & 0xffffff);
|
|
619
|
+
if (mode === CM_P256 || mode === CM_P16)
|
|
620
|
+
return (0, palette_1.paletteToHex)(packed & 0xff, theme);
|
|
621
|
+
return null; // default → render as the text color
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Read the extended underline style and color for a cell.
|
|
625
|
+
*
|
|
626
|
+
* The public @xterm/headless v5.5 cell API only exposes `isUnderline(): number`,
|
|
627
|
+
* which is 1 for *every* underline variant — it cannot distinguish curly/double/
|
|
628
|
+
* dotted/dashed or a distinct underline color. That information is held in the
|
|
629
|
+
* cell's internal extended attributes (`IExtendedAttrs.underlineStyle` /
|
|
630
|
+
* `underlineColor`), so we read it through a guarded structural cast and fall
|
|
631
|
+
* back to a plain single underline if a future xterm changes the shape.
|
|
632
|
+
*/
|
|
633
|
+
function readUnderline(cell, theme) {
|
|
634
|
+
const ext = cell.extended;
|
|
635
|
+
const styleNum = typeof ext?.underlineStyle === 'number' ? ext.underlineStyle : 1;
|
|
636
|
+
const style = UNDERLINE_STYLES[styleNum] ?? 'single';
|
|
637
|
+
const color = typeof ext?.underlineColor === 'number' && ext.underlineColor !== 0
|
|
638
|
+
? decodePackedColor(ext.underlineColor, theme)
|
|
639
|
+
: null;
|
|
640
|
+
return { style, color };
|
|
641
|
+
}
|
|
642
|
+
function resolveColor(cell, which, theme) {
|
|
643
|
+
if (which === 'fg') {
|
|
644
|
+
if (cell.isFgDefault())
|
|
645
|
+
return null;
|
|
646
|
+
if (cell.isFgRGB())
|
|
647
|
+
return (0, palette_1.packedRgbToHex)(cell.getFgColor());
|
|
648
|
+
if (cell.isFgPalette())
|
|
649
|
+
return (0, palette_1.paletteToHex)(cell.getFgColor(), theme);
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
if (cell.isBgDefault())
|
|
653
|
+
return null;
|
|
654
|
+
if (cell.isBgRGB())
|
|
655
|
+
return (0, palette_1.packedRgbToHex)(cell.getBgColor());
|
|
656
|
+
if (cell.isBgPalette())
|
|
657
|
+
return (0, palette_1.paletteToHex)(cell.getBgColor(), theme);
|
|
658
|
+
return null;
|
|
659
|
+
}
|