vterm.js 0.5.0 → 0.5.1

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/README.md CHANGED
@@ -19,6 +19,7 @@ Part of the [vterm](https://github.com/beorn/vterm) monorepo.
19
19
  - **Focus tracking** — mode 1004
20
20
  - **Synchronized output** — mode 2026
21
21
  - **Application cursor keys & keypad**
22
+ - **Kitty keyboard protocol** — progressive-enhancement flags with a push/pop/set stack (`CSI u`)
22
23
  - **OSC sequences** — window title (OSC 0/2), hyperlinks (OSC 8), clipboard (OSC 52), colors
23
24
  - **DCS sequences** — consumed and ignored, XTVERSION response
24
25
  - **Device attributes** — DA1/DA2/DA3 responses
@@ -27,6 +28,7 @@ Part of the [vterm](https://github.com/beorn/vterm) monorepo.
27
28
  - **Character sets** — DEC Special Graphics (box drawing), UTF-8
28
29
  - **Soft terminal reset** — DECSTR
29
30
  - **Scrollback buffer** with configurable limit
31
+ - **State serialization** — binary `snapshot()`/`restore()` for exact state, `serialize()`/`serializeSnapshot()` for a minimal-ANSI projection any VT-compatible terminal can replay
30
32
  - **Wide character support** — CJK, emoji ZWJ sequences, regional indicators, VS-16
31
33
  - **Full C0/C1 control code handling**
32
34
  - **Zero dependencies** — works in Bun, Node.js, and browsers
@@ -96,6 +98,164 @@ console.log(cell.fg) // { r: 255, g: 100, b: 0 }
96
98
  console.log(cell.underlineColor) // { r: 0, g: 150, b: 255 }
97
99
  ```
98
100
 
101
+ ### Kitty keyboard
102
+
103
+ Progressive keyboard enhancement (the Kitty protocol) is negotiated through a flags stack:
104
+
105
+ ```typescript
106
+ screen.process(new TextEncoder().encode("\x1b[>1u")) // push: save current flags, install flags=1
107
+ screen.process(new TextEncoder().encode("\x1b[<u")) // pop: restore the previously pushed flags
108
+ screen.process(new TextEncoder().encode("\x1b[=5;2u")) // set: OR flags=5 into the live flags
109
+ ```
110
+
111
+ - **push** — `CSI > flags u` saves the current flags on the stack, then installs `flags`
112
+ - **pop** — `CSI < u` restores the flags at the top of the stack (empty stack → `0`)
113
+ - **set** — `CSI = flags ; mode u` writes directly without touching the stack: mode `1` (or omitted) assigns, `2` ORs the given bits in, `3` clears them
114
+
115
+ The flags and their stack survive `snapshot()`/`restore()`, and `serialize()` reconstructs any `(flags, stack)` pair by seeding the stack bottom with one set (`CSI = u`), replaying the remaining stack entries as pushes (`CSI > u`), and landing on the live flags with a final push.
116
+
117
+ ### Serialize (state → ANSI)
118
+
119
+ ```typescript
120
+ import { createVtermScreen, serializeSnapshot } from "vterm.js"
121
+
122
+ const screen = createVtermScreen({ cols: 80, rows: 24 })
123
+ screen.process(new TextEncoder().encode("Hello, \x1b[1;32mBold Green\x1b[0m World!"))
124
+
125
+ // Re-encode current state as minimal ANSI a fresh same-size terminal can replay
126
+ const ansi = screen.serialize()
127
+ // Equivalent when you already hold a snapshot object:
128
+ // const ansi = serializeSnapshot(screen.snapshot())
129
+
130
+ const restored = createVtermScreen({ cols: 80, rows: 24 })
131
+ restored.process(new TextEncoder().encode(ansi))
132
+ console.log(restored.getText()) // "Hello, Bold Green World!" — text and styles both survived
133
+ ```
134
+
135
+ `serialize(options?)` walks scrollback and the visible screen and emits a minimal SGR/mode/cursor stream that a fresh same-size terminal can replay: the pending pen at the cursor, DECAWM/insert/origin/reverse/app-cursor/app-keypad/bracketed/mouse/focus modes, margins, alt-screen, and cursor shape all survive the round trip. `SerializeOptions` toggles `includeScrollback` (default `true`), `includeTitle` (default `false`), `hyperlinks` (default `true`), and `excludeModes` (an array of mode keys to skip, leaving the receiver's fresh default for those). Two exclusions are always enforced and cannot be toggled off: synchronized-output mode (`?2026`) and DECCOLM (`?3`) are never emitted, since replaying either would wedge or wipe a real receiver. The inactive screen buffer, DECSC saved-cursor state, the color stack, and mid-parse parser state aren't representable in a VT byte stream and stay unserialized by design — use the binary `snapshot()`/`restore()` pair, or raw byte replay, when those need to cross too.
136
+
137
+ **Phase order.** The emit stream is five ordered phases: **history** (scrollback flushed via CRLF, since CUP cannot address it) → **geometry** (`?1049h` alt-enter and the DECSTBM scroll region — both home the cursor) → **paint-safe modes** (DECSET set-forms only, mouse, kitty keyboard, palette/default colors, optional title) → **paint** (DECAWM off, `CSI H` home + `CSI 2J` clear, then positioned pen-diff rows) → **finalize** (autowrap, cursor shape, charset, insert, tab stops, DECSLRM, pending pen, visibility, cursor). Two constraints are load-bearing: **no geometry op may follow the cursor** (all homing must precede the paint), and the **cursor is restored last** — emitted region-relative (`?6h` origin then a margin-relative `CSI H`) when origin mode is set and the target lies inside the scroll region, absolute otherwise. `?2026` (synchronized output) and `?3` (DECCOLM) are never emitted — replaying either would wedge or wipe a live receiver.
138
+
139
+ **Soft-wrap history.** `Snapshot` carries `scrollbackSoftWrapped: boolean[]` alongside `scrollback` — bit `i` true means scrollback row `i` wraps into row `i+1` (one logical line split across rows); an absent array (older stored v1 snapshots) is treated as all-false. Phase-1 history re-links a wrapped row by emitting it at full width with no line break, so the receiver's own autowrap re-records the bit as the row scrolls back.
140
+
141
+ ### Ops and taps
142
+
143
+ `apply(op)` is the single public write entry — the same seam a session journal records and replays. An op is coarse, serializable data; **bytes stay the canonical encoding** (an `output` op is just bytes, deterministically re-parsed on replay — vterm never reifies a per-VT-action op):
144
+
145
+ ```typescript
146
+ type TerminalOp =
147
+ | { type: "output"; data: Uint8Array | string } // routes to process() — a string is UTF-8-encoded first
148
+ | { type: "resize"; cols: number; rows: number } // routes to resize()
149
+ ```
150
+
151
+ `apply()` is additive over `process()`/`resize()`, which remain public. Two opt-in taps observe the write stream without changing it:
152
+
153
+ - **`tapOps(listener): () => void`** — the listener fires **once per applied op** (one per `process`/`apply`/`resize` call), with the canonical payload (an `output` op always carries a `Uint8Array`, even when a string was passed to `apply()`). This is the journaling seam.
154
+ - **`tapParser(listener): () => void`** — the listener fires **once per parsed VT action, AFTER the engine applies it, in stream order**. Consecutive printable graphemes coalesce into one `print` event (flushed before any control event and at end-of-flood):
155
+
156
+ ```typescript
157
+ type ParserEvent =
158
+ | { kind: "print"; text: string }
159
+ | { kind: "execute"; code: number } // a C0 control byte (BEL/BS/HT/LF/VT/FF/CR)
160
+ | { kind: "csi"; final: string; params: number[]; prefix?: string; intermediates?: string }
161
+ | { kind: "osc"; code: number; data: string }
162
+ | { kind: "esc"; final: string; intermediates?: string }
163
+ ```
164
+
165
+ Both taps return an **unsubscribe** function, and both are **zero-overhead when unused** — no event object is allocated on the byte-flood path unless a listener is registered. Taps are **fail-loud**: a listener MUST NOT throw; if it does, the exception propagates out of the write call and the engine is left in a consistent state (never wedged mid-sequence). For CSI, `params` uses the engine's own top-level parse (`;`-split, empty → `0`; colon sub-parameters collapse to their leading integer); `prefix` is the private marker (`?`/`>`/`<`/`=`) when present. DCS and APC string sequences are not surfaced as parser events.
166
+
167
+ ```typescript
168
+ import { createVtermScreen, type TerminalOp, type ParserEvent } from "vterm.js"
169
+
170
+ const screen = createVtermScreen({ cols: 80, rows: 24 })
171
+
172
+ // Journal every write; re-applying the log onto a fresh screen reproduces the state exactly.
173
+ const journal: TerminalOp[] = []
174
+ const stopJournal = screen.tapOps((op) => journal.push(op))
175
+
176
+ // Mirror the window title without polling for it.
177
+ let title = ""
178
+ const stopTitle = screen.tapParser((ev: ParserEvent) => {
179
+ if (ev.kind === "osc" && (ev.code === 0 || ev.code === 2)) title = ev.data
180
+ })
181
+
182
+ screen.apply({ type: "output", data: "\x1b]0;build passing\x07\x1b[1mhi\x1b[0m" })
183
+ console.log(title) // "build passing" — set the moment the OSC was applied
184
+
185
+ stopJournal()
186
+ stopTitle()
187
+
188
+ const replay = createVtermScreen({ cols: 80, rows: 24 })
189
+ for (const op of journal) replay.apply(op)
190
+ console.log(replay.serialize() === screen.serialize()) // true
191
+ ```
192
+
193
+ ### Signals — the reactive read plane
194
+
195
+ `screen.signals` is the third read plane (alongside the pull plane — `getRowAbsolute`/`takeDirty` — and the push plane — `tapOps`/`tapParser`). Five equality-gated **read-signals** turn "poll the engine every frame" into "subscribe and get told when it changes" — they exist to replace a guest's title-diff polling and its DECSET regex scanning:
196
+
197
+ | Signal | Value | Fires when |
198
+ | --------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
199
+ | `title$` | `string` | the window title (OSC 0/2) changes |
200
+ | `modes$` | `TerminalModes` | any DEC/xterm mode flips (alt screen, cursor visibility, bracketed paste, mouse tracking + protocol level, focus, origin, insert, …) |
201
+ | `cursor$` | `Cursor` | the cursor `{ col, row }` moves |
202
+ | `size$` | `Size` | the screen is resized |
203
+ | `damage$` | `DirtyRegion` | every flush, with that flush's BATCHED dirty rows |
204
+
205
+ Each signal is a minimal, dependency-free shape — vterm.js imports no reactive library, so the interface is exactly:
206
+
207
+ ```typescript
208
+ interface ReadSignal<T> {
209
+ get(): T // the CURRENT value, read live — never consumes or resets a delivery
210
+ subscribe(listener: (value: T) => void): () => void // returns an unsubscribe function
211
+ }
212
+ ```
213
+
214
+ A consumer wraps it in `alien-signals`, a zustand store, or React's `useSyncExternalStore` trivially, precisely because the shape is just `{ get, subscribe }`.
215
+
216
+ **Flush boundary.** Every signal coalesces to **at most one emission per public state-mutating call** (`process` / `apply` / `resize` / `reset` / `restore`; `apply` inherits it via `process`/`resize`). Ten mode flips in one `process()` call deliver **one** `modes$` emission carrying the final set; a paint touching twenty rows delivers **one** `damage$` batch with the union of rows.
217
+
218
+ **Change-only.** `title$`/`modes$`/`cursor$`/`size$` deliver only when their value actually changed across the call (equality-gated). Subscribing captures the current value as its baseline and does **not** fire immediately — only later changes deliver.
219
+
220
+ **`damage$` vs `takeDirty()` — two independent epochs.** `damage$` publishes the same `DirtyRegion` shape as `takeDirty()`, but runs on its **own** accumulator. Subscribing to `damage$` never steals or resets the pull-plane `takeDirty()` epoch, so a `damage$` renderer and a `takeDirty()` differ observe the same damage without draining each other. The emitted region (including its `rows` `Set`) is **shared** across all `damage$` subscribers for that flush — treat it as read-only.
221
+
222
+ **Zero overhead when unused.** Nothing is allocated until `.signals` is first read, each signal is created lazily on first access, and the write core does **no** per-write damage accumulation until `damage$` actually has a subscriber. Listeners are **fail-loud** (a throwing listener propagates out of the write call).
223
+
224
+ ```typescript
225
+ import { createVtermScreen, type DirtyRegion, type TerminalModes } from "vterm.js"
226
+
227
+ const screen = createVtermScreen({ cols: 80, rows: 24 })
228
+
229
+ // Replace title polling: told the moment it changes, never on an equal value.
230
+ screen.signals.title$.subscribe((title) => setWindowTitle(title))
231
+
232
+ // Replace the DECSET regex scanner: the closed mode set, structured.
233
+ screen.signals.modes$.subscribe((m: TerminalModes) => {
234
+ if (m.altScreen) enterAltScreen()
235
+ useMouse(m.mouseTracking, m.mouseTrackingMode, m.sgrMouse)
236
+ })
237
+
238
+ // Frame-batched repaint: one dirty-set per flush, coexisting with any takeDirty() differ.
239
+ screen.signals.damage$.subscribe((d: DirtyRegion) => {
240
+ if (d.rows === "all") repaintEverything()
241
+ else for (const row of d.rows) repaintRow(row)
242
+ if (d.cursor) repaintCursor()
243
+ })
244
+ ```
245
+
246
+ ## Naming — schema migration
247
+
248
+ The public vocabulary is converging on the terminal domain model (name the thing, not the mechanism; **row = cells, line = text**; flat progressive shapes over discriminated unions). The canonical names below are live now; the old names remain exported as `@deprecated` aliases through the migration window, so existing code keeps compiling.
249
+
250
+ | Deprecated | Canonical | Notes |
251
+ | ---------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------ |
252
+ | `CellColor` | `Color` | Same shape `{ r, g, b, index? }`; a type named `RGB`/`CellColor` carrying `.index` was the smell. |
253
+ | `ScreenSnapshot` / `VtermScreenSnapshot` | `Snapshot` | The namespace supplies the context; `VtermScreenSnapshot` stays exported as an alias for existing consumers. |
254
+ | `getLine(row): ScreenCell[]` | `getRow(row)` | Row = cells; the text readers (`getText`, `getScrollbackText`) keep "line/text". |
255
+ | `getCursorPosition(): {x,y}` | `getCursor(): Cursor` | `Cursor` is `{ col, row }` — the grid's own vocabulary. |
256
+
257
+ **Deferred (not renamed this slice):** the `Snapshot.cursor` / `Snapshot.savedState` fields keep `x`/`y` rather than `col`/`row`, because those field names are the wire shape read by `encodeScreenSnapshotBinary` — renaming them would silently change the persisted binary format. The `col`/`row` vocabulary is available at the read boundary via `getCursor()` / the `Cursor` type. The snapshot field rename is a codec-format concern for a coordinated schema bump.
258
+
99
259
  ## API
100
260
 
101
261
  ### `createVtermScreen(options)`
@@ -109,32 +269,47 @@ console.log(cell.underlineColor) // { r: 0, g: 150, b: 255 }
109
269
 
110
270
  ### Screen methods
111
271
 
112
- | Method | Description |
113
- | ------------------------------ | ------------------------------------------------------ |
114
- | `process(data: Uint8Array)` | Feed raw terminal data |
115
- | `getText()` | Get all text (scrollback + screen) |
116
- | `getTextRange(sr, sc, er, ec)` | Get text in a range |
117
- | `getLine(row)` | Get cells for a row |
118
- | `getCell(row, col)` | Get a single cell |
119
- | `getCursorPosition()` | Get cursor `{ x, y }` |
120
- | `getCursorVisible()` | Check cursor visibility |
121
- | `getCursorShape()` | Get cursor shape: `"block"`, `"underline"`, or `"bar"` |
122
- | `getCursorBlinking()` | Check if cursor is blinking |
123
- | `getMode(mode)` | Check terminal mode |
124
- | `getTitle()` | Get window title |
125
- | `getScrollbackLength()` | Number of scrollback lines |
126
- | `getViewportOffset()` | Current viewport scroll offset |
127
- | `scrollViewport(delta)` | Scroll viewport |
128
- | `resize(cols, rows)` | Resize terminal |
129
- | `reset()` | Reset to initial state |
272
+ | Method | Description |
273
+ | ------------------------------ | ---------------------------------------------------------------------------------------- |
274
+ | `process(data: Uint8Array)` | Feed raw terminal data |
275
+ | `apply(op: TerminalOp)` | Apply one serializable write op (`output` / `resize`) |
276
+ | `tapOps(listener)` | Observe applied ops; returns unsubscribe |
277
+ | `tapParser(listener)` | Observe parsed VT actions post-apply; returns unsubscribe |
278
+ | `signals` | Reactive read plane: `title$`/`modes$`/`cursor$`/`size$`/`damage$` (lazy, zero-overhead) |
279
+ | `getText()` | Get all text (scrollback + screen) |
280
+ | `getTextRange(sr, sc, er, ec)` | Get text in a range |
281
+ | `getRow(row)` | Get cells for a screen row (row = cells, line = text) |
282
+ | `getLine(row)` | **Deprecated** — alias of `getRow` |
283
+ | `getCell(row, col)` | Get a single cell |
284
+ | `getCursor()` | Get cursor `{ col, row }` |
285
+ | `getCursorPosition()` | **Deprecated** — cursor `{ x, y }`; use `getCursor()` |
286
+ | `getCursorVisible()` | Check cursor visibility |
287
+ | `getCursorShape()` | Get cursor shape: `"block"`, `"underline"`, or `"bar"` |
288
+ | `getCursorBlinking()` | Check if cursor is blinking |
289
+ | `getMode(mode)` | Check terminal mode |
290
+ | `getTitle()` | Get window title |
291
+ | `getScrollbackLength()` | Number of scrollback lines |
292
+ | `getViewportOffset()` | Current viewport scroll offset |
293
+ | `scrollViewport(delta)` | Scroll viewport |
294
+ | `totalRows()` | Buffer height: retained scrollback + screen |
295
+ | `screenRows()` | Visible screen row count |
296
+ | `viewportTop()` | Absolute row where the viewport's top line sits |
297
+ | `getRowAbsolute(row)` | Cells at an ABSOLUTE row (0 = oldest retained line) |
298
+ | `firstRetainedRow()` | Global index of retained row 0 (lines trimmed so far) |
299
+ | `takeDirty()` | Take + reset accumulated per-row damage (pull plane) |
300
+ | `resize(cols, rows)` | Resize terminal |
301
+ | `reset()` | Reset to initial state |
302
+ | `snapshot()` | Capture serializable T0-T3 terminal state |
303
+ | `restore(snapshot)` | Restore a snapshot captured from `snapshot()` |
304
+ | `serialize(options?)` | Re-encode current state as minimal ANSI a fresh same-size terminal can replay |
130
305
 
131
306
  ### Cell properties
132
307
 
133
308
  ```typescript
134
309
  interface ScreenCell {
135
310
  char: string
136
- fg: CellColor | null // { r, g, b }
137
- bg: CellColor | null
311
+ fg: Color | null // { r, g, b, index? }
312
+ bg: Color | null
138
313
  bold: boolean
139
314
  faint: boolean
140
315
  italic: boolean
@@ -150,6 +325,97 @@ interface ScreenCell {
150
325
  }
151
326
  ```
152
327
 
328
+ `ScreenCell` is a **read-boundary value** — it is materialized on demand, not how the grid
329
+ is stored. See below.
330
+
331
+ ### Internal representation — packed cell grid
332
+
333
+ The engine stores the grid as **packed typed arrays**, not one heap object per cell. Each
334
+ row keeps a `Uint32Array` of metadata (boolean attributes + a 3-bit underline-style enum +
335
+ color/URL presence bits), a parallel `string[]` grapheme sidecar, and lazily-allocated
336
+ color planes: 24-bit packed RGB in a `Uint32Array` alongside the palette-origin index in an
337
+ `Int16Array`. Colors are therefore stored as primitives, so the byte-flood write path
338
+ allocates **nothing per cell** — no cell object and no color object. `ScreenCell` objects
339
+ materialize only at the read boundary (`getCell`, `getRow`, `getRowAbsolute`, `snapshot`,
340
+ `serialize`), and `Color` identity (`{ r, g, b, index? }`) is preserved end to end: an
341
+ indexed SGR keeps its origin `index` so `serialize()` re-emits the faithful indexed form
342
+ rather than baking truecolor.
343
+
344
+ The color planes are allocated on a row's first colored cell, so all-plain-text rows stay
345
+ lean (`meta` + `chars` only). OSC-8 URLs (rare) live in a sparse per-row `Map`. The public
346
+ contract is unchanged — reads, snapshots, ops/taps, and damage tracking behave identically
347
+ to the previous heap-object grid (the full existing test suite passes unchanged).
348
+
349
+ The encoding mirrors the shape of silvery's `ag-term` render buffer (packed `Uint32Array`
350
+ metadata + separate grapheme array) without importing it — vterm stays dependency-free.
351
+
352
+ **Throughput** (`bun tools/bench-packed-grid.ts`, vs the pre-packing heap-object grid):
353
+ `~1.3×` faster on both a 200k-line scroll flood and a large in-place repaint, at `~22%`
354
+ lower peak RSS (the write path no longer churns per-cell objects through the GC).
355
+
356
+ ## Absolute rows and damage tracking
357
+
358
+ Two engine-native read planes over the buffer, for renderers that address the whole
359
+ history and repaint only what changed.
360
+
361
+ ### Absolute rows
362
+
363
+ The buffer is the retained scrollback followed by the screen. **Absolute row 0 is the
364
+ oldest retained scrollback line**; the screen occupies the last `screenRows()` rows
365
+ (absolute `totalRows() - screenRows()` through `totalRows() - 1`). The existing
366
+ screen-relative reads (`getLine`, `getCell`) are unchanged — absolute addressing is
367
+ additive.
368
+
369
+ ```typescript
370
+ screen.totalRows() // scrollback.length + screenRows()
371
+ screen.screenRows() // the terminal's row dimension
372
+ screen.getRowAbsolute(0) // Cell[] of the oldest retained line
373
+ screen.viewportTop() // absolute row of the viewport's top line
374
+ // at the bottom → totalRows() - screenRows(); scrolled fully up → 0
375
+ ```
376
+
377
+ Naming follows the ecosystem rule **row = cells, line = text**: `getRowAbsolute`
378
+ returns cells; colors are stripped of palette-origin index at the read boundary (same
379
+ contract as `getLine`). Out-of-range indices return a blank row.
380
+
381
+ **Stability contract.** As lines scroll IN, an existing scrollback row keeps its
382
+ absolute index (the screen shifts up in absolute terms, but its content keeps the same
383
+ absolute index because scrollback grows by exactly the shift). When retention trimming
384
+ evicts the oldest lines, every absolute index shifts down by the trimmed count and
385
+ `firstRetainedRow()` bumps by the same amount. `firstRetainedRow()` is the **global**
386
+ index of retained row 0 (the count of lines ever trimmed, `0` initially; reset by
387
+ `reset()` / `restore()`). A stable global id is `firstRetainedRow() + <absolute row>`;
388
+ an increase since a prior read signals a trim.
389
+
390
+ ### Damage tracking (`takeDirty`)
391
+
392
+ `takeDirty()` returns the per-row damage accumulated since the previous call and resets
393
+ the epoch. It is the **pull-plane** surface — a renderer reads it on its own schedule,
394
+ independent of the push-plane `tapOps` / `tapParser`. Accumulation is always on; the
395
+ write path costs at most one `Set` membership update per row-run (no per-cell
396
+ allocation), and structural changes drop the set for the `"all"` sentinel.
397
+
398
+ ```typescript
399
+ const { rows, cursor, scrolled } = screen.takeDirty()
400
+ ```
401
+
402
+ | Field | Type | Meaning |
403
+ | ---------- | ---------------------- | -------------------------------------------------------------------- |
404
+ | `rows` | `Set<number> \| "all"` | Changed rows as ABSOLUTE indices, or `"all"` on structural change |
405
+ | `cursor` | `boolean` | Cursor position / visibility / shape / blink changed since last take |
406
+ | `scrolled` | `number` | Lines that entered scrollback since last take |
407
+
408
+ `rows` is `"all"` on resize, full clear (ED 2/3), alt-screen switch, `reset()`, and
409
+ `restore()`. The returned `Set` is **owned by the caller** — the engine keeps a fresh
410
+ empty accumulator after the take. On a normal scroll only the newly-blanked bottom row
411
+ is reported as changed (the other rows kept their absolute index) and `scrolled`
412
+ increments, so a scrollback-preserving renderer shifts its viewport by `scrolled` and
413
+ repaints just `rows`. `rows` indices are valid against the buffer at take time; if a
414
+ take spanned a trim (`firstRetainedRow()` increased), rebase any cached indices by the
415
+ delta.
416
+
417
+ The **reactive** counterpart is `signals.damage$` (see [Signals](#signals--the-reactive-read-plane)) — the same `DirtyRegion` shape delivered per-flush to subscribers. It runs on an accumulator **independent** of `takeDirty()`, so a push-style `damage$` renderer and a pull-style `takeDirty()` differ coexist without draining each other's epoch.
418
+
153
419
  ## vs vt100.js
154
420
 
155
421
  | Feature | vt100.js | vterm.js |