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/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
+ }