tmux-ide 2.6.1 → 2.7.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.
Files changed (83) hide show
  1. package/README.md +14 -9
  2. package/bin/cli.js +1013 -516
  3. package/bin/cli.ts +62 -5
  4. package/package.json +9 -6
  5. package/packages/daemon/dist/agent-explain.d.ts +8 -1
  6. package/packages/daemon/dist/agent-explain.js +19 -3
  7. package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
  8. package/packages/daemon/dist/lib/tui-binary.js +130 -0
  9. package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
  10. package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
  11. package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
  12. package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
  13. package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
  14. package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
  15. package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
  16. package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
  17. package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
  18. package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
  19. package/packages/daemon/src/agent-explain.ts +34 -6
  20. package/packages/daemon/src/lib/app-config.ts +93 -2
  21. package/packages/daemon/src/lib/tui-binary.ts +165 -0
  22. package/packages/daemon/src/tui/chrome/notify.ts +221 -34
  23. package/packages/daemon/src/tui/chrome/updater.ts +89 -7
  24. package/packages/daemon/src/tui/compiled.ts +11 -3
  25. package/packages/daemon/src/tui/detect/classify.ts +15 -0
  26. package/packages/daemon/src/tui/detect/manifest-loader.ts +2 -1
  27. package/packages/daemon/src/tui/detect/manifest.ts +15 -0
  28. package/packages/daemon/src/tui/detect/manifests.ts +161 -33
  29. package/packages/daemon/src/tui/detect/process-tree.ts +20 -0
  30. package/packages/daemon/src/tui/main.ts +13 -1
  31. package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
  32. package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
  33. package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
  34. package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
  35. package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
  36. package/packages/daemon/src/tui/mirror/blit.ts +186 -0
  37. package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
  38. package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
  39. package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
  40. package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
  41. package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
  42. package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
  43. package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
  44. package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
  45. package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
  46. package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
  47. package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
  48. package/packages/daemon/src/tui/mirror/palette.ts +274 -0
  49. package/packages/daemon/src/tui/mirror/pane-mirror.ts +499 -19
  50. package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
  51. package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
  52. package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
  53. package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
  54. package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
  55. package/packages/daemon/src/tui/mirror/selection.ts +262 -0
  56. package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
  57. package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
  58. package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
  59. package/packages/daemon/src/tui/mirror/spans.ts +46 -0
  60. package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
  61. package/packages/daemon/src/tui/team/entry.ts +34 -7
  62. package/packages/daemon/src/tui/team/report.ts +11 -1
  63. package/packages/daemon/src/tui/team/sessions.ts +102 -13
  64. package/scripts/build-tui.mjs +11 -4
  65. package/scripts/perf-mirror.mjs +313 -0
  66. package/skill/SKILL.md +22 -0
  67. package/templates/AGENTS.md +14 -7
  68. package/templates/agent-team-monorepo.yml +8 -0
  69. package/templates/agent-team-nextjs.yml +8 -0
  70. package/templates/agent-team.yml +10 -0
  71. package/templates/convex.yml +2 -0
  72. package/templates/default.yml +11 -5
  73. package/templates/go.yml +4 -0
  74. package/templates/missions.yml +6 -0
  75. package/templates/nextjs.yml +4 -0
  76. package/templates/python.yml +4 -0
  77. package/templates/skills/backend.md +5 -12
  78. package/templates/skills/frontend.md +5 -12
  79. package/templates/skills/general-worker.md +5 -12
  80. package/templates/skills/researcher.md +7 -12
  81. package/templates/skills/reviewer.md +7 -16
  82. package/templates/vite.yml +4 -0
  83. package/packages/daemon/src/tui/mirror/viewer.tsx +0 -166
@@ -6,73 +6,553 @@
6
6
  * alt-screen, scroll regions) maintaining a cell buffer. We write raw pane
7
7
  * bytes in and read the grid out; the TUI draws the snapshot. This is the
8
8
  * seam between "tmux owns the PTYs" and "tmux-ide owns the pixels".
