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 +286 -20
- package/dist/index.d.mts +448 -2
- package/dist/index.mjs +2245 -186
- package/package.json +3 -1
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
|
-
| `
|
|
116
|
-
| `
|
|
117
|
-
| `
|
|
118
|
-
| `
|
|
119
|
-
| `
|
|
120
|
-
| `
|
|
121
|
-
| `
|
|
122
|
-
| `
|
|
123
|
-
| `
|
|
124
|
-
| `
|
|
125
|
-
| `
|
|
126
|
-
| `
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
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:
|
|
137
|
-
bg:
|
|
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 |
|