vt100.js 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Beorn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # vt100.js
2
+
3
+ Pure TypeScript VT100 terminal emulator. Zero dependencies, headless, fast.
4
+
5
+ ## Features
6
+
7
+ - Full VT100/ANSI escape sequence parsing
8
+ - SGR attributes (bold, italic, underline, colors, etc.)
9
+ - 16-color, 256-color, and 24-bit truecolor support
10
+ - Cursor movement, save/restore (DECSC/DECRC)
11
+ - Screen modes (alternate screen, auto-wrap, origin mode, etc.)
12
+ - Scroll regions (DECSTBM) with content preservation
13
+ - Scrollback buffer with configurable limit
14
+ - Insert/delete characters and lines
15
+ - Wide character support (CJK, emoji)
16
+ - Zero dependencies -- works in Bun, Node.js, and browsers
17
+
18
+ ## Comparison
19
+
20
+ | Package | Screen State | SGR Colors | Scrollback | Wide Chars | Zero Deps | Size |
21
+ |---------|:---:|:---:|:---:|:---:|:---:|:---:|
22
+ | **vt100.js** | yes | 16/256/true | yes | yes | yes | ~30KB |
23
+ | `@xterm/headless` | yes | 16/256/true | yes | yes | no | ~500KB |
24
+ | `node-pty` + xterm | yes | 16/256/true | yes | yes | no | native build |
25
+ | `ansi-parser` | no | parse only | no | no | yes | ~5KB |
26
+ | `terminal-kit` | yes | 16/256/true | no | partial | no | ~2MB |
27
+ | `blessed` / `neo-blessed` | yes | 16/256 | no | no | no | ~1MB |
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ npm install vt100.js
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ ```typescript
38
+ import { createVt100Screen } from "vt100.js"
39
+
40
+ const screen = createVt100Screen({ cols: 80, rows: 24 })
41
+ screen.process(new TextEncoder().encode("Hello, \x1b[1mBold\x1b[0m World!"))
42
+
43
+ // Read cell state
44
+ const cell = screen.getCell(0, 0)
45
+ console.log(cell.char) // "H"
46
+
47
+ // Read text
48
+ console.log(screen.getText()) // "Hello, Bold World!"
49
+
50
+ // Check cursor position
51
+ const pos = screen.getCursorPosition()
52
+ console.log(pos) // { x: 18, y: 0 }
53
+ ```
54
+
55
+ ## API
56
+
57
+ ### `createVt100Screen(options)`
58
+
59
+ Create a new screen instance.
60
+
61
+ ```typescript
62
+ const screen = createVt100Screen({
63
+ cols: 80, // terminal width
64
+ rows: 24, // terminal height
65
+ scrollbackLimit: 1000 // max scrollback lines (default: 1000)
66
+ })
67
+ ```
68
+
69
+ ### Screen methods
70
+
71
+ | Method | Description |
72
+ |--------|-------------|
73
+ | `process(data: Uint8Array)` | Feed raw terminal data |
74
+ | `getText()` | Get all text (scrollback + screen) |
75
+ | `getTextRange(startRow, startCol, endRow, endCol)` | Get text in a range |
76
+ | `getLine(row)` | Get cells for a row |
77
+ | `getCell(row, col)` | Get a single cell (char, fg, bg, bold, etc.) |
78
+ | `getCursorPosition()` | Get cursor `{ x, y }` |
79
+ | `getCursorVisible()` | Check cursor visibility |
80
+ | `getMode(mode)` | Check terminal mode (`altScreen`, `bracketedPaste`, etc.) |
81
+ | `getTitle()` | Get window title (set via OSC 0/2) |
82
+ | `getScrollbackLength()` | Number of scrollback lines |
83
+ | `getViewportOffset()` | Current viewport scroll offset |
84
+ | `scrollViewport(delta)` | Scroll viewport by delta lines |
85
+ | `resize(cols, rows)` | Resize the terminal |
86
+ | `reset()` | Reset to initial state |
87
+
88
+ ### Cell properties
89
+
90
+ ```typescript
91
+ interface ScreenCell {
92
+ char: string // character content
93
+ fg: CellColor | null // foreground color { r, g, b }
94
+ bg: CellColor | null // background color { r, g, b }
95
+ bold: boolean
96
+ faint: boolean
97
+ italic: boolean
98
+ underline: UnderlineStyle // "none" | "single" | "double" | "curly" | "dotted" | "dashed"
99
+ strikethrough: boolean
100
+ inverse: boolean
101
+ hidden: boolean
102
+ wide: boolean // true for CJK/emoji double-width chars
103
+ }
104
+ ```
105
+
106
+ ### Terminal modes
107
+
108
+ Queryable via `screen.getMode(mode)`:
109
+
110
+ - `altScreen` -- alternate screen buffer
111
+ - `bracketedPaste` -- bracketed paste mode
112
+ - `mouseTracking` -- mouse tracking
113
+ - `autoWrap` -- auto-wrap at right margin (default: on)
114
+ - `applicationCursor` -- application cursor keys
115
+ - `applicationKeypad` -- application keypad mode
116
+ - `originMode` -- origin mode
117
+ - `insertMode` -- insert mode
118
+ - `reverseVideo` -- reverse video
119
+ - `focusTracking` -- focus tracking
120
+ - `cursorVisible` -- cursor visibility
121
+
122
+ ## Ecosystem
123
+
124
+ - [Termless](https://termless.dev) -- headless terminal testing (uses vt100.js as its default backend)
125
+ - [Terminfo.dev](https://terminfo.dev) -- terminal feature support tables
126
+ - [Silvery](https://silvery.dev) -- React TUI framework
127
+ - [@termless/vt100](https://github.com/beorn/termless) -- Termless backend wrapper for vt100.js
128
+
129
+ ## License
130
+
131
+ MIT
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "vt100.js",
3
+ "version": "0.1.0",
4
+ "description": "Pure TypeScript VT100 terminal emulator. Zero dependencies, headless, fast.",
5
+ "keywords": ["vt100", "terminal-emulator", "ansi", "typescript", "headless", "zero-dependency", "tui"],
6
+ "homepage": "https://github.com/beorn/vt100",
7
+ "bugs": "https://github.com/beorn/vt100/issues",
8
+ "license": "MIT",
9
+ "author": "Beorn",
10
+ "repository": "github:beorn/vt100",
11
+ "type": "module",
12
+ "module": "./src/index.ts",
13
+ "exports": {
14
+ ".": "./src/index.ts"
15
+ },
16
+ "files": ["src"],
17
+ "publishConfig": { "access": "public" },
18
+ "engines": { "bun": ">=1.0.0", "node": ">=18" },
19
+ "devDependencies": {
20
+ "vitest": "^4.0.0"
21
+ },
22
+ "scripts": {
23
+ "test": "vitest run",
24
+ "typecheck": "tsc --noEmit"
25
+ }
26
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export {
2
+ createScreen as createVt100Screen,
3
+ type Screen as Vt100Screen,
4
+ type ScreenOptions as Vt100ScreenOptions,
5
+ type ScreenCell,
6
+ type CellColor,
7
+ type UnderlineStyle,
8
+ } from "./screen.ts"
package/src/screen.ts ADDED
@@ -0,0 +1,1202 @@
1
+ /**
2
+ * Pure TypeScript VT100 terminal emulator.
3
+ *
4
+ * Inspired by the Rust vt100 crate — parses a terminal byte stream and
5
+ * maintains an in-memory screen representation with per-cell attributes,
6
+ * cursor tracking, and mode state.
7
+ *
8
+ * Zero dependencies. Handles: SGR (16/256/truecolor), cursor movement,
9
+ * erase, scroll regions, alternate screen, modes, OSC title, and more.
10
+ */
11
+
12
+ // ═══════════════════════════════════════════════════════
13
+ // Internal cell representation
14
+ // ═══════════════════════════════════════════════════════
15
+
16
+ export interface CellColor {
17
+ r: number
18
+ g: number
19
+ b: number
20
+ }
21
+
22
+ export type UnderlineStyle = "none" | "single" | "double" | "curly" | "dotted" | "dashed"
23
+
24
+ export interface ScreenCell {
25
+ char: string
26
+ fg: CellColor | null
27
+ bg: CellColor | null
28
+ bold: boolean
29
+ faint: boolean
30
+ italic: boolean
31
+ underline: UnderlineStyle
32
+ strikethrough: boolean
33
+ inverse: boolean
34
+ hidden: boolean
35
+ wide: boolean
36
+ }
37
+
38
+ /** Frozen sentinel for unwritten cells — never mutate, copy-on-write in writeChar(). */
39
+ const EMPTY_CELL: ScreenCell = Object.freeze({
40
+ char: "",
41
+ fg: null,
42
+ bg: null,
43
+ bold: false,
44
+ faint: false,
45
+ italic: false,
46
+ underline: "none" as UnderlineStyle,
47
+ strikethrough: false,
48
+ inverse: false,
49
+ hidden: false,
50
+ wide: false,
51
+ })
52
+
53
+ function emptyCell(): ScreenCell {
54
+ return { ...EMPTY_CELL }
55
+ }
56
+
57
+ // ═══════════════════════════════════════════════════════
58
+ // ANSI 256-color palette
59
+ // ═══════════════════════════════════════════════════════
60
+
61
+ const ANSI_16: readonly CellColor[] = [
62
+ { r: 0x00, g: 0x00, b: 0x00 }, // 0 Black
63
+ { r: 0x80, g: 0x00, b: 0x00 }, // 1 Red
64
+ { r: 0x00, g: 0x80, b: 0x00 }, // 2 Green
65
+ { r: 0x80, g: 0x80, b: 0x00 }, // 3 Yellow
66
+ { r: 0x00, g: 0x00, b: 0x80 }, // 4 Blue
67
+ { r: 0x80, g: 0x00, b: 0x80 }, // 5 Magenta
68
+ { r: 0x00, g: 0x80, b: 0x80 }, // 6 Cyan
69
+ { r: 0xc0, g: 0xc0, b: 0xc0 }, // 7 White
70
+ { r: 0x80, g: 0x80, b: 0x80 }, // 8 Bright Black
71
+ { r: 0xff, g: 0x00, b: 0x00 }, // 9 Bright Red
72
+ { r: 0x00, g: 0xff, b: 0x00 }, // 10 Bright Green
73
+ { r: 0xff, g: 0xff, b: 0x00 }, // 11 Bright Yellow
74
+ { r: 0x00, g: 0x00, b: 0xff }, // 12 Bright Blue
75
+ { r: 0xff, g: 0x00, b: 0xff }, // 13 Bright Magenta
76
+ { r: 0x00, g: 0xff, b: 0xff }, // 14 Bright Cyan
77
+ { r: 0xff, g: 0xff, b: 0xff }, // 15 Bright White
78
+ ]
79
+
80
+ function buildPalette256(): CellColor[] {
81
+ const palette: CellColor[] = [...ANSI_16]
82
+ const levels = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]
83
+ for (let r = 0; r < 6; r++) {
84
+ for (let g = 0; g < 6; g++) {
85
+ for (let b = 0; b < 6; b++) {
86
+ palette.push({ r: levels[r]!, g: levels[g]!, b: levels[b]! })
87
+ }
88
+ }
89
+ }
90
+ for (let i = 0; i < 24; i++) {
91
+ const v = 8 + i * 10
92
+ palette.push({ r: v, g: v, b: v })
93
+ }
94
+ return palette
95
+ }
96
+
97
+ const PALETTE_256 = buildPalette256()
98
+
99
+ // ═══════════════════════════════════════════════════════
100
+ // Unicode width (simplified — CJK detection)
101
+ // ═══════════════════════════════════════════════════════
102
+
103
+ function isWide(codePoint: number): boolean {
104
+ // CJK Unified Ideographs, CJK Compatibility Ideographs, etc.
105
+ return (
106
+ (codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
107
+ (codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK Radicals
108
+ (codePoint >= 0x3041 && codePoint <= 0x33bf) || // Hiragana, Katakana, Bopomofo, etc.
109
+ (codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK Unified Extension A
110
+ (codePoint >= 0x4e00 && codePoint <= 0xa4cf) || // CJK Unified Ideographs
111
+ (codePoint >= 0xa960 && codePoint <= 0xa97c) || // Hangul Jamo Extended-A
112
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul Syllables
113
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK Compatibility Ideographs
114
+ (codePoint >= 0xfe10 && codePoint <= 0xfe19) || // Vertical Forms
115
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6b) || // CJK Compatibility Forms
116
+ (codePoint >= 0xff01 && codePoint <= 0xff60) || // Fullwidth Forms
117
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) || // Fullwidth Signs
118
+ (codePoint >= 0x1f300 && codePoint <= 0x1f9ff) || // Misc Symbols/Emoticons
119
+ (codePoint >= 0x1fa00 && codePoint <= 0x1faff) || // Extended Symbols & Pictographs
120
+ (codePoint >= 0x20000 && codePoint <= 0x2fffd) || // CJK Extension B-F
121
+ (codePoint >= 0x30000 && codePoint <= 0x3fffd) // CJK Extension G+
122
+ )
123
+ }
124
+
125
+ // ═══════════════════════════════════════════════════════
126
+ // Screen
127
+ // ═══════════════════════════════════════════════════════
128
+
129
+ export interface ScreenOptions {
130
+ cols: number
131
+ rows: number
132
+ scrollbackLimit?: number
133
+ }
134
+
135
+ interface Attrs {
136
+ fg: CellColor | null
137
+ bg: CellColor | null
138
+ bold: boolean
139
+ faint: boolean
140
+ italic: boolean
141
+ underline: UnderlineStyle
142
+ strikethrough: boolean
143
+ inverse: boolean
144
+ hidden: boolean
145
+ }
146
+
147
+ export interface Screen {
148
+ readonly cols: number
149
+ readonly rows: number
150
+ process(data: Uint8Array): void
151
+ resize(cols: number, rows: number): void
152
+ reset(): void
153
+ getCell(row: number, col: number): ScreenCell
154
+ getLine(row: number): ScreenCell[]
155
+ getText(): string
156
+ getTextRange(startRow: number, startCol: number, endRow: number, endCol: number): string
157
+ getCursorPosition(): { x: number; y: number }
158
+ getCursorVisible(): boolean
159
+ getTitle(): string
160
+ getMode(mode: string): boolean
161
+ getScrollbackLength(): number
162
+ getViewportOffset(): number
163
+ scrollViewport(delta: number): void
164
+ }
165
+
166
+ export function createScreen(opts: ScreenOptions): Screen {
167
+ let cols = opts.cols
168
+ let rows = opts.rows
169
+ const scrollbackLimit = opts.scrollbackLimit ?? 1000
170
+
171
+ // Main and alternate screen buffers
172
+ let mainGrid: ScreenCell[][] = makeGrid(cols, rows)
173
+ let altGrid: ScreenCell[][] = makeGrid(cols, rows)
174
+ let grid = mainGrid
175
+ let scrollback: ScreenCell[][] = []
176
+
177
+ // Cursor
178
+ let curX = 0
179
+ let curY = 0
180
+ let curVisible = true
181
+ let savedCurX = 0
182
+ let savedCurY = 0
183
+
184
+ // DECSC/DECRC saved state (cursor + attrs + modes)
185
+ interface SavedState {
186
+ curX: number
187
+ curY: number
188
+ attrs: Attrs
189
+ originMode: boolean
190
+ autoWrap: boolean
191
+ }
192
+ let savedState: SavedState = {
193
+ curX: 0,
194
+ curY: 0,
195
+ attrs: resetAttrs(),
196
+ originMode: false,
197
+ autoWrap: true,
198
+ }
199
+
200
+ // Current drawing attributes
201
+ let attrs: Attrs = resetAttrs()
202
+
203
+ // Terminal state
204
+ let title = ""
205
+ let useAltScreen = false
206
+ let bracketedPaste = false
207
+ let applicationCursor = false
208
+ let applicationKeypad = false
209
+ let autoWrap = true
210
+ let mouseTracking = false
211
+ let focusTracking = false
212
+ let originMode = false
213
+ let insertMode = false
214
+ let reverseVideo = false
215
+
216
+ // Scroll region (inclusive, 0-based)
217
+ let scrollTop = 0
218
+ let scrollBottom = rows - 1
219
+
220
+ // Viewport scroll offset for scrollViewport()
221
+ let viewportOffset = 0
222
+
223
+ // Parser state
224
+ let parserState: "ground" | "escape" | "csi" | "osc" | "dcs" | "oscString" = "ground"
225
+ let escBuf = ""
226
+ let oscBuf = ""
227
+
228
+ // Decoder for incoming bytes
229
+ const decoder = new TextDecoder()
230
+
231
+ function makeGrid(c: number, r: number): ScreenCell[][] {
232
+ const g: ScreenCell[][] = []
233
+ for (let row = 0; row < r; row++) {
234
+ g.push(makeRow(c))
235
+ }
236
+ return g
237
+ }
238
+
239
+ function makeRow(c: number): ScreenCell[] {
240
+ const row: ScreenCell[] = []
241
+ for (let col = 0; col < c; col++) {
242
+ row.push(EMPTY_CELL)
243
+ }
244
+ return row
245
+ }
246
+
247
+ function resetAttrs(): Attrs {
248
+ return {
249
+ fg: null,
250
+ bg: null,
251
+ bold: false,
252
+ faint: false,
253
+ italic: false,
254
+ underline: "none",
255
+ strikethrough: false,
256
+ inverse: false,
257
+ hidden: false,
258
+ }
259
+ }
260
+
261
+ function clampCursor(): void {
262
+ if (curX < 0) curX = 0
263
+ if (curX >= cols) curX = cols - 1
264
+ if (curY < 0) curY = 0
265
+ if (curY >= rows) curY = rows - 1
266
+ }
267
+
268
+ // ── Scrolling ──
269
+
270
+ function scrollUp(top: number, bottom: number): void {
271
+ // Move top row to scrollback (only if main screen & top of screen)
272
+ if (grid === mainGrid && top === 0) {
273
+ scrollback.push(grid[0]!)
274
+ // Bulk trim when exceeding 2x limit to avoid O(n) shift() on every scroll
275
+ if (scrollback.length > scrollbackLimit * 2) {
276
+ scrollback.splice(0, scrollback.length - scrollbackLimit)
277
+ }
278
+ }
279
+ // Shift rows up within the region
280
+ for (let i = top; i < bottom; i++) {
281
+ grid[i] = grid[i + 1]!
282
+ }
283
+ grid[bottom] = makeRow(cols)
284
+ }
285
+
286
+ function scrollDown(top: number, bottom: number): void {
287
+ for (let i = bottom; i > top; i--) {
288
+ grid[i] = grid[i - 1]!
289
+ }
290
+ grid[top] = makeRow(cols)
291
+ }
292
+
293
+ // ── Character writing ──
294
+
295
+ function writeChar(ch: string): void {
296
+ const codePoint = ch.codePointAt(0) ?? 0
297
+ const wide = isWide(codePoint)
298
+ const charWidth = wide ? 2 : 1
299
+
300
+ // Handle autowrap at end of line
301
+ if (curX + charWidth > cols) {
302
+ if (autoWrap) {
303
+ curX = 0
304
+ curY++
305
+ if (curY > scrollBottom) {
306
+ curY = scrollBottom
307
+ scrollUp(scrollTop, scrollBottom)
308
+ }
309
+ } else {
310
+ curX = cols - charWidth
311
+ }
312
+ }
313
+
314
+ // Insert mode: shift existing characters right before writing
315
+ if (insertMode) {
316
+ const row = grid[curY]!
317
+ for (let i = 0; i < charWidth; i++) {
318
+ row.splice(curX, 0, EMPTY_CELL)
319
+ row.pop()
320
+ }
321
+ }
322
+
323
+ // Copy-on-write: if cell is the shared EMPTY_CELL sentinel, create a fresh object
324
+ const row = grid[curY]!
325
+ let cell = row[curX]!
326
+ if (cell === EMPTY_CELL) {
327
+ cell = { ...EMPTY_CELL }
328
+ row[curX] = cell
329
+ }
330
+ cell.char = ch
331
+ cell.fg = attrs.fg ? { ...attrs.fg } : null
332
+ cell.bg = attrs.bg ? { ...attrs.bg } : null
333
+ cell.bold = attrs.bold
334
+ cell.faint = attrs.faint
335
+ cell.italic = attrs.italic
336
+ cell.underline = attrs.underline
337
+ cell.strikethrough = attrs.strikethrough
338
+ cell.inverse = attrs.inverse
339
+ cell.hidden = attrs.hidden
340
+ cell.wide = wide
341
+
342
+ if (wide && curX + 1 < cols) {
343
+ // Spacer cell for wide character — always create fresh
344
+ let spacer = row[curX + 1]!
345
+ if (spacer === EMPTY_CELL) {
346
+ spacer = { ...EMPTY_CELL }
347
+ row[curX + 1] = spacer
348
+ }
349
+ spacer.char = ""
350
+ spacer.fg = null
351
+ spacer.bg = null
352
+ spacer.bold = false
353
+ spacer.faint = false
354
+ spacer.italic = false
355
+ spacer.underline = "none"
356
+ spacer.strikethrough = false
357
+ spacer.inverse = false
358
+ spacer.hidden = false
359
+ spacer.wide = false
360
+ }
361
+
362
+ curX += charWidth
363
+ }
364
+
365
+ // ── CSI handler ──
366
+
367
+ function handleCSI(params: string, finalByte: string): void {
368
+ const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
369
+
370
+ switch (finalByte) {
371
+ case "A": // CUU - Cursor Up
372
+ curY -= Math.max(parts[0] ?? 1, 1)
373
+ clampCursor()
374
+ break
375
+ case "B": // CUD - Cursor Down
376
+ curY += Math.max(parts[0] ?? 1, 1)
377
+ clampCursor()
378
+ break
379
+ case "C": // CUF - Cursor Forward
380
+ curX += Math.max(parts[0] ?? 1, 1)
381
+ clampCursor()
382
+ break
383
+ case "D": // CUB - Cursor Back
384
+ curX -= Math.max(parts[0] ?? 1, 1)
385
+ clampCursor()
386
+ break
387
+ case "E": // CNL - Cursor Next Line
388
+ curY += Math.max(parts[0] ?? 1, 1)
389
+ curX = 0
390
+ clampCursor()
391
+ break
392
+ case "F": // CPL - Cursor Previous Line
393
+ curY -= Math.max(parts[0] ?? 1, 1)
394
+ curX = 0
395
+ clampCursor()
396
+ break
397
+ case "G": // CHA - Cursor Horizontal Absolute
398
+ curX = (parts[0] ?? 1) - 1
399
+ clampCursor()
400
+ break
401
+ case "H": // CUP - Cursor Position
402
+ case "f": // HVP - same as CUP
403
+ curY = (parts[0] ?? 1) - 1
404
+ curX = (parts[1] ?? 1) - 1
405
+ clampCursor()
406
+ break
407
+ case "J": // ED - Erase in Display
408
+ handleEraseDisplay(parts[0] ?? 0)
409
+ break
410
+ case "K": // EL - Erase in Line
411
+ handleEraseLine(parts[0] ?? 0)
412
+ break
413
+ case "L": // IL - Insert Lines
414
+ handleInsertLines(Math.max(parts[0] ?? 1, 1))
415
+ break
416
+ case "M": // DL - Delete Lines
417
+ handleDeleteLines(Math.max(parts[0] ?? 1, 1))
418
+ break
419
+ case "P": // DCH - Delete Characters
420
+ handleDeleteChars(Math.max(parts[0] ?? 1, 1))
421
+ break
422
+ case "@": // ICH - Insert Characters
423
+ handleInsertChars(Math.max(parts[0] ?? 1, 1))
424
+ break
425
+ case "X": // ECH - Erase Characters
426
+ handleEraseChars(Math.max(parts[0] ?? 1, 1))
427
+ break
428
+ case "S": // SU - Scroll Up
429
+ for (let i = 0; i < Math.max(parts[0] ?? 1, 1); i++) {
430
+ scrollUp(scrollTop, scrollBottom)
431
+ }
432
+ break
433
+ case "T": // SD - Scroll Down
434
+ for (let i = 0; i < Math.max(parts[0] ?? 1, 1); i++) {
435
+ scrollDown(scrollTop, scrollBottom)
436
+ }
437
+ break
438
+ case "d": // VPA - Line Position Absolute
439
+ curY = (parts[0] ?? 1) - 1
440
+ clampCursor()
441
+ break
442
+ case "m": // SGR - Select Graphic Rendition
443
+ handleSGR(params)
444
+ break
445
+ case "r": // DECSTBM - Set Scrolling Region
446
+ scrollTop = (parts[0] ?? 1) - 1
447
+ scrollBottom = (parts[1] ?? rows) - 1
448
+ if (scrollTop < 0) scrollTop = 0
449
+ if (scrollBottom >= rows) scrollBottom = rows - 1
450
+ if (scrollTop > scrollBottom) {
451
+ scrollTop = 0
452
+ scrollBottom = rows - 1
453
+ }
454
+ curX = 0
455
+ curY = originMode ? scrollTop : 0
456
+ break
457
+ case "n": // DSR - Device Status Report (ignore)
458
+ break
459
+ case "c": // DA - Device Attributes (ignore)
460
+ break
461
+ case "s": // SCP - Save Cursor Position
462
+ savedCurX = curX
463
+ savedCurY = curY
464
+ break
465
+ case "u": // RCP - Restore Cursor Position
466
+ curX = savedCurX
467
+ curY = savedCurY
468
+ clampCursor()
469
+ break
470
+ default:
471
+ // Unknown CSI sequence — ignore
472
+ break
473
+ }
474
+ }
475
+
476
+ function handleCSIPrivate(params: string, finalByte: string): void {
477
+ const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
478
+ const set = finalByte === "h"
479
+
480
+ for (const code of parts) {
481
+ switch (code) {
482
+ case 1: // DECCKM - Application Cursor
483
+ applicationCursor = set
484
+ break
485
+ case 6: // DECOM - Origin Mode
486
+ originMode = set
487
+ break
488
+ case 7: // DECAWM - Autowrap Mode
489
+ autoWrap = set
490
+ break
491
+ case 25: // DECTCEM - Cursor Visible
492
+ curVisible = set
493
+ break
494
+ case 47: // Alternate screen buffer (old)
495
+ case 1047: // Alternate screen buffer
496
+ if (set && !useAltScreen) {
497
+ useAltScreen = true
498
+ grid = altGrid
499
+ } else if (!set && useAltScreen) {
500
+ useAltScreen = false
501
+ grid = mainGrid
502
+ }
503
+ break
504
+ case 66: // DECNKM - Application Keypad
505
+ applicationKeypad = set
506
+ break
507
+ case 1000: // Mouse tracking (basic)
508
+ case 1002: // Mouse tracking (button events)
509
+ case 1003: // Mouse tracking (all events)
510
+ mouseTracking = set
511
+ break
512
+ case 1004: // Focus tracking
513
+ focusTracking = set
514
+ break
515
+ case 1049: // Alternate screen buffer + save/restore cursor
516
+ if (set && !useAltScreen) {
517
+ savedCurX = curX
518
+ savedCurY = curY
519
+ useAltScreen = true
520
+ altGrid = makeGrid(cols, rows)
521
+ grid = altGrid
522
+ curX = 0
523
+ curY = 0
524
+ } else if (!set && useAltScreen) {
525
+ useAltScreen = false
526
+ grid = mainGrid
527
+ curX = savedCurX
528
+ curY = savedCurY
529
+ clampCursor()
530
+ }
531
+ break
532
+ case 2004: // Bracketed paste
533
+ bracketedPaste = set
534
+ break
535
+ case 5: // DECSCNM - Reverse Video
536
+ reverseVideo = set
537
+ break
538
+ case 4: // IRM - Insert Mode (via DEC private)
539
+ insertMode = set
540
+ break
541
+ }
542
+ }
543
+ }
544
+
545
+ function handleEraseDisplay(mode: number): void {
546
+ switch (mode) {
547
+ case 0: // Erase from cursor to end
548
+ eraseCells(curY, curX, curY, cols - 1)
549
+ for (let row = curY + 1; row < rows; row++) {
550
+ eraseCells(row, 0, row, cols - 1)
551
+ }
552
+ break
553
+ case 1: // Erase from start to cursor
554
+ for (let row = 0; row < curY; row++) {
555
+ eraseCells(row, 0, row, cols - 1)
556
+ }
557
+ eraseCells(curY, 0, curY, curX)
558
+ break
559
+ case 2: // Erase entire display
560
+ case 3: // Erase entire display + scrollback
561
+ for (let row = 0; row < rows; row++) {
562
+ eraseCells(row, 0, row, cols - 1)
563
+ }
564
+ if (mode === 3) {
565
+ scrollback.length = 0
566
+ }
567
+ break
568
+ }
569
+ }
570
+
571
+ function handleEraseLine(mode: number): void {
572
+ switch (mode) {
573
+ case 0: // Erase from cursor to end of line
574
+ eraseCells(curY, curX, curY, cols - 1)
575
+ break
576
+ case 1: // Erase from start to cursor
577
+ eraseCells(curY, 0, curY, curX)
578
+ break
579
+ case 2: // Erase entire line
580
+ eraseCells(curY, 0, curY, cols - 1)
581
+ break
582
+ }
583
+ }
584
+
585
+ function eraseCells(row: number, startCol: number, _endRow: number, endCol: number): void {
586
+ const r = grid[row]
587
+ if (!r) return
588
+ for (let col = startCol; col <= endCol && col < cols; col++) {
589
+ r[col] = emptyCell()
590
+ }
591
+ }
592
+
593
+ function handleInsertLines(count: number): void {
594
+ if (curY < scrollTop || curY > scrollBottom) return
595
+ for (let i = 0; i < count; i++) {
596
+ scrollDown(curY, scrollBottom)
597
+ }
598
+ }
599
+
600
+ function handleDeleteLines(count: number): void {
601
+ if (curY < scrollTop || curY > scrollBottom) return
602
+ for (let i = 0; i < count; i++) {
603
+ scrollUp(curY, scrollBottom)
604
+ }
605
+ }
606
+
607
+ function handleDeleteChars(count: number): void {
608
+ const row = grid[curY]
609
+ if (!row) return
610
+ for (let i = 0; i < count; i++) {
611
+ if (curX < cols) {
612
+ row.splice(curX, 1)
613
+ row.push(emptyCell())
614
+ }
615
+ }
616
+ }
617
+
618
+ function handleInsertChars(count: number): void {
619
+ const row = grid[curY]
620
+ if (!row) return
621
+ for (let i = 0; i < count; i++) {
622
+ row.splice(curX, 0, emptyCell())
623
+ row.pop()
624
+ }
625
+ }
626
+
627
+ function handleEraseChars(count: number): void {
628
+ const row = grid[curY]
629
+ if (!row) return
630
+ for (let i = 0; i < count && curX + i < cols; i++) {
631
+ row[curX + i] = emptyCell()
632
+ }
633
+ }
634
+
635
+ // ── SGR (Select Graphic Rendition) ──
636
+
637
+ function handleSGR(rawParams: string): void {
638
+ // Parse SGR parameters, handling colon sub-parameters (e.g., "4:3" for curly underline)
639
+ const segments = rawParams.split(";")
640
+ const params: number[] = []
641
+ // Map from param index to colon sub-parameters (e.g., index of "4" -> [4, 3])
642
+ const subParams = new Map<number, number[]>()
643
+ for (const seg of segments) {
644
+ if (seg.includes(":")) {
645
+ const subs = seg.split(":").map((s) => (s === "" ? 0 : parseInt(s, 10)))
646
+ subParams.set(params.length, subs)
647
+ params.push(subs[0]!)
648
+ } else {
649
+ params.push(seg === "" ? 0 : parseInt(seg, 10))
650
+ }
651
+ }
652
+
653
+ if (params.length === 0 || (params.length === 1 && params[0] === 0)) {
654
+ attrs = resetAttrs()
655
+ return
656
+ }
657
+
658
+ let i = 0
659
+ while (i < params.length) {
660
+ const code = params[i]!
661
+ switch (code) {
662
+ case 0:
663
+ attrs = resetAttrs()
664
+ break
665
+ case 1:
666
+ attrs.bold = true
667
+ break
668
+ case 2:
669
+ attrs.faint = true
670
+ break
671
+ case 3:
672
+ attrs.italic = true
673
+ break
674
+ case 4: {
675
+ // SGR 4 with optional sub-parameter: 4:0=none, 4:1=single, 4:3=curly, etc.
676
+ const subs = subParams.get(i)
677
+ if (subs && subs.length > 1) {
678
+ const sub = subs[1]!
679
+ switch (sub) {
680
+ case 0:
681
+ attrs.underline = "none"
682
+ break
683
+ case 1:
684
+ attrs.underline = "single"
685
+ break
686
+ case 2:
687
+ attrs.underline = "double"
688
+ break
689
+ case 3:
690
+ attrs.underline = "curly"
691
+ break
692
+ case 4:
693
+ attrs.underline = "dotted"
694
+ break
695
+ case 5:
696
+ attrs.underline = "dashed"
697
+ break
698
+ default:
699
+ attrs.underline = "single"
700
+ break
701
+ }
702
+ } else {
703
+ attrs.underline = "single"
704
+ }
705
+ break
706
+ }
707
+ case 7:
708
+ attrs.inverse = true
709
+ break
710
+ case 8: // Hidden/conceal
711
+ attrs.hidden = true
712
+ break
713
+ case 9:
714
+ attrs.strikethrough = true
715
+ break
716
+ case 21: // Double underline
717
+ attrs.underline = "double"
718
+ break
719
+ case 22: // Normal intensity (neither bold nor faint)
720
+ attrs.bold = false
721
+ attrs.faint = false
722
+ break
723
+ case 23:
724
+ attrs.italic = false
725
+ break
726
+ case 24:
727
+ attrs.underline = "none"
728
+ break
729
+ case 27:
730
+ attrs.inverse = false
731
+ break
732
+ case 28: // Reveal (turn off hidden/conceal)
733
+ attrs.hidden = false
734
+ break
735
+ case 29:
736
+ attrs.strikethrough = false
737
+ break
738
+ // Foreground colors 30-37
739
+ case 30:
740
+ case 31:
741
+ case 32:
742
+ case 33:
743
+ case 34:
744
+ case 35:
745
+ case 36:
746
+ case 37:
747
+ attrs.fg = { ...PALETTE_256[code - 30]! }
748
+ break
749
+ case 38: {
750
+ // Extended foreground: 38;5;N (256) or 38;2;R;G;B (truecolor)
751
+ const result = parseExtendedColor(params, i)
752
+ if (result) {
753
+ attrs.fg = result.color
754
+ i = result.nextIndex - 1 // -1 because loop increments
755
+ }
756
+ break
757
+ }
758
+ case 39: // Default foreground
759
+ attrs.fg = null
760
+ break
761
+ // Background colors 40-47
762
+ case 40:
763
+ case 41:
764
+ case 42:
765
+ case 43:
766
+ case 44:
767
+ case 45:
768
+ case 46:
769
+ case 47:
770
+ attrs.bg = { ...PALETTE_256[code - 40]! }
771
+ break
772
+ case 48: {
773
+ // Extended background: 48;5;N (256) or 48;2;R;G;B (truecolor)
774
+ const result = parseExtendedColor(params, i)
775
+ if (result) {
776
+ attrs.bg = result.color
777
+ i = result.nextIndex - 1
778
+ }
779
+ break
780
+ }
781
+ case 49: // Default background
782
+ attrs.bg = null
783
+ break
784
+ // Bright foreground 90-97
785
+ case 90:
786
+ case 91:
787
+ case 92:
788
+ case 93:
789
+ case 94:
790
+ case 95:
791
+ case 96:
792
+ case 97:
793
+ attrs.fg = { ...PALETTE_256[code - 90 + 8]! }
794
+ break
795
+ // Bright background 100-107
796
+ case 100:
797
+ case 101:
798
+ case 102:
799
+ case 103:
800
+ case 104:
801
+ case 105:
802
+ case 106:
803
+ case 107:
804
+ attrs.bg = { ...PALETTE_256[code - 100 + 8]! }
805
+ break
806
+ }
807
+ i++
808
+ }
809
+ }
810
+
811
+ function parseExtendedColor(params: number[], startIndex: number): { color: CellColor; nextIndex: number } | null {
812
+ if (startIndex + 1 >= params.length) return null
813
+
814
+ const type = params[startIndex + 1]
815
+ if (type === 5 && startIndex + 2 < params.length) {
816
+ // 256-color: 38;5;N
817
+ const idx = params[startIndex + 2]!
818
+ const color = PALETTE_256[idx] ?? { r: 0, g: 0, b: 0 }
819
+ return { color: { ...color }, nextIndex: startIndex + 3 }
820
+ } else if (type === 2 && startIndex + 4 < params.length) {
821
+ // Truecolor: 38;2;R;G;B
822
+ return {
823
+ color: {
824
+ r: params[startIndex + 2]!,
825
+ g: params[startIndex + 3]!,
826
+ b: params[startIndex + 4]!,
827
+ },
828
+ nextIndex: startIndex + 5,
829
+ }
830
+ }
831
+ return null
832
+ }
833
+
834
+ // ── OSC handler ──
835
+
836
+ function handleOSC(oscString: string): void {
837
+ const semicolonIdx = oscString.indexOf(";")
838
+ if (semicolonIdx === -1) return
839
+
840
+ const code = parseInt(oscString.substring(0, semicolonIdx), 10)
841
+ const value = oscString.substring(semicolonIdx + 1)
842
+
843
+ switch (code) {
844
+ case 0: // Set icon name and window title
845
+ case 2: // Set window title
846
+ title = value
847
+ break
848
+ case 1: // Set icon name (ignore)
849
+ break
850
+ }
851
+ }
852
+
853
+ // ── Main parser ──
854
+
855
+ function process(data: Uint8Array): void {
856
+ const text = decoder.decode(data, { stream: true })
857
+
858
+ for (let i = 0; i < text.length; i++) {
859
+ const ch = text[i]!
860
+ const code = text.charCodeAt(i)
861
+
862
+ switch (parserState) {
863
+ case "ground":
864
+ if (code === 0x1b) {
865
+ parserState = "escape"
866
+ escBuf = ""
867
+ } else if (code === 0x07) {
868
+ // BEL — ignore
869
+ } else if (code === 0x08) {
870
+ // BS - Backspace
871
+ if (curX > 0) curX--
872
+ } else if (code === 0x09) {
873
+ // TAB
874
+ curX = Math.min((Math.floor(curX / 8) + 1) * 8, cols - 1)
875
+ } else if (code === 0x0a || code === 0x0b || code === 0x0c) {
876
+ // LF, VT, FF — linefeed
877
+ curY++
878
+ if (curY > scrollBottom) {
879
+ curY = scrollBottom
880
+ scrollUp(scrollTop, scrollBottom)
881
+ }
882
+ } else if (code === 0x0d) {
883
+ // CR - Carriage Return
884
+ curX = 0
885
+ } else if (code >= 0x20) {
886
+ // Handle surrogate pairs for characters > U+FFFF
887
+ let char = ch
888
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
889
+ const nextCode = text.charCodeAt(i + 1)
890
+ if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
891
+ char = ch + text[i + 1]!
892
+ i++
893
+ }
894
+ }
895
+ writeChar(char)
896
+ }
897
+ break
898
+
899
+ case "escape":
900
+ if (ch === "[") {
901
+ parserState = "csi"
902
+ escBuf = ""
903
+ } else if (ch === "]") {
904
+ parserState = "osc"
905
+ oscBuf = ""
906
+ } else if (ch === "P") {
907
+ parserState = "dcs"
908
+ escBuf = ""
909
+ } else if (ch === "c") {
910
+ // RIS - Reset to Initial State
911
+ fullReset()
912
+ } else if (ch === "D") {
913
+ // IND - Index (move cursor down, scroll if needed)
914
+ curY++
915
+ if (curY > scrollBottom) {
916
+ curY = scrollBottom
917
+ scrollUp(scrollTop, scrollBottom)
918
+ }
919
+ parserState = "ground"
920
+ } else if (ch === "M") {
921
+ // RI - Reverse Index (move cursor up, scroll if needed)
922
+ curY--
923
+ if (curY < scrollTop) {
924
+ curY = scrollTop
925
+ scrollDown(scrollTop, scrollBottom)
926
+ }
927
+ parserState = "ground"
928
+ } else if (ch === "7") {
929
+ // DECSC - Save Cursor + attributes + modes
930
+ savedState = {
931
+ curX,
932
+ curY,
933
+ attrs: { ...attrs, fg: attrs.fg ? { ...attrs.fg } : null, bg: attrs.bg ? { ...attrs.bg } : null },
934
+ originMode,
935
+ autoWrap,
936
+ }
937
+ parserState = "ground"
938
+ } else if (ch === "8") {
939
+ // DECRC - Restore Cursor + attributes + modes
940
+ curX = savedState.curX
941
+ curY = savedState.curY
942
+ attrs = {
943
+ ...savedState.attrs,
944
+ fg: savedState.attrs.fg ? { ...savedState.attrs.fg } : null,
945
+ bg: savedState.attrs.bg ? { ...savedState.attrs.bg } : null,
946
+ }
947
+ originMode = savedState.originMode
948
+ autoWrap = savedState.autoWrap
949
+ clampCursor()
950
+ parserState = "ground"
951
+ } else if (ch === "E") {
952
+ // NEL - Next Line
953
+ curX = 0
954
+ curY++
955
+ if (curY > scrollBottom) {
956
+ curY = scrollBottom
957
+ scrollUp(scrollTop, scrollBottom)
958
+ }
959
+ parserState = "ground"
960
+ } else {
961
+ // Unknown escape — return to ground
962
+ parserState = "ground"
963
+ }
964
+ break
965
+
966
+ case "csi":
967
+ if (code >= 0x40 && code <= 0x7e) {
968
+ // Final byte — dispatch CSI
969
+ if (escBuf.startsWith("?")) {
970
+ handleCSIPrivate(escBuf.substring(1), ch)
971
+ } else {
972
+ handleCSI(escBuf, ch)
973
+ }
974
+ parserState = "ground"
975
+ } else if (escBuf.length >= 256) {
976
+ // Buffer overflow — drop to ground to avoid unbounded accumulation
977
+ parserState = "ground"
978
+ } else {
979
+ // Parameter or intermediate byte
980
+ escBuf += ch
981
+ }
982
+ break
983
+
984
+ case "osc":
985
+ if (code === 0x07) {
986
+ // BEL terminates OSC
987
+ handleOSC(oscBuf)
988
+ parserState = "ground"
989
+ } else if (code === 0x1b) {
990
+ // ESC might be start of ST (\x1b\\)
991
+ parserState = "oscString"
992
+ } else if (oscBuf.length >= 4096) {
993
+ // Buffer overflow — drop to ground to avoid unbounded accumulation
994
+ parserState = "ground"
995
+ } else {
996
+ oscBuf += ch
997
+ }
998
+ break
999
+
1000
+ case "oscString":
1001
+ if (ch === "\\") {
1002
+ // ST (String Terminator) — end of OSC
1003
+ handleOSC(oscBuf)
1004
+ }
1005
+ // Either way, back to ground
1006
+ parserState = "ground"
1007
+ break
1008
+
1009
+ case "dcs":
1010
+ // Consume until ST
1011
+ if (code === 0x1b) {
1012
+ parserState = "oscString" // Reuse ST detection
1013
+ }
1014
+ break
1015
+ }
1016
+ }
1017
+ }
1018
+
1019
+ function fullReset(): void {
1020
+ mainGrid = makeGrid(cols, rows)
1021
+ altGrid = makeGrid(cols, rows)
1022
+ grid = mainGrid
1023
+ scrollback = []
1024
+ curX = 0
1025
+ curY = 0
1026
+ curVisible = true
1027
+ savedCurX = 0
1028
+ savedCurY = 0
1029
+ savedState = { curX: 0, curY: 0, attrs: resetAttrs(), originMode: false, autoWrap: true }
1030
+ attrs = resetAttrs()
1031
+ title = ""
1032
+ useAltScreen = false
1033
+ bracketedPaste = false
1034
+ applicationCursor = false
1035
+ applicationKeypad = false
1036
+ autoWrap = true
1037
+ mouseTracking = false
1038
+ focusTracking = false
1039
+ originMode = false
1040
+ insertMode = false
1041
+ reverseVideo = false
1042
+ scrollTop = 0
1043
+ scrollBottom = rows - 1
1044
+ viewportOffset = 0
1045
+ parserState = "ground"
1046
+ escBuf = ""
1047
+ oscBuf = ""
1048
+ }
1049
+
1050
+ function resize(newCols: number, newRows: number): void {
1051
+ const newMain = makeGrid(newCols, newRows)
1052
+ const newAlt = makeGrid(newCols, newRows)
1053
+
1054
+ // Copy content from old grids
1055
+ copyGrid(mainGrid, newMain, Math.min(cols, newCols), Math.min(rows, newRows))
1056
+ copyGrid(altGrid, newAlt, Math.min(cols, newCols), Math.min(rows, newRows))
1057
+
1058
+ mainGrid = newMain
1059
+ altGrid = newAlt
1060
+ grid = useAltScreen ? altGrid : mainGrid
1061
+ cols = newCols
1062
+ rows = newRows
1063
+ scrollTop = 0
1064
+ scrollBottom = rows - 1
1065
+ clampCursor()
1066
+ }
1067
+
1068
+ function copyGrid(src: ScreenCell[][], dst: ScreenCell[][], copyCols: number, copyRows: number): void {
1069
+ for (let row = 0; row < copyRows; row++) {
1070
+ for (let col = 0; col < copyCols; col++) {
1071
+ const srcCell = src[row]?.[col]
1072
+ if (srcCell) {
1073
+ dst[row]![col] = { ...srcCell }
1074
+ }
1075
+ }
1076
+ }
1077
+ }
1078
+
1079
+ function getCell(row: number, col: number): ScreenCell {
1080
+ const r = grid[row]
1081
+ if (!r || col >= cols) return emptyCell()
1082
+ return { ...r[col]! }
1083
+ }
1084
+
1085
+ function getLine(row: number): ScreenCell[] {
1086
+ const r = grid[row]
1087
+ if (!r) return makeRow(cols)
1088
+ return r.map((cell) => ({ ...cell }))
1089
+ }
1090
+
1091
+ function getText(): string {
1092
+ const lines: string[] = []
1093
+
1094
+ // Scrollback
1095
+ for (const row of scrollback) {
1096
+ lines.push(rowToString(row))
1097
+ }
1098
+
1099
+ // Screen
1100
+ for (let r = 0; r < rows; r++) {
1101
+ lines.push(rowToString(grid[r]!))
1102
+ }
1103
+
1104
+ return lines.join("\n")
1105
+ }
1106
+
1107
+ function rowToString(row: ScreenCell[]): string {
1108
+ let line = ""
1109
+ for (let i = 0; i < row.length; i++) {
1110
+ const cell = row[i]!
1111
+ if (cell.wide) {
1112
+ line += cell.char
1113
+ } else if (cell.char === "") {
1114
+ // Skip spacer cells after wide chars, otherwise treat as space
1115
+ if (i > 0 && row[i - 1]?.wide) {
1116
+ continue
1117
+ }
1118
+ line += " "
1119
+ } else {
1120
+ line += cell.char
1121
+ }
1122
+ }
1123
+ return line.replace(/\s+$/, "") // Trim trailing whitespace
1124
+ }
1125
+
1126
+ function getTextRange(startRow: number, startCol: number, endRow: number, endCol: number): string {
1127
+ const parts: string[] = []
1128
+
1129
+ for (let row = startRow; row <= endRow; row++) {
1130
+ const r = grid[row]
1131
+ if (!r) continue
1132
+
1133
+ const colStart = row === startRow ? startCol : 0
1134
+ const colEnd = row === endRow ? endCol : cols
1135
+
1136
+ let line = ""
1137
+ for (let col = colStart; col < colEnd; col++) {
1138
+ const cell = r[col]
1139
+ if (!cell) continue
1140
+ if (cell.char === "" && col > 0 && r[col - 1]?.wide) continue // Skip spacer
1141
+ line += cell.char || " "
1142
+ }
1143
+ parts.push(line.replace(/\s+$/, ""))
1144
+ }
1145
+
1146
+ return parts.join("\n")
1147
+ }
1148
+
1149
+ function getMode(mode: string): boolean {
1150
+ switch (mode) {
1151
+ case "altScreen":
1152
+ return useAltScreen
1153
+ case "cursorVisible":
1154
+ return curVisible
1155
+ case "bracketedPaste":
1156
+ return bracketedPaste
1157
+ case "applicationCursor":
1158
+ return applicationCursor
1159
+ case "applicationKeypad":
1160
+ return applicationKeypad
1161
+ case "autoWrap":
1162
+ return autoWrap
1163
+ case "mouseTracking":
1164
+ return mouseTracking
1165
+ case "focusTracking":
1166
+ return focusTracking
1167
+ case "originMode":
1168
+ return originMode
1169
+ case "insertMode":
1170
+ return insertMode
1171
+ case "reverseVideo":
1172
+ return reverseVideo
1173
+ default:
1174
+ return false
1175
+ }
1176
+ }
1177
+
1178
+ return {
1179
+ get cols() {
1180
+ return cols
1181
+ },
1182
+ get rows() {
1183
+ return rows
1184
+ },
1185
+ process,
1186
+ resize,
1187
+ reset: fullReset,
1188
+ getCell,
1189
+ getLine,
1190
+ getText,
1191
+ getTextRange,
1192
+ getCursorPosition: () => ({ x: curX, y: curY }),
1193
+ getCursorVisible: () => curVisible,
1194
+ getTitle: () => title,
1195
+ getMode,
1196
+ getScrollbackLength: () => scrollback.length,
1197
+ getViewportOffset: () => viewportOffset,
1198
+ scrollViewport: (delta: number) => {
1199
+ viewportOffset = Math.max(0, Math.min(scrollback.length, viewportOffset + delta))
1200
+ },
1201
+ }
1202
+ }