9
+ *
10
+ * Fidelity notes (M21.6 — the current truth):
11
+ * - Colors resolve to packed 0xRRGGBB here (256-palette + truecolor + the
12
+ * 16 base colors), so the renderer never needs a palette.
13
+ * - Attributes carried, mapped onto OpenTUI's TextAttributes bitmask:
14
+ * bold, dim, italic, underline, blink, strikethrough. INVERSE is NOT set as
15
+ * an attribute — it renders as a fg/bg SWAP in the blit (a framebuffer cell
16
+ * carrying the INVERSE bit does not flush as reverse; see blit.ts), so it
17
+ * composes with the selection/cursor swaps.
18
+ * - Attributes DROPPED (no representation downstream): overline (xterm exposes
19
+ * `isOverline()`, OpenTUI has no overline attribute); extended underline
20
+ * STYLES (curly/double/dotted) and underline COLOR (not on xterm-headless's
21
+ * public cell API, and OpenTUI has only a single underline). Blink is passed
22
+ * through as the BLINK attribute — the real terminal decides whether to honor
23
+ * it (most modern terminals ignore or soften it); we don't down-map it.
24
+ * - CURSOR: not painted into the grid. The focused pane drives the REAL
25
+ * hardware cursor via the renderer (position + DECTCEM hide/show + DECSCUSR
26
+ * shape/blink, read through {@link cursorState}); unfocused panes get a quiet
27
+ * painted marker. See pane-surface.tsx.
28
+ * - Wide glyphs (CJK, emoji) occupy one cell + a zero-width spacer; the
29
+ * spacer is skipped so runs stay grid-aligned.
30
+ * - `scrollback` is real (5000 lines): `snapshot(offset)` renders `offset`
31
+ * lines above the live viewport, `scrollbackDepth()` says how far back
32
+ * a pane can go.
9
33
  */
10
34
  import { Terminal } from "@xterm/headless";
35
+ import {
36
+ writeCell,
37
+ writeContinuation,
38
+ SPACE_CODE,
39
+ type CellArrays,
40
+ type GraphemeOverride,
41
+ } from "./blit.ts";
42
+ import { AckWriter } from "./ack-writer.ts";
43
+
44
+ /** OpenTUI TextAttributes bit values (kept literal to avoid the dep here). */
45
+ const ATTR_BOLD = 1;
46
+ const ATTR_DIM = 2;
47
+ const ATTR_ITALIC = 4;
48
+ const ATTR_UNDERLINE = 8;
49
+ const ATTR_BLINK = 16;
50
+ const ATTR_INVERSE = 32;
51
+ const ATTR_STRIKETHROUGH = 128;
11
52
 
12
- /** A run of same-colored text within a row. */
53
+ /** A run of same-styled text within a row. Colors are packed 0xRRGGBB. */
13
54
  export interface StyledRun {
14
55
  text: string;
15
- /** 256-palette index, or null for the default foreground. */
56
+ /** Foreground as packed RGB, or null for the terminal default. */
16
57
  fg: number | null;
17
- bold: boolean;
58
+ /** Background as packed RGB, or null for the terminal default. */
59
+ bg: number | null;
60
+ /** OpenTUI TextAttributes bitmask. */
61
+ attributes: number;
18
62
  }
19
63
 
20
64
  export interface MirrorSnapshot {
21
65
  rows: StyledRun[][];
22
66
  cursorX: number;
23
67
  cursorY: number;
68
+ /** How many lines above the live viewport this snapshot starts (0 = live). */
69
+ scrollOffset: number;
70
+ }
71
+
72
+ /** The standard xterm 256-color palette as packed 0xRRGGBB. */
73
+ export const XTERM_PALETTE: readonly number[] = buildXtermPalette();
74
+
75
+ function buildXtermPalette(): number[] {
76
+ const base = [
77
+ 0x000000, 0xcd0000, 0x00cd00, 0xcdcd00, 0x0000ee, 0xcd00cd, 0x00cdcd, 0xe5e5e5, 0x7f7f7f,
78
+ 0xff0000, 0x00ff00, 0xffff00, 0x5c5cff, 0xff00ff, 0x00ffff, 0xffffff,
79
+ ];
80
+ const palette = [...base];
81
+ const levels = [0, 95, 135, 175, 215, 255];
82
+ for (let i = 16; i < 232; i++) {
83
+ const n = i - 16;
84
+ const r = levels[Math.floor(n / 36)]!;
85
+ const g = levels[Math.floor(n / 6) % 6]!;
86
+ const b = levels[n % 6]!;
87
+ palette.push((r << 16) | (g << 8) | b);
88
+ }
89
+ for (let i = 232; i < 256; i++) {
90
+ const v = 8 + 10 * (i - 232);
91
+ palette.push((v << 16) | (v << 8) | v);
92
+ }
93
+ return palette;
94
+ }
95
+
96
+ /** The live cursor state a surface needs to drive the hardware cursor (M21.6). */
97
+ export interface CursorState {
98
+ /** Grid column/row of the cursor within the visible viewport. */
99
+ x: number;
100
+ y: number;
101
+ /** DECTCEM — the app hid the cursor (`CSI ?25 l`). */
102
+ hidden: boolean;
103
+ /** DECSCUSR shape, xterm's vocabulary. */
104
+ style: "block" | "underline" | "bar";
105
+ /** DECSCUSR blink flag. */
106
+ blink: boolean;
107
+ }
108
+
109
+ /** The slice of xterm's internal coreService we read for cursor mode state. */
110
+ interface CoreServiceInternal {
111
+ isCursorHidden?: boolean;
112
+ decPrivateModes?: { cursorStyle?: "block" | "underline" | "bar"; cursorBlink?: boolean };
113
+ }
114
+
115
+ /** Per-call inputs for the incremental {@link PaneMirror.blit} (M21.4). */
116
+ export interface BlitOptions {
117
+ /** Repaint every visible row and refill the shadow (first frame, resize, a
118
+ * scrolled/searching view, or any time the framebuffer may be out of sync). */
119
+ full: boolean;
120
+ /** Extra rows to repaint regardless of the content compare — the caller's
121
+ * selection/search churn (the union of the old and new highlighted rows). */
122
+ forceRows?: readonly number[] | null;
123
+ /** OUT — the rows actually written this call. The caller clears it first and
124
+ * re-applies its selection/search post-passes over exactly these rows. */
125
+ dirtyRows: number[];
126
+ /** OUT — multi-codepoint grapheme cells to re-write via `setCell`. */
127
+ graphemes?: GraphemeOverride[];
128
+ }
129
+
130
+ /** True iff the shadow slice at `off` equals `data` (an exact per-row cell-data
131
+ * compare — no hash, so no collision can strand a stale row). Early-exits on the
132
+ * first differing u32. */
133
+ function shadowMatches(shadow: Uint32Array, off: number, data: Uint32Array): boolean {
134
+ if (data.length + off > shadow.length) return false;
135
+ for (let k = 0; k < data.length; k++) {
136
+ if (shadow[off + k] !== data[k]) return false;
137
+ }
138
+ return true;
24
139
  }
25
140
 
26
141
  export class PaneMirror {
27
142
  private readonly term: Terminal;
28
- readonly cols: number;
29
- readonly rows: number;
143
+ /** Ack-paced writes (M21.5): xterm's `write` is async with a completion
144
+ * callback; chunks arriving mid-parse buffer here and follow as ONE joined
145
+ * write from the callback, so parser backpressure never queues unbounded
146
+ * entries — and never stalls the control-channel reader loop feeding us. */
147
+ /** Fires when a paced write has actually PARSED into the grid — the dirty
148
+ * signal the render tick must re-arm on (enqueue-time dirty can be consumed
149
+ * before the parse lands, dropping the final frame; see AckWriter.onAck). */
150
+ onParsed?: () => void;
151
+ private readonly writer = new AckWriter(
152
+ (data, done) => this.term.write(data, done),
153
+ () => this.onParsed?.(),
154
+ );
155
+ cols: number;
156
+ rows: number;
157
+
158
+ // ── Incremental-blit state (M21.4) ─────────────────────────────────────────
159
+ /** Per-pane content version — bumps on any grid change (parse/scroll/resize).
160
+ * A surface gates its walk on this: an unchanged pane never re-reads. */
161
+ private _version = 0;
162
+ /** Net forward scrolls (line count) since the last blit — drives the shift
163
+ * fast path. Counted from xterm's onScroll (which fires once per scrolled
164
+ * line and keeps firing at the scrollback cap, unlike the saturating payload). */
165
+ private _pendingScroll = 0;
166
+ /** The alt/normal buffer swapped (`?1049h/l`) — the whole grid is new. */
167
+ private _bufferSwapped = false;
168
+ /** Shadow of the last-blitted rows' raw xterm cell data (`_line._data`,
169
+ * cols×3 u32/row) — an EXACT compare finds changed rows with no getter cost
170
+ * and no hash-collision risk. Null until the capability probe or a resize. */
171
+ private _shadow: Uint32Array | null = null;
172
+ private _shadowValid = false;
173
+ /** `_line._data` reachable (xterm-headless internal, pinned 6.0). When false,
174
+ * the blit degrades to a full repaint every walk — correct, just not
175
+ * incremental. */
176
+ private _incremental = true;
30
177
 
31
178
  constructor(cols: number, rows: number) {
32
179
  this.cols = cols;
33
180
  this.rows = rows;
34
- this.term = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 0 });
181
+ this.term = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 5000 });
182
+ this.term.onWriteParsed(() => this._version++);
183
+ this.term.onScroll(() => this._pendingScroll++);
184
+ this.term.buffer.onBufferChange(() => {
185
+ this._bufferSwapped = true;
186
+ this._version++;
187
+ });
188
+ // Capability probe (once): can we reach xterm's raw per-line cell data for
189
+ // the exact-compare dirty check? If not, the blit repaints in full.
190
+ const probe = this.term.buffer.active.getLine(0) as { _line?: { _data?: unknown } } | undefined;
191
+ this._incremental = probe?._line?._data instanceof Uint32Array;
192
+ }
193
+
194
+ /** The per-pane content version (M21.4) — see {@link _version}. */
195
+ contentVersion(): number {
196
+ return this._version;
197
+ }
198
+
199
+ /**
200
+ * The live cursor: grid position + the app-driven DECTCEM visibility and
201
+ * DECSCUSR shape/blink (M21.6). Position is public (`cursorX/Y`); the mode
202
+ * state lives on xterm's internal coreService (no public getter), reached
203
+ * defensively — if the internal is absent the cursor stays visible as a block,
204
+ * which is the terminal default anyway. `style` is xterm's vocabulary
205
+ * (`block`/`underline`/`bar`); the renderer maps it.
206
+ */
207
+ cursorState(): CursorState {
208
+ const buf = this.term.buffer.active;
209
+ const core = (this.term as unknown as { _core?: { coreService?: CoreServiceInternal } })._core;
210
+ const cs = core?.coreService;
211
+ const dec = cs?.decPrivateModes;
212
+ return {
213
+ x: buf.cursorX,
214
+ y: buf.cursorY,
215
+ hidden: cs?.isCursorHidden === true,
216
+ style: dec?.cursorStyle ?? this.term.options.cursorStyle ?? "block",
217
+ blink: dec?.cursorBlink ?? this.term.options.cursorBlink ?? false,
218
+ };
35
219
  }
36
220
 
37
221
  /** Feed raw pane bytes (UTF-8) from a control-mode %output event. */
38
222
  write(data: Uint8Array | string): void {
39
- this.term.write(data);
223
+ // Normalize to bytes so the pacer coalesces freely; a JS string encodes to
224
+ // the same UTF-8 xterm would have decoded it from.
225
+ this.writer.write(typeof data === "string" ? new TextEncoder().encode(data) : data);
40
226
  }
41
227
 
42
228
  resize(cols: number, rows: number): void {
229
+ this.cols = cols;
230
+ this.rows = rows;
43
231
  this.term.resize(cols, rows);
232
+ // Geometry changed — the shadow and any prior framebuffer are stale; the
233
+ // next blit must repaint in full (the surface also forces it on its resize).
234
+ this._shadow = null;
235
+ this._pendingScroll = 0;
236
+ this._bufferSwapped = false;
237
+ this._version++;
238
+ }
239
+
240
+ /** Lines available above the live viewport (how far back scroll can go). */
241
+ scrollbackDepth(): number {
242
+ return this.term.buffer.active.viewportY;
44
243
  }
45
244
 
46
- /** Read the visible grid as rows of same-styled runs (plus the cursor). */
47
- snapshot(): MirrorSnapshot {
245
+ /**
246
+ * The WHOLE buffer (scrollback + live viewport) as plain text lines, top→bottom
247
+ * — the search corpus. Read on demand (cheap; no per-frame cost). Uses xterm's
248
+ * `translateToString(true)` so wide-glyph spacers collapse and trailing blanks
249
+ * trim exactly the way the rendered snapshot rows do, keeping a match's column
250
+ * aligned between the search hit and the highlight injection. Line index `y` is
251
+ * absolute (0 = oldest scrollback line); the live viewport top sits at
252
+ * `scrollbackDepth()`, so a match at line `y` maps to visible row
253
+ * `y - (scrollbackDepth - scrollOffset)`.
254
+ */
255
+ bufferLines(): string[] {
48
256
  const buf = this.term.buffer.active;
257
+ const out: string[] = [];
258
+ for (let y = 0; y < buf.length; y++) {
259
+ const line = buf.getLine(y);
260
+ out.push(line ? line.translateToString(true) : "");
261
+ }
262
+ return out;
263
+ }
264
+
265
+ /**
266
+ * Read a grid as rows of same-styled runs.
267
+ *
268
+ * @param scrollOffset Render this many lines above the live viewport
269
+ * (clamped to the available scrollback). 0 = live view.
270
+ * @param withCursor Paint the cursor cell inverse (the focused pane).
271
+ * @param includeRows Serialize the styled rows. `false` returns only the
272
+ * cursor/offset metadata (rows `[]`) — the framebuffer-blit path (M21.3)
273
+ * reads cells via {@link blit} instead, so it skips the run rebuild entirely.
274
+ */
275
+ snapshot(scrollOffset = 0, withCursor = false, includeRows = true): MirrorSnapshot {
276
+ const buf = this.term.buffer.active;
277
+ const offset = Math.max(0, Math.min(scrollOffset, buf.viewportY));
278
+ if (!includeRows) {
279
+ return { rows: [], cursorX: buf.cursorX, cursorY: buf.cursorY, scrollOffset: offset };
280
+ }
281
+ const baseY = buf.viewportY - offset;
282
+ const live = offset === 0;
49
283
  const rows: StyledRun[][] = [];
50
284
  const cell = buf.getNullCell();
51
285
 
52
286
  for (let y = 0; y < this.rows; y++) {
53
- const line = buf.getLine(buf.viewportY + y);
287
+ const line = buf.getLine(baseY + y);
54
288
  const runs: StyledRun[] = [];
55
289
  if (line) {
56
290
  let text = "";
57
291
  let fg: number | null = null;
58
- let bold = false;
292
+ let bg: number | null = null;
293
+ let attrs = 0;
294
+ const isCursorRow = withCursor && live && y === buf.cursorY;
59
295
  for (let x = 0; x < this.cols; x++) {
60
296
  line.getCell(x, cell);
61
- const cellFg = cell.isFgDefault() ? null : cell.isFgPalette() ? cell.getFgColor() : null;
62
- const cellBold = cell.isBold() !== 0;
63
- if (x > 0 && (cellFg !== fg || cellBold !== bold) && text.length > 0) {
64
- runs.push({ text, fg, bold });
297
+ if (cell.getWidth() === 0) continue; // spacer half of a wide glyph
298
+
299
+ let cellFg: number | null = null;
300
+ if (cell.isFgRGB()) cellFg = cell.getFgColor();
301
+ else if (cell.isFgPalette()) cellFg = XTERM_PALETTE[cell.getFgColor()] ?? null;
302
+
303
+ let cellBg: number | null = null;
304
+ if (cell.isBgRGB()) cellBg = cell.getBgColor();
305
+ else if (cell.isBgPalette()) cellBg = XTERM_PALETTE[cell.getBgColor()] ?? null;
306
+
307
+ let cellAttrs = 0;
308
+ if (cell.isBold()) cellAttrs |= ATTR_BOLD;
309
+ if (cell.isDim()) cellAttrs |= ATTR_DIM;
310
+ if (cell.isItalic()) cellAttrs |= ATTR_ITALIC;
311
+ if (cell.isUnderline()) cellAttrs |= ATTR_UNDERLINE;
312
+ if (cell.isBlink()) cellAttrs |= ATTR_BLINK;
313
+ if (cell.isInverse()) cellAttrs |= ATTR_INVERSE;
314
+ if (cell.isStrikethrough()) cellAttrs |= ATTR_STRIKETHROUGH;
315
+ // The cursor renders as an inverse cell in the focused, live pane.
316
+ if (isCursorRow && x === buf.cursorX) cellAttrs ^= ATTR_INVERSE;
317
+
318
+ if (text.length > 0 && (cellFg !== fg || cellBg !== bg || cellAttrs !== attrs)) {
319
+ runs.push({ text, fg, bg, attributes: attrs });
65
320
  text = "";
66
321
  }
67
322
  fg = cellFg;
68
- bold = cellBold;
69
- text += cell.getChars() || " ";
323
+ bg = cellBg;
324
+ attrs = cellAttrs;
325
+ const chars = cell.getChars() || " ";
326
+ text += chars;
327
+ // A wide glyph fills two columns with one string; pad the run's
328
+ // grid alignment by skipping the spacer in the next iteration
329
+ // (handled by the getWidth()===0 check above).
70
330
  }
71
- if (text.length > 0) runs.push({ text, fg, bold });
331
+ if (text.length > 0) runs.push({ text, fg, bg, attributes: attrs });
72
332
  }
73
333
  rows.push(runs);
74
334
  }
75
- return { rows, cursorX: buf.cursorX, cursorY: buf.cursorY };
335
+ return { rows, cursorX: buf.cursorX, cursorY: buf.cursorY, scrollOffset: offset };
336
+ }
337
+
338
+ /**
339
+ * Blit the visible grid into a framebuffer's packed typed arrays — the
340
+ * native-feel render path, now INCREMENTAL (M21.4). Same cell semantics as
341
+ * {@link snapshot} (colors → `0xRRGGBB`, the OpenTUI attribute bitmask incl.
342
+ * blink, wide-glyph spacers). The cursor is NOT painted here (M21.6): the
343
+ * focused pane drives the real hardware cursor and the surface paints any
344
+ * unfocused marker. Only the rows that actually changed are rewritten:
345
+ *
346
+ * - A **scroll fast path** (xterm `onScroll` counted forward-scroll lines)
347
+ * shifts the already-correct pixels up with `copyWithin`, so a flood repaints
348
+ * only the new bottom rows, not the whole grid.
349
+ * - A **per-row exact compare** of xterm's raw cell data against a shadow finds
350
+ * in-place changes (alt-screen redraws) with no getter cost; unchanged rows
351
+ * are skipped (~78% of the blit cost is the writes we then avoid).
352
+ * - `opts.full` repaints everything (first frame, resize, scrolled/searching
353
+ * view), `opts.forceRows` repaints the caller's selection/search churn, and
354
+ * the cursor's old+new rows always repaint (the overlay isn't in the shadow).
355
+ *
356
+ * `opts.dirtyRows` is filled with the rows written (the caller re-applies its
357
+ * post-passes there). `defaultFg`/`defaultBg` are packed `0xRRGGBB` for the
358
+ * terminal default. When the raw-cell-data internal is unreachable the blit
359
+ * degrades to a full repaint every call (still correct).
360
+ */
361
+ blit(
362
+ buffers: CellArrays,
363
+ width: number,
364
+ height: number,
365
+ scrollOffset: number,
366
+ defaultFg: number,
367
+ defaultBg: number,
368
+ opts: BlitOptions,
369
+ ): void {
370
+ const buf = this.term.buffer.active;
371
+ const offset = Math.max(0, Math.min(scrollOffset, buf.viewportY));
372
+ const baseY = buf.viewportY - offset;
373
+ const live = offset === 0;
374
+ const cell = buf.getNullCell();
375
+ const cols = Math.min(this.cols, width);
376
+ const rowLen = this.cols * 3; // xterm packs 3 u32 per cell (content/fg/bg)
377
+
378
+ // (Re)size the shadow to the current geometry; a fresh shadow is invalid, so
379
+ // the first blit after it repaints in full and fills it.
380
+ if (this._incremental) {
381
+ if (this._shadow === null || this._shadow.length !== rowLen * height) {
382
+ this._shadow = rowLen * height > 0 ? new Uint32Array(rowLen * height) : null;
383
+ this._shadowValid = false;
384
+ }
385
+ }
386
+
387
+ // A full repaint is forced on the first frame, a resize, a buffer swap, a
388
+ // scrolled/searching view (offset > 0 — the whole window is different), or
389
+ // when the incremental machinery is unavailable.
390
+ const full =
391
+ opts.full ||
392
+ !live ||
393
+ this._bufferSwapped ||
394
+ !this._incremental ||
395
+ !this._shadowValid ||
396
+ this._shadow === null;
397
+ this._bufferSwapped = false;
398
+
399
+ // Scroll fast path: shift the already-correct pixels + shadow up so only the
400
+ // newly exposed bottom rows fall out of sync.
401
+ let shift = 0;
402
+ if (!full && this._pendingScroll > 0) shift = Math.min(this._pendingScroll, height);
403
+ this._pendingScroll = 0;
404
+ if (shift > 0 && this._shadow) {
405
+ const w4 = width * 4;
406
+ buffers.char.copyWithin(0, shift * width, height * width);
407
+ buffers.fg.copyWithin(0, shift * w4, height * w4);
408
+ buffers.bg.copyWithin(0, shift * w4, height * w4);
409
+ buffers.attributes.copyWithin(0, shift * width, height * width);
410
+ this._shadow.copyWithin(0, shift * rowLen, height * rowLen);
411
+ }
412
+ const bottomDirtyFrom = full ? 0 : height - shift;
413
+
414
+ const dfR = (defaultFg >> 16) & 0xff;
415
+ const dfG = (defaultFg >> 8) & 0xff;
416
+ const dfB = defaultFg & 0xff;
417
+ const dbR = (defaultBg >> 16) & 0xff;
418
+ const dbG = (defaultBg >> 8) & 0xff;
419
+ const dbB = defaultBg & 0xff;
420
+ const forceRows = opts.forceRows && opts.forceRows.length ? opts.forceRows : null;
421
+
422
+ for (let y = 0; y < height; y++) {
423
+ const data = this._incremental ? this.rowData(baseY, y) : null;
424
+ let dirty = full || y >= bottomDirtyFrom || data === null; // no shadow info for this row → always repaint
425
+ if (!dirty && this._shadow) dirty = !shadowMatches(this._shadow, y * rowLen, data!);
426
+ if (!dirty && forceRows) {
427
+ for (let i = 0; i < forceRows.length; i++)
428
+ if (forceRows[i] === y) {
429
+ dirty = true;
430
+ break;
431
+ }
432
+ }
433
+ if (!dirty) continue;
434
+
435
+ this.blitRow(
436
+ cell,
437
+ buffers,
438
+ y,
439
+ baseY,
440
+ width,
441
+ cols,
442
+ dfR,
443
+ dfG,
444
+ dfB,
445
+ dbR,
446
+ dbG,
447
+ dbB,
448
+ defaultFg,
449
+ defaultBg,
450
+ opts.graphemes,
451
+ );
452
+ if (this._shadow && data) this._shadow.set(data, y * rowLen);
453
+ opts.dirtyRows.push(y);
454
+ }
455
+ if (this._incremental && this._shadow) this._shadowValid = true;
456
+ }
457
+
458
+ /** The raw xterm cell data for visible row `y` (`cols`×3 u32), or null when the
459
+ * row or the internal is unavailable. See the constructor's capability probe. */
460
+ private rowData(baseY: number, y: number): Uint32Array | null {
461
+ if (y >= this.rows) return null;
462
+ const line = this.term.buffer.active.getLine(baseY + y) as
463
+ | { _line?: { _data?: Uint32Array } }
464
+ | undefined;
465
+ const data = line?._line?._data;
466
+ return data instanceof Uint32Array ? data : null;
467
+ }
468
+
469
+ /** Write one visible row's cells into the framebuffer (the M21.3 per-cell blit,
470
+ * extracted so the incremental path repaints a single row). */
471
+ private blitRow(
472
+ cell: ReturnType<Terminal["buffer"]["active"]["getNullCell"]>,
473
+ buffers: CellArrays,
474
+ y: number,
475
+ baseY: number,
476
+ width: number,
477
+ cols: number,
478
+ dfR: number,
479
+ dfG: number,
480
+ dfB: number,
481
+ dbR: number,
482
+ dbG: number,
483
+ dbB: number,
484
+ defaultFg: number,
485
+ defaultBg: number,
486
+ graphemes?: GraphemeOverride[],
487
+ ): void {
488
+ const buf = this.term.buffer.active;
489
+ const line = y < this.rows ? buf.getLine(baseY + y) : null;
490
+ for (let x = 0; x < width; x++) {
491
+ const idx = y * width + x;
492
+ if (!line || x >= cols) {
493
+ writeCell(buffers, idx, SPACE_CODE, null, null, 0, dfR, dfG, dfB, dbR, dbG, dbB);
494
+ continue;
495
+ }
496
+ line.getCell(x, cell);
497
+ if (cell.getWidth() === 0) {
498
+ // Spacer half of the preceding wide glyph — inherit its colors.
499
+ writeContinuation(buffers, idx);
500
+ continue;
501
+ }
502
+
503
+ let fg: number | null = null;
504
+ if (cell.isFgRGB()) fg = cell.getFgColor();
505
+ else if (cell.isFgPalette()) fg = XTERM_PALETTE[cell.getFgColor()] ?? null;
506
+
507
+ let bg: number | null = null;
508
+ if (cell.isBgRGB()) bg = cell.getBgColor();
509
+ else if (cell.isBgPalette()) bg = XTERM_PALETTE[cell.getBgColor()] ?? null;
510
+
511
+ let attrs = 0;
512
+ if (cell.isBold()) attrs |= ATTR_BOLD;
513
+ if (cell.isDim()) attrs |= ATTR_DIM;
514
+ if (cell.isItalic()) attrs |= ATTR_ITALIC;
515
+ if (cell.isUnderline()) attrs |= ATTR_UNDERLINE;
516
+ if (cell.isBlink()) attrs |= ATTR_BLINK;
517
+ if (cell.isStrikethrough()) attrs |= ATTR_STRIKETHROUGH;
518
+ // Reverse video (app INVERSE) renders as a fg/bg SWAP, not the INVERSE
519
+ // attribute bit — a framebuffer cell carrying that bit does not flush as
520
+ // reverse (see blit.ts). Resolve nulls to the defaults first so
521
+ // default-on-default inverts to defaultBg-on-defaultFg.
522
+ const inverted = !!cell.isInverse();
523
+
524
+ const chars = cell.getChars();
525
+ const codepoint = chars ? (chars.codePointAt(0) ?? SPACE_CODE) : SPACE_CODE;
526
+ if (inverted) {
527
+ const rFg = fg === null ? defaultFg : fg;
528
+ const rBg = bg === null ? defaultBg : bg;
529
+ writeCell(buffers, idx, codepoint, rBg, rFg, attrs, dfR, dfG, dfB, dbR, dbG, dbB);
530
+ } else {
531
+ writeCell(buffers, idx, codepoint, fg, bg, attrs, dfR, dfG, dfB, dbR, dbG, dbB);
532
+ }
533
+ // A grapheme wider than its base codepoint (ZWJ/flag emoji, combining marks)
534
+ // can't live in a single u32 — record it for the native setCell re-write.
535
+ if (graphemes && chars.length > (codepoint > 0xffff ? 2 : 1)) {
536
+ graphemes.push({ x, y, chars, fg, bg, attrs });
537
+ }
538
+ }
539
+ }
540
+
541
+ /**
542
+ * The visible rows as plain text (trailing blanks trimmed, wide spacers
543
+ * collapsed) — the on-demand read the OSC52 copy path uses when the blit path
544
+ * has omitted the styled rows. `scrollOffset` matches {@link snapshot}.
545
+ */
546
+ visibleRowTexts(scrollOffset = 0): string[] {
547
+ const buf = this.term.buffer.active;
548
+ const offset = Math.max(0, Math.min(scrollOffset, buf.viewportY));
549
+ const baseY = buf.viewportY - offset;
550
+ const out: string[] = [];
551
+ for (let y = 0; y < this.rows; y++) {
552
+ const line = buf.getLine(baseY + y);
553
+ out.push(line ? line.translateToString(true) : "");
554
+ }
555
+ return out;
76
556
  }
77
557
 
78
558
  dispose(): void {