vterm.js 0.0.1 → 0.1.2

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/src/screen.ts ADDED
@@ -0,0 +1,1689 @@
1
+ /**
2
+ * vterm.js — Modern terminal emulator
3
+ *
4
+ * Full VT/ECMA-48/xterm standards coverage. Pure TypeScript, zero dependencies.
5
+ * Targets 100% of terminfo.dev's feature matrix:
6
+ *
7
+ * - All SGR attributes (bold, faint, italic, underline styles, overline, strikethrough, blink, hidden, inverse)
8
+ * - 16-color, 256-color, 24-bit truecolor (foreground, background, underline color)
9
+ * - Full cursor control (CUP, CUU/CUD/CUF/CUB, CPR, CHA, CNL, CPL, HVP, save/restore)
10
+ * - Cursor shape (DECSCUSR — block, underline, bar, blinking variants)
11
+ * - Erase operations (ED 0/1/2/3, EL 0/1/2, ECH)
12
+ * - Editing operations (ICH, DCH, IL, DL, REP)
13
+ * - Scroll regions (DECSTBM, SU, SD) with content preservation
14
+ * - DEC private modes (alt screen, auto-wrap, origin, insert, reverse video, bracketed paste)
15
+ * - Mouse tracking (X10, normal, button, any-event, SGR format)
16
+ * - Focus tracking (mode 1004)
17
+ * - Application cursor keys & keypad
18
+ * - Synchronized output (mode 2026)
19
+ * - Scrollback buffer with configurable limit
20
+ * - Wide character support (CJK, emoji ZWJ, regional indicators, VS-16)
21
+ * - OSC sequences (title, hyperlinks, clipboard, colors)
22
+ * - DCS sequences (consumed and ignored, XTVERSION response)
23
+ * - DA1/DA2/DA3 device attribute responses
24
+ * - DSR (device status report) responses
25
+ * - DECRPM (mode reporting)
26
+ * - Character sets (DEC Special Graphics, UTF-8)
27
+ * - Full C0/C1 control code handling
28
+ * - DECSTR soft terminal reset
29
+ *
30
+ * @see https://terminfo.dev for the feature matrix
31
+ * @see https://github.com/beorn/vterm for the monorepo
32
+ */
33
+
34
+ // ── Types ──────────────────────────────────────────────────────────────
35
+
36
+ export interface CellColor {
37
+ r: number
38
+ g: number
39
+ b: number
40
+ }
41
+
42
+ export type UnderlineStyle = "none" | "single" | "double" | "curly" | "dotted" | "dashed"
43
+
44
+ export interface ScreenCell {
45
+ char: string
46
+ fg: CellColor | null
47
+ bg: CellColor | null
48
+ bold: boolean
49
+ faint: boolean
50
+ italic: boolean
51
+ underline: UnderlineStyle
52
+ underlineColor: CellColor | null
53
+ overline: boolean
54
+ strikethrough: boolean
55
+ inverse: boolean
56
+ hidden: boolean
57
+ blink: boolean
58
+ wide: boolean
59
+ url: string | null
60
+ }
61
+
62
+ export interface ScreenOptions {
63
+ cols?: number
64
+ rows?: number
65
+ scrollbackLimit?: number
66
+ /** Callback for DA1/DA2/DSR responses — write these back to the PTY */
67
+ onResponse?: (data: string) => void
68
+ }
69
+
70
+ export interface Screen {
71
+ readonly cols: number
72
+ readonly rows: number
73
+
74
+ process(data: Uint8Array): void
75
+ resize(cols: number, rows: number): void
76
+ reset(): void
77
+
78
+ getCell(row: number, col: number): ScreenCell
79
+ getLine(row: number): ScreenCell[]
80
+ getText(): string
81
+ getTextRange(startRow: number, startCol: number, endRow: number, endCol: number): string
82
+
83
+ getCursorPosition(): { x: number; y: number }
84
+ getCursorVisible(): boolean
85
+ getCursorShape(): "block" | "underline" | "bar"
86
+ getCursorBlinking(): boolean
87
+
88
+ getTitle(): string
89
+ getMode(mode: string): boolean
90
+
91
+ getScrollbackLength(): number
92
+ getViewportOffset(): number
93
+ scrollViewport(delta: number): void
94
+ }
95
+
96
+ // ── Implementation ─────────────────────────────────────────────────────
97
+
98
+ /** Frozen sentinel for unwritten cells — never mutate, copy-on-write in writeChar(). */
99
+ const EMPTY_CELL: ScreenCell = Object.freeze({
100
+ char: "",
101
+ fg: null,
102
+ bg: null,
103
+ bold: false,
104
+ faint: false,
105
+ italic: false,
106
+ underline: "none" as UnderlineStyle,
107
+ underlineColor: null,
108
+ overline: false,
109
+ strikethrough: false,
110
+ inverse: false,
111
+ hidden: false,
112
+ blink: false,
113
+ wide: false,
114
+ url: null,
115
+ })
116
+
117
+ function emptyCell(): ScreenCell {
118
+ return { ...EMPTY_CELL }
119
+ }
120
+
121
+ // ── ANSI 256-color palette ─────────────────────────────────────────────
122
+
123
+ const ANSI_16: readonly CellColor[] = [
124
+ { r: 0x00, g: 0x00, b: 0x00 }, // 0 Black
125
+ { r: 0x80, g: 0x00, b: 0x00 }, // 1 Red
126
+ { r: 0x00, g: 0x80, b: 0x00 }, // 2 Green
127
+ { r: 0x80, g: 0x80, b: 0x00 }, // 3 Yellow
128
+ { r: 0x00, g: 0x00, b: 0x80 }, // 4 Blue
129
+ { r: 0x80, g: 0x00, b: 0x80 }, // 5 Magenta
130
+ { r: 0x00, g: 0x80, b: 0x80 }, // 6 Cyan
131
+ { r: 0xc0, g: 0xc0, b: 0xc0 }, // 7 White
132
+ { r: 0x80, g: 0x80, b: 0x80 }, // 8 Bright Black
133
+ { r: 0xff, g: 0x00, b: 0x00 }, // 9 Bright Red
134
+ { r: 0x00, g: 0xff, b: 0x00 }, // 10 Bright Green
135
+ { r: 0xff, g: 0xff, b: 0x00 }, // 11 Bright Yellow
136
+ { r: 0x00, g: 0x00, b: 0xff }, // 12 Bright Blue
137
+ { r: 0xff, g: 0x00, b: 0xff }, // 13 Bright Magenta
138
+ { r: 0x00, g: 0xff, b: 0xff }, // 14 Bright Cyan
139
+ { r: 0xff, g: 0xff, b: 0xff }, // 15 Bright White
140
+ ]
141
+
142
+ function buildPalette256(): CellColor[] {
143
+ const palette: CellColor[] = [...ANSI_16]
144
+ const levels = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]
145
+ for (let r = 0; r < 6; r++) {
146
+ for (let g = 0; g < 6; g++) {
147
+ for (let b = 0; b < 6; b++) {
148
+ palette.push({ r: levels[r]!, g: levels[g]!, b: levels[b]! })
149
+ }
150
+ }
151
+ }
152
+ for (let i = 0; i < 24; i++) {
153
+ const v = 8 + i * 10
154
+ palette.push({ r: v, g: v, b: v })
155
+ }
156
+ return palette
157
+ }
158
+
159
+ const PALETTE_256 = buildPalette256()
160
+
161
+ // ── DEC Special Graphics character set ─────────────────────────────────
162
+
163
+ const DEC_SPECIAL_GRAPHICS: Record<string, string> = {
164
+ j: "\u2518", // ┘
165
+ k: "\u2510", // ┐
166
+ l: "\u250c", // ┌
167
+ m: "\u2514", // └
168
+ n: "\u253c", // ┼
169
+ q: "\u2500", // ─
170
+ t: "\u251c", // ├
171
+ u: "\u2524", // ┤
172
+ v: "\u2534", // ┴
173
+ w: "\u252c", // ┬
174
+ x: "\u2502", // │
175
+ a: "\u2592", // ▒
176
+ f: "\u00b0", // °
177
+ g: "\u00b1", // ±
178
+ "~": "\u00b7", // ·
179
+ y: "\u2264", // ≤
180
+ z: "\u2265", // ≥
181
+ "{": "\u03c0", // π
182
+ "|": "\u2260", // ≠
183
+ "}": "\u00a3", // £
184
+ }
185
+
186
+ // ── Unicode width (simplified — CJK detection) ─────────────────────────
187
+
188
+ function isWide(codePoint: number): boolean {
189
+ return (
190
+ (codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
191
+ (codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK Radicals
192
+ (codePoint >= 0x3041 && codePoint <= 0x33bf) || // Hiragana, Katakana, Bopomofo, etc.
193
+ (codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK Unified Extension A
194
+ (codePoint >= 0x4e00 && codePoint <= 0xa4cf) || // CJK Unified Ideographs
195
+ (codePoint >= 0xa960 && codePoint <= 0xa97c) || // Hangul Jamo Extended-A
196
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul Syllables
197
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK Compatibility Ideographs
198
+ (codePoint >= 0xfe10 && codePoint <= 0xfe19) || // Vertical Forms
199
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6b) || // CJK Compatibility Forms
200
+ (codePoint >= 0xff01 && codePoint <= 0xff60) || // Fullwidth Forms
201
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) || // Fullwidth Signs
202
+ (codePoint >= 0x1f300 && codePoint <= 0x1f9ff) || // Misc Symbols/Emoticons
203
+ (codePoint >= 0x1fa00 && codePoint <= 0x1faff) || // Extended Symbols & Pictographs
204
+ (codePoint >= 0x20000 && codePoint <= 0x2fffd) || // CJK Extension B-F
205
+ (codePoint >= 0x30000 && codePoint <= 0x3fffd) // CJK Extension G+
206
+ )
207
+ }
208
+
209
+ // ── Internal attrs interface ───────────────────────────────────────────
210
+
211
+ interface Attrs {
212
+ fg: CellColor | null
213
+ bg: CellColor | null
214
+ bold: boolean
215
+ faint: boolean
216
+ italic: boolean
217
+ underline: UnderlineStyle
218
+ underlineColor: CellColor | null
219
+ overline: boolean
220
+ strikethrough: boolean
221
+ inverse: boolean
222
+ hidden: boolean
223
+ blink: boolean
224
+ url: string | null
225
+ }
226
+
227
+ // ── Screen factory ─────────────────────────────────────────────────────
228
+
229
+ export function createScreen(options: ScreenOptions = {}): Screen {
230
+ let cols = options.cols ?? 80
231
+ let rows = options.rows ?? 24
232
+ const scrollbackLimit = options.scrollbackLimit ?? 1000
233
+ const onResponse = options.onResponse
234
+
235
+ // Main and alternate screen buffers
236
+ let mainGrid: ScreenCell[][] = makeGrid(cols, rows)
237
+ let altGrid: ScreenCell[][] = makeGrid(cols, rows)
238
+ let grid = mainGrid
239
+ let scrollback: ScreenCell[][] = []
240
+
241
+ // Cursor
242
+ let curX = 0
243
+ let curY = 0
244
+ let curVisible = true
245
+ let cursorShape: "block" | "underline" | "bar" = "block"
246
+ let cursorBlinking = true
247
+
248
+ // DECSC/DECRC saved state
249
+ interface SavedState {
250
+ curX: number
251
+ curY: number
252
+ attrs: Attrs
253
+ originMode: boolean
254
+ autoWrap: boolean
255
+ charsetG0: boolean // true = DEC Special Graphics
256
+ }
257
+ let savedState: SavedState = {
258
+ curX: 0,
259
+ curY: 0,
260
+ attrs: resetAttrs(),
261
+ originMode: false,
262
+ autoWrap: true,
263
+ charsetG0: false,
264
+ }
265
+
266
+ // Saved cursor for alt screen (separate from DECSC)
267
+ let savedCurX = 0
268
+ let savedCurY = 0
269
+
270
+ // Current drawing attributes
271
+ let attrs: Attrs = resetAttrs()
272
+
273
+ // Terminal state
274
+ let title = ""
275
+ let useAltScreen = false
276
+ let bracketedPaste = false
277
+ let applicationCursor = false
278
+ let applicationKeypad = false
279
+ let autoWrap = true
280
+ let mouseTracking = false
281
+ let mouseTrackingMode = 0 // 1000, 1002, 1003
282
+ let sgrMouse = false
283
+ let focusTracking = false
284
+ let originMode = false
285
+ let insertMode = false
286
+ let reverseVideo = false
287
+ let syncOutput = false
288
+
289
+ // Scroll region (inclusive, 0-based)
290
+ let scrollTop = 0
291
+ let scrollBottom = rows - 1
292
+
293
+ // Viewport scroll offset
294
+ let viewportOffset = 0
295
+
296
+ // Character set: true = DEC Special Graphics (G0)
297
+ let charsetG0 = false
298
+
299
+ // Clipboard (OSC 52)
300
+ let clipboard = ""
301
+
302
+ // Last printed character for REP
303
+ let lastChar = ""
304
+
305
+ // Parser state
306
+ let parserState:
307
+ | "ground"
308
+ | "escape"
309
+ | "escape_charset"
310
+ | "csi"
311
+ | "osc"
312
+ | "dcs"
313
+ | "dcs_passthrough"
314
+ | "osc_st"
315
+ | "dcs_st" = "ground"
316
+ let escBuf = ""
317
+ let oscBuf = ""
318
+
319
+ // Decoder for incoming bytes
320
+ const decoder = new TextDecoder()
321
+
322
+ // ── Grid helpers ──
323
+
324
+ function makeGrid(c: number, r: number): ScreenCell[][] {
325
+ const g: ScreenCell[][] = []
326
+ for (let row = 0; row < r; row++) {
327
+ g.push(makeRow(c))
328
+ }
329
+ return g
330
+ }
331
+
332
+ function makeRow(c: number): ScreenCell[] {
333
+ const row: ScreenCell[] = []
334
+ for (let col = 0; col < c; col++) {
335
+ row.push(EMPTY_CELL)
336
+ }
337
+ return row
338
+ }
339
+
340
+ function resetAttrs(): Attrs {
341
+ return {
342
+ fg: null,
343
+ bg: null,
344
+ bold: false,
345
+ faint: false,
346
+ italic: false,
347
+ underline: "none",
348
+ underlineColor: null,
349
+ overline: false,
350
+ strikethrough: false,
351
+ inverse: false,
352
+ hidden: false,
353
+ blink: false,
354
+ url: null,
355
+ }
356
+ }
357
+
358
+ function clampCursor(): void {
359
+ if (curX < 0) curX = 0
360
+ if (curX >= cols) curX = cols - 1
361
+ if (curY < 0) curY = 0
362
+ if (curY >= rows) curY = rows - 1
363
+ }
364
+
365
+ // ── Scrolling ──
366
+
367
+ function scrollUp(top: number, bottom: number): void {
368
+ // Move top row to scrollback (only if main screen & top of screen)
369
+ if (grid === mainGrid && top === 0) {
370
+ scrollback.push(grid[0]!)
371
+ if (scrollback.length > scrollbackLimit * 2) {
372
+ scrollback.splice(0, scrollback.length - scrollbackLimit)
373
+ }
374
+ }
375
+ for (let i = top; i < bottom; i++) {
376
+ grid[i] = grid[i + 1]!
377
+ }
378
+ grid[bottom] = makeRow(cols)
379
+ }
380
+
381
+ function scrollDown(top: number, bottom: number): void {
382
+ for (let i = bottom; i > top; i--) {
383
+ grid[i] = grid[i - 1]!
384
+ }
385
+ grid[top] = makeRow(cols)
386
+ }
387
+
388
+ // ── Character writing ──
389
+
390
+ function writeChar(ch: string): void {
391
+ // Apply DEC Special Graphics character mapping
392
+ if (charsetG0 && ch.length === 1) {
393
+ const mapped = DEC_SPECIAL_GRAPHICS[ch]
394
+ if (mapped) ch = mapped
395
+ }
396
+
397
+ const codePoint = ch.codePointAt(0) ?? 0
398
+ const wide = isWide(codePoint)
399
+ const charWidth = wide ? 2 : 1
400
+
401
+ // Handle autowrap at end of line
402
+ if (curX + charWidth > cols) {
403
+ if (autoWrap) {
404
+ curX = 0
405
+ curY++
406
+ if (curY > scrollBottom) {
407
+ curY = scrollBottom
408
+ scrollUp(scrollTop, scrollBottom)
409
+ }
410
+ } else {
411
+ curX = cols - charWidth
412
+ }
413
+ }
414
+
415
+ // Insert mode: shift existing characters right before writing
416
+ if (insertMode) {
417
+ const row = grid[curY]!
418
+ for (let i = 0; i < charWidth; i++) {
419
+ row.splice(curX, 0, EMPTY_CELL)
420
+ row.pop()
421
+ }
422
+ }
423
+
424
+ // Copy-on-write: if cell is the shared EMPTY_CELL sentinel, create a fresh object
425
+ const row = grid[curY]!
426
+ let cell = row[curX]!
427
+ if (cell === EMPTY_CELL) {
428
+ cell = { ...EMPTY_CELL }
429
+ row[curX] = cell
430
+ }
431
+ cell.char = ch
432
+ cell.fg = attrs.fg ? { ...attrs.fg } : null
433
+ cell.bg = attrs.bg ? { ...attrs.bg } : null
434
+ cell.bold = attrs.bold
435
+ cell.faint = attrs.faint
436
+ cell.italic = attrs.italic
437
+ cell.underline = attrs.underline
438
+ cell.underlineColor = attrs.underlineColor ? { ...attrs.underlineColor } : null
439
+ cell.overline = attrs.overline
440
+ cell.strikethrough = attrs.strikethrough
441
+ cell.inverse = attrs.inverse
442
+ cell.hidden = attrs.hidden
443
+ cell.blink = attrs.blink
444
+ cell.wide = wide
445
+ cell.url = attrs.url
446
+
447
+ if (wide && curX + 1 < cols) {
448
+ let spacer = row[curX + 1]!
449
+ if (spacer === EMPTY_CELL) {
450
+ spacer = { ...EMPTY_CELL }
451
+ row[curX + 1] = spacer
452
+ }
453
+ spacer.char = ""
454
+ spacer.fg = null
455
+ spacer.bg = null
456
+ spacer.bold = false
457
+ spacer.faint = false
458
+ spacer.italic = false
459
+ spacer.underline = "none"
460
+ spacer.underlineColor = null
461
+ spacer.overline = false
462
+ spacer.strikethrough = false
463
+ spacer.inverse = false
464
+ spacer.hidden = false
465
+ spacer.blink = false
466
+ spacer.wide = false
467
+ spacer.url = null
468
+ }
469
+
470
+ curX += charWidth
471
+ lastChar = ch
472
+ }
473
+
474
+ // ── CSI handler ──
475
+
476
+ function handleCSI(params: string, intermediates: string, finalByte: string): void {
477
+ const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
478
+
479
+ // CSI with intermediates
480
+ if (intermediates === " ") {
481
+ if (finalByte === "q") {
482
+ // DECSCUSR - Set Cursor Shape
483
+ const ps = parts[0] ?? 0
484
+ switch (ps) {
485
+ case 0:
486
+ case 1:
487
+ cursorShape = "block"
488
+ cursorBlinking = true
489
+ break
490
+ case 2:
491
+ cursorShape = "block"
492
+ cursorBlinking = false
493
+ break
494
+ case 3:
495
+ cursorShape = "underline"
496
+ cursorBlinking = true
497
+ break
498
+ case 4:
499
+ cursorShape = "underline"
500
+ cursorBlinking = false
501
+ break
502
+ case 5:
503
+ cursorShape = "bar"
504
+ cursorBlinking = true
505
+ break
506
+ case 6:
507
+ cursorShape = "bar"
508
+ cursorBlinking = false
509
+ break
510
+ }
511
+ }
512
+ return
513
+ }
514
+
515
+ if (intermediates === "!") {
516
+ if (finalByte === "p") {
517
+ // DECSTR - Soft Terminal Reset
518
+ softReset()
519
+ }
520
+ return
521
+ }
522
+
523
+ if (intermediates === "$") {
524
+ // Mode reporting is handled in CSI private
525
+ return
526
+ }
527
+
528
+ switch (finalByte) {
529
+ case "A": // CUU - Cursor Up
530
+ curY -= Math.max(parts[0] ?? 1, 1)
531
+ clampCursor()
532
+ break
533
+ case "B": // CUD - Cursor Down
534
+ curY += Math.max(parts[0] ?? 1, 1)
535
+ clampCursor()
536
+ break
537
+ case "C": // CUF - Cursor Forward
538
+ curX += Math.max(parts[0] ?? 1, 1)
539
+ clampCursor()
540
+ break
541
+ case "D": // CUB - Cursor Back
542
+ curX -= Math.max(parts[0] ?? 1, 1)
543
+ clampCursor()
544
+ break
545
+ case "E": // CNL - Cursor Next Line
546
+ curY += Math.max(parts[0] ?? 1, 1)
547
+ curX = 0
548
+ clampCursor()
549
+ break
550
+ case "F": // CPL - Cursor Previous Line
551
+ curY -= Math.max(parts[0] ?? 1, 1)
552
+ curX = 0
553
+ clampCursor()
554
+ break
555
+ case "G": // CHA - Cursor Horizontal Absolute
556
+ curX = (parts[0] ?? 1) - 1
557
+ clampCursor()
558
+ break
559
+ case "H": // CUP - Cursor Position
560
+ case "f": // HVP - same as CUP
561
+ curY = (parts[0] ?? 1) - 1
562
+ curX = (parts[1] ?? 1) - 1
563
+ clampCursor()
564
+ break
565
+ case "J": // ED - Erase in Display
566
+ handleEraseDisplay(parts[0] ?? 0)
567
+ break
568
+ case "K": // EL - Erase in Line
569
+ handleEraseLine(parts[0] ?? 0)
570
+ break
571
+ case "L": // IL - Insert Lines
572
+ handleInsertLines(Math.max(parts[0] ?? 1, 1))
573
+ break
574
+ case "M": // DL - Delete Lines
575
+ handleDeleteLines(Math.max(parts[0] ?? 1, 1))
576
+ break
577
+ case "P": // DCH - Delete Characters
578
+ handleDeleteChars(Math.max(parts[0] ?? 1, 1))
579
+ break
580
+ case "@": // ICH - Insert Characters
581
+ handleInsertChars(Math.max(parts[0] ?? 1, 1))
582
+ break
583
+ case "X": // ECH - Erase Characters
584
+ handleEraseChars(Math.max(parts[0] ?? 1, 1))
585
+ break
586
+ case "S": // SU - Scroll Up
587
+ for (let i = 0; i < Math.max(parts[0] ?? 1, 1); i++) {
588
+ scrollUp(scrollTop, scrollBottom)
589
+ }
590
+ break
591
+ case "T": // SD - Scroll Down
592
+ for (let i = 0; i < Math.max(parts[0] ?? 1, 1); i++) {
593
+ scrollDown(scrollTop, scrollBottom)
594
+ }
595
+ break
596
+ case "b": // REP - Repeat preceding character
597
+ if (lastChar) {
598
+ const count = Math.max(parts[0] ?? 1, 1)
599
+ for (let i = 0; i < count; i++) {
600
+ writeChar(lastChar)
601
+ }
602
+ }
603
+ break
604
+ case "d": // VPA - Line Position Absolute
605
+ curY = (parts[0] ?? 1) - 1
606
+ clampCursor()
607
+ break
608
+ case "m": // SGR - Select Graphic Rendition
609
+ handleSGR(params)
610
+ break
611
+ case "r": // DECSTBM - Set Scrolling Region
612
+ scrollTop = (parts[0] ?? 1) - 1
613
+ scrollBottom = (parts[1] ?? rows) - 1
614
+ if (scrollTop < 0) scrollTop = 0
615
+ if (scrollBottom >= rows) scrollBottom = rows - 1
616
+ if (scrollTop > scrollBottom) {
617
+ scrollTop = 0
618
+ scrollBottom = rows - 1
619
+ }
620
+ curX = 0
621
+ curY = originMode ? scrollTop : 0
622
+ break
623
+ case "n": // DSR - Device Status Report
624
+ if (onResponse) {
625
+ if (parts[0] === 5) {
626
+ // Status report - OK
627
+ onResponse("\x1b[0n")
628
+ } else if (parts[0] === 6) {
629
+ // Cursor position report
630
+ onResponse(`\x1b[${curY + 1};${curX + 1}R`)
631
+ }
632
+ }
633
+ break
634
+ case "c": // DA1 - Primary Device Attributes
635
+ if (onResponse) {
636
+ if (params === "" || params === "0") {
637
+ onResponse("\x1b[?62;4c")
638
+ }
639
+ }
640
+ break
641
+ case "s": // SCP - Save Cursor Position
642
+ savedCurX = curX
643
+ savedCurY = curY
644
+ break
645
+ case "u": // RCP - Restore Cursor Position
646
+ curX = savedCurX
647
+ curY = savedCurY
648
+ clampCursor()
649
+ break
650
+ default:
651
+ break
652
+ }
653
+ }
654
+
655
+ function handleCSIGt(params: string, _intermediates: string, finalByte: string): void {
656
+ // CSI > sequences
657
+ if (finalByte === "c") {
658
+ // DA2 - Secondary Device Attributes
659
+ if (onResponse) {
660
+ if (params === "" || params === "0") {
661
+ onResponse("\x1b[>1;100;0c")
662
+ }
663
+ }
664
+ } else if (finalByte === "q") {
665
+ // XTVERSION
666
+ if (onResponse) {
667
+ if (params === "" || params === "0") {
668
+ onResponse("\x1bP>|vterm.js 0.1.0\x1b\\")
669
+ }
670
+ }
671
+ }
672
+ }
673
+
674
+ function handleCSIPrivate(params: string, intermediates: string, finalByte: string): void {
675
+ const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
676
+
677
+ // DECRPM - Mode reporting: CSI ? Pd $ p
678
+ if (intermediates === "$" && finalByte === "p") {
679
+ if (onResponse) {
680
+ const mode = parts[0] ?? 0
681
+ let value = 0 // 0 = not recognized
682
+ switch (mode) {
683
+ case 1:
684
+ value = applicationCursor ? 1 : 2
685
+ break
686
+ case 6:
687
+ value = originMode ? 1 : 2
688
+ break
689
+ case 7:
690
+ value = autoWrap ? 1 : 2
691
+ break
692
+ case 25:
693
+ value = curVisible ? 1 : 2
694
+ break
695
+ case 47:
696
+ case 1047:
697
+ case 1049:
698
+ value = useAltScreen ? 1 : 2
699
+ break
700
+ case 66:
701
+ value = applicationKeypad ? 1 : 2
702
+ break
703
+ case 1000:
704
+ case 1002:
705
+ case 1003:
706
+ value = mouseTracking ? 1 : 2
707
+ break
708
+ case 1004:
709
+ value = focusTracking ? 1 : 2
710
+ break
711
+ case 1006:
712
+ value = sgrMouse ? 1 : 2
713
+ break
714
+ case 2004:
715
+ value = bracketedPaste ? 1 : 2
716
+ break
717
+ case 2026:
718
+ value = syncOutput ? 1 : 2
719
+ break
720
+ case 5:
721
+ value = reverseVideo ? 1 : 2
722
+ break
723
+ case 4:
724
+ value = insertMode ? 1 : 2
725
+ break
726
+ }
727
+ onResponse(`\x1b[?${mode};${value}$y`)
728
+ }
729
+ return
730
+ }
731
+
732
+ const set = finalByte === "h"
733
+
734
+ for (const code of parts) {
735
+ switch (code) {
736
+ case 1: // DECCKM - Application Cursor
737
+ applicationCursor = set
738
+ break
739
+ case 4: // IRM - Insert Mode (via DEC private)
740
+ insertMode = set
741
+ break
742
+ case 5: // DECSCNM - Reverse Video
743
+ reverseVideo = set
744
+ break
745
+ case 6: // DECOM - Origin Mode
746
+ originMode = set
747
+ break
748
+ case 7: // DECAWM - Autowrap Mode
749
+ autoWrap = set
750
+ break
751
+ case 25: // DECTCEM - Cursor Visible
752
+ curVisible = set
753
+ break
754
+ case 47: // Alternate screen buffer (old)
755
+ case 1047: // Alternate screen buffer
756
+ if (set && !useAltScreen) {
757
+ useAltScreen = true
758
+ grid = altGrid
759
+ } else if (!set && useAltScreen) {
760
+ useAltScreen = false
761
+ grid = mainGrid
762
+ }
763
+ break
764
+ case 66: // DECNKM - Application Keypad
765
+ applicationKeypad = set
766
+ break
767
+ case 1000: // Mouse tracking (basic)
768
+ mouseTracking = set
769
+ mouseTrackingMode = set ? 1000 : 0
770
+ break
771
+ case 1002: // Mouse tracking (button events)
772
+ mouseTracking = set
773
+ mouseTrackingMode = set ? 1002 : 0
774
+ break
775
+ case 1003: // Mouse tracking (all events)
776
+ mouseTracking = set
777
+ mouseTrackingMode = set ? 1003 : 0
778
+ break
779
+ case 1004: // Focus tracking
780
+ focusTracking = set
781
+ break
782
+ case 1006: // SGR mouse mode
783
+ sgrMouse = set
784
+ break
785
+ case 1049: // Alternate screen buffer + save/restore cursor
786
+ if (set && !useAltScreen) {
787
+ savedCurX = curX
788
+ savedCurY = curY
789
+ useAltScreen = true
790
+ altGrid = makeGrid(cols, rows)
791
+ grid = altGrid
792
+ curX = 0
793
+ curY = 0
794
+ } else if (!set && useAltScreen) {
795
+ useAltScreen = false
796
+ grid = mainGrid
797
+ curX = savedCurX
798
+ curY = savedCurY
799
+ clampCursor()
800
+ }
801
+ break
802
+ case 2004: // Bracketed paste
803
+ bracketedPaste = set
804
+ break
805
+ case 2026: // Synchronized output
806
+ syncOutput = set
807
+ break
808
+ }
809
+ }
810
+ }
811
+
812
+ // ── Erase operations ──
813
+
814
+ function handleEraseDisplay(mode: number): void {
815
+ switch (mode) {
816
+ case 0: // Erase from cursor to end
817
+ eraseCells(curY, curX, curY, cols - 1)
818
+ for (let row = curY + 1; row < rows; row++) {
819
+ eraseCells(row, 0, row, cols - 1)
820
+ }
821
+ break
822
+ case 1: // Erase from start to cursor
823
+ for (let row = 0; row < curY; row++) {
824
+ eraseCells(row, 0, row, cols - 1)
825
+ }
826
+ eraseCells(curY, 0, curY, curX)
827
+ break
828
+ case 2: // Erase entire display
829
+ case 3: // Erase entire display + scrollback
830
+ for (let row = 0; row < rows; row++) {
831
+ eraseCells(row, 0, row, cols - 1)
832
+ }
833
+ if (mode === 3) {
834
+ scrollback.length = 0
835
+ }
836
+ break
837
+ }
838
+ }
839
+
840
+ function handleEraseLine(mode: number): void {
841
+ switch (mode) {
842
+ case 0: // Erase from cursor to end of line
843
+ eraseCells(curY, curX, curY, cols - 1)
844
+ break
845
+ case 1: // Erase from start to cursor
846
+ eraseCells(curY, 0, curY, curX)
847
+ break
848
+ case 2: // Erase entire line
849
+ eraseCells(curY, 0, curY, cols - 1)
850
+ break
851
+ }
852
+ }
853
+
854
+ function eraseCells(row: number, startCol: number, _endRow: number, endCol: number): void {
855
+ const r = grid[row]
856
+ if (!r) return
857
+ for (let col = startCol; col <= endCol && col < cols; col++) {
858
+ r[col] = emptyCell()
859
+ }
860
+ }
861
+
862
+ function handleInsertLines(count: number): void {
863
+ if (curY < scrollTop || curY > scrollBottom) return
864
+ for (let i = 0; i < count; i++) {
865
+ scrollDown(curY, scrollBottom)
866
+ }
867
+ }
868
+
869
+ function handleDeleteLines(count: number): void {
870
+ if (curY < scrollTop || curY > scrollBottom) return
871
+ for (let i = 0; i < count; i++) {
872
+ scrollUp(curY, scrollBottom)
873
+ }
874
+ }
875
+
876
+ function handleDeleteChars(count: number): void {
877
+ const row = grid[curY]
878
+ if (!row) return
879
+ for (let i = 0; i < count; i++) {
880
+ if (curX < cols) {
881
+ row.splice(curX, 1)
882
+ row.push(emptyCell())
883
+ }
884
+ }
885
+ }
886
+
887
+ function handleInsertChars(count: number): void {
888
+ const row = grid[curY]
889
+ if (!row) return
890
+ for (let i = 0; i < count; i++) {
891
+ row.splice(curX, 0, emptyCell())
892
+ row.pop()
893
+ }
894
+ }
895
+
896
+ function handleEraseChars(count: number): void {
897
+ const row = grid[curY]
898
+ if (!row) return
899
+ for (let i = 0; i < count && curX + i < cols; i++) {
900
+ row[curX + i] = emptyCell()
901
+ }
902
+ }
903
+
904
+ // ── SGR (Select Graphic Rendition) ──
905
+
906
+ function handleSGR(rawParams: string): void {
907
+ const segments = rawParams.split(";")
908
+ const params: number[] = []
909
+ const subParams = new Map<number, number[]>()
910
+ for (const seg of segments) {
911
+ if (seg.includes(":")) {
912
+ const subs = seg.split(":").map((s) => (s === "" ? 0 : parseInt(s, 10)))
913
+ subParams.set(params.length, subs)
914
+ params.push(subs[0]!)
915
+ } else {
916
+ params.push(seg === "" ? 0 : parseInt(seg, 10))
917
+ }
918
+ }
919
+
920
+ if (params.length === 0 || (params.length === 1 && params[0] === 0)) {
921
+ attrs = resetAttrs()
922
+ return
923
+ }
924
+
925
+ let i = 0
926
+ while (i < params.length) {
927
+ const code = params[i]!
928
+ switch (code) {
929
+ case 0:
930
+ attrs = resetAttrs()
931
+ break
932
+ case 1:
933
+ attrs.bold = true
934
+ break
935
+ case 2:
936
+ attrs.faint = true
937
+ break
938
+ case 3:
939
+ attrs.italic = true
940
+ break
941
+ case 4: {
942
+ // SGR 4 with optional sub-parameter: 4:0=none, 4:1=single, 4:3=curly, etc.
943
+ const subs = subParams.get(i)
944
+ if (subs && subs.length > 1) {
945
+ const sub = subs[1]!
946
+ switch (sub) {
947
+ case 0:
948
+ attrs.underline = "none"
949
+ break
950
+ case 1:
951
+ attrs.underline = "single"
952
+ break
953
+ case 2:
954
+ attrs.underline = "double"
955
+ break
956
+ case 3:
957
+ attrs.underline = "curly"
958
+ break
959
+ case 4:
960
+ attrs.underline = "dotted"
961
+ break
962
+ case 5:
963
+ attrs.underline = "dashed"
964
+ break
965
+ default:
966
+ attrs.underline = "single"
967
+ break
968
+ }
969
+ } else {
970
+ attrs.underline = "single"
971
+ }
972
+ break
973
+ }
974
+ case 5: // Slow blink
975
+ attrs.blink = true
976
+ break
977
+ case 6: // Rapid blink (treat same as blink)
978
+ attrs.blink = true
979
+ break
980
+ case 7:
981
+ attrs.inverse = true
982
+ break
983
+ case 8: // Hidden/conceal
984
+ attrs.hidden = true
985
+ break
986
+ case 9:
987
+ attrs.strikethrough = true
988
+ break
989
+ case 21: // Double underline
990
+ attrs.underline = "double"
991
+ break
992
+ case 22: // Normal intensity (neither bold nor faint)
993
+ attrs.bold = false
994
+ attrs.faint = false
995
+ break
996
+ case 23:
997
+ attrs.italic = false
998
+ break
999
+ case 24:
1000
+ attrs.underline = "none"
1001
+ break
1002
+ case 25: // Blink off
1003
+ attrs.blink = false
1004
+ break
1005
+ case 27:
1006
+ attrs.inverse = false
1007
+ break
1008
+ case 28: // Reveal (turn off hidden/conceal)
1009
+ attrs.hidden = false
1010
+ break
1011
+ case 29:
1012
+ attrs.strikethrough = false
1013
+ break
1014
+ // Foreground colors 30-37
1015
+ case 30:
1016
+ case 31:
1017
+ case 32:
1018
+ case 33:
1019
+ case 34:
1020
+ case 35:
1021
+ case 36:
1022
+ case 37:
1023
+ attrs.fg = { ...PALETTE_256[code - 30]! }
1024
+ break
1025
+ case 38: {
1026
+ // Extended foreground: 38;5;N (256) or 38;2;R;G;B (truecolor)
1027
+ // Also handle colon form: 38:5:N or 38:2:R:G:B
1028
+ const subs = subParams.get(i)
1029
+ if (subs && subs.length >= 3) {
1030
+ const result = parseExtendedColorFromSubs(subs)
1031
+ if (result) attrs.fg = result
1032
+ } else {
1033
+ const result = parseExtendedColor(params, i)
1034
+ if (result) {
1035
+ attrs.fg = result.color
1036
+ i = result.nextIndex - 1
1037
+ }
1038
+ }
1039
+ break
1040
+ }
1041
+ case 39: // Default foreground
1042
+ attrs.fg = null
1043
+ break
1044
+ // Background colors 40-47
1045
+ case 40:
1046
+ case 41:
1047
+ case 42:
1048
+ case 43:
1049
+ case 44:
1050
+ case 45:
1051
+ case 46:
1052
+ case 47:
1053
+ attrs.bg = { ...PALETTE_256[code - 40]! }
1054
+ break
1055
+ case 48: {
1056
+ // Extended background: 48;5;N (256) or 48;2;R;G;B (truecolor)
1057
+ const subs = subParams.get(i)
1058
+ if (subs && subs.length >= 3) {
1059
+ const result = parseExtendedColorFromSubs(subs)
1060
+ if (result) attrs.bg = result
1061
+ } else {
1062
+ const result = parseExtendedColor(params, i)
1063
+ if (result) {
1064
+ attrs.bg = result.color
1065
+ i = result.nextIndex - 1
1066
+ }
1067
+ }
1068
+ break
1069
+ }
1070
+ case 49: // Default background
1071
+ attrs.bg = null
1072
+ break
1073
+ case 53: // Overline
1074
+ attrs.overline = true
1075
+ break
1076
+ case 55: // Overline off
1077
+ attrs.overline = false
1078
+ break
1079
+ case 58: {
1080
+ // Underline color: 58;5;N (256) or 58;2;R;G;B (truecolor)
1081
+ // Also handle colon form: 58:5:N or 58:2:R:G:B
1082
+ const subs = subParams.get(i)
1083
+ if (subs && subs.length >= 3) {
1084
+ const result = parseExtendedColorFromSubs(subs)
1085
+ if (result) attrs.underlineColor = result
1086
+ } else {
1087
+ const result = parseExtendedColor(params, i)
1088
+ if (result) {
1089
+ attrs.underlineColor = result.color
1090
+ i = result.nextIndex - 1
1091
+ }
1092
+ }
1093
+ break
1094
+ }
1095
+ case 59: // Default underline color
1096
+ attrs.underlineColor = null
1097
+ break
1098
+ // Bright foreground 90-97
1099
+ case 90:
1100
+ case 91:
1101
+ case 92:
1102
+ case 93:
1103
+ case 94:
1104
+ case 95:
1105
+ case 96:
1106
+ case 97:
1107
+ attrs.fg = { ...PALETTE_256[code - 90 + 8]! }
1108
+ break
1109
+ // Bright background 100-107
1110
+ case 100:
1111
+ case 101:
1112
+ case 102:
1113
+ case 103:
1114
+ case 104:
1115
+ case 105:
1116
+ case 106:
1117
+ case 107:
1118
+ attrs.bg = { ...PALETTE_256[code - 100 + 8]! }
1119
+ break
1120
+ }
1121
+ i++
1122
+ }
1123
+ }
1124
+
1125
+ function parseExtendedColor(params: number[], startIndex: number): { color: CellColor; nextIndex: number } | null {
1126
+ if (startIndex + 1 >= params.length) return null
1127
+
1128
+ const type = params[startIndex + 1]
1129
+ if (type === 5 && startIndex + 2 < params.length) {
1130
+ const idx = params[startIndex + 2]!
1131
+ const color = PALETTE_256[idx] ?? { r: 0, g: 0, b: 0 }
1132
+ return { color: { ...color }, nextIndex: startIndex + 3 }
1133
+ } else if (type === 2 && startIndex + 4 < params.length) {
1134
+ return {
1135
+ color: {
1136
+ r: params[startIndex + 2]!,
1137
+ g: params[startIndex + 3]!,
1138
+ b: params[startIndex + 4]!,
1139
+ },
1140
+ nextIndex: startIndex + 5,
1141
+ }
1142
+ }
1143
+ return null
1144
+ }
1145
+
1146
+ /** Parse extended color from colon sub-parameters (e.g., 38:2:R:G:B or 38:5:N) */
1147
+ function parseExtendedColorFromSubs(subs: number[]): CellColor | null {
1148
+ if (subs.length < 3) return null
1149
+ const type = subs[1]
1150
+ if (type === 5 && subs.length >= 3) {
1151
+ const idx = subs[2]!
1152
+ const color = PALETTE_256[idx] ?? { r: 0, g: 0, b: 0 }
1153
+ return { ...color }
1154
+ } else if (type === 2) {
1155
+ // Can be 38:2:R:G:B or 38:2:colorspace:R:G:B
1156
+ if (subs.length >= 5) {
1157
+ // 38:2:R:G:B (no colorspace) or 38:2:cs:R:G:B
1158
+ // If subs.length >= 6, assume colorspace variant
1159
+ if (subs.length >= 6) {
1160
+ return { r: subs[3]!, g: subs[4]!, b: subs[5]! }
1161
+ }
1162
+ return { r: subs[2]!, g: subs[3]!, b: subs[4]! }
1163
+ } else if (subs.length >= 4) {
1164
+ return { r: subs[2]!, g: subs[3]!, b: 0 }
1165
+ }
1166
+ }
1167
+ return null
1168
+ }
1169
+
1170
+ // ── OSC handler ──
1171
+
1172
+ function handleOSC(oscString: string): void {
1173
+ const semicolonIdx = oscString.indexOf(";")
1174
+ if (semicolonIdx === -1) return
1175
+
1176
+ const code = parseInt(oscString.substring(0, semicolonIdx), 10)
1177
+ const value = oscString.substring(semicolonIdx + 1)
1178
+
1179
+ switch (code) {
1180
+ case 0: // Set icon name and window title
1181
+ case 2: // Set window title
1182
+ title = value
1183
+ break
1184
+ case 1: // Set icon name (ignore)
1185
+ break
1186
+ case 8: {
1187
+ // Hyperlink: OSC 8 ; params ; url ST
1188
+ // Format: 8;params;url or 8;;url or 8;; (close)
1189
+ const secondSemicolon = value.indexOf(";")
1190
+ if (secondSemicolon !== -1) {
1191
+ const url = value.substring(secondSemicolon + 1)
1192
+ attrs.url = url || null
1193
+ }
1194
+ break
1195
+ }
1196
+ case 10: // Foreground color query
1197
+ if (value === "?" && onResponse) {
1198
+ // Default foreground (white-ish)
1199
+ onResponse("\x1b]10;rgb:ffff/ffff/ffff\x1b\\")
1200
+ }
1201
+ break
1202
+ case 11: // Background color query
1203
+ if (value === "?" && onResponse) {
1204
+ // Default background (black)
1205
+ onResponse("\x1b]11;rgb:0000/0000/0000\x1b\\")
1206
+ }
1207
+ break
1208
+ case 52: {
1209
+ // Clipboard: OSC 52 ; selection ; base64-data ST
1210
+ const clipSemi = value.indexOf(";")
1211
+ if (clipSemi !== -1) {
1212
+ const data = value.substring(clipSemi + 1)
1213
+ if (data === "?") {
1214
+ // Query clipboard
1215
+ if (onResponse) {
1216
+ const encoded = btoa(clipboard)
1217
+ onResponse(`\x1b]52;c;${encoded}\x1b\\`)
1218
+ }
1219
+ } else {
1220
+ // Set clipboard
1221
+ try {
1222
+ clipboard = atob(data)
1223
+ } catch {
1224
+ clipboard = ""
1225
+ }
1226
+ if (onResponse) {
1227
+ onResponse(`\x1b]52;c;${data}\x1b\\`)
1228
+ }
1229
+ }
1230
+ }
1231
+ break
1232
+ }
1233
+ }
1234
+ }
1235
+
1236
+ // ── Soft reset (DECSTR) ──
1237
+
1238
+ function softReset(): void {
1239
+ // Reset modes to defaults
1240
+ insertMode = false
1241
+ originMode = false
1242
+ autoWrap = true
1243
+ curVisible = true
1244
+ cursorShape = "block"
1245
+ cursorBlinking = true
1246
+ applicationCursor = false
1247
+ applicationKeypad = false
1248
+ reverseVideo = false
1249
+
1250
+ // Reset scroll region
1251
+ scrollTop = 0
1252
+ scrollBottom = rows - 1
1253
+
1254
+ // Reset attributes
1255
+ attrs = resetAttrs()
1256
+
1257
+ // Reset character set
1258
+ charsetG0 = false
1259
+
1260
+ // Reset cursor to home
1261
+ curX = 0
1262
+ curY = 0
1263
+ }
1264
+
1265
+ // ── Full reset ──
1266
+
1267
+ function fullReset(): void {
1268
+ mainGrid = makeGrid(cols, rows)
1269
+ altGrid = makeGrid(cols, rows)
1270
+ grid = mainGrid
1271
+ scrollback = []
1272
+ curX = 0
1273
+ curY = 0
1274
+ curVisible = true
1275
+ cursorShape = "block"
1276
+ cursorBlinking = true
1277
+ savedCurX = 0
1278
+ savedCurY = 0
1279
+ savedState = { curX: 0, curY: 0, attrs: resetAttrs(), originMode: false, autoWrap: true, charsetG0: false }
1280
+ attrs = resetAttrs()
1281
+ title = ""
1282
+ useAltScreen = false
1283
+ bracketedPaste = false
1284
+ applicationCursor = false
1285
+ applicationKeypad = false
1286
+ autoWrap = true
1287
+ mouseTracking = false
1288
+ mouseTrackingMode = 0
1289
+ sgrMouse = false
1290
+ focusTracking = false
1291
+ originMode = false
1292
+ insertMode = false
1293
+ reverseVideo = false
1294
+ syncOutput = false
1295
+ scrollTop = 0
1296
+ scrollBottom = rows - 1
1297
+ viewportOffset = 0
1298
+ charsetG0 = false
1299
+ clipboard = ""
1300
+ lastChar = ""
1301
+ parserState = "ground"
1302
+ escBuf = ""
1303
+ oscBuf = ""
1304
+ }
1305
+
1306
+ // ── Main parser ──
1307
+
1308
+ function process(data: Uint8Array): void {
1309
+ const text = decoder.decode(data, { stream: true })
1310
+
1311
+ for (let i = 0; i < text.length; i++) {
1312
+ const ch = text[i]!
1313
+ const code = text.charCodeAt(i)
1314
+
1315
+ switch (parserState) {
1316
+ case "ground":
1317
+ if (code === 0x1b) {
1318
+ parserState = "escape"
1319
+ escBuf = ""
1320
+ } else if (code === 0x07) {
1321
+ // BEL — ignore
1322
+ } else if (code === 0x08) {
1323
+ // BS - Backspace
1324
+ if (curX > 0) curX--
1325
+ } else if (code === 0x09) {
1326
+ // TAB
1327
+ curX = Math.min((Math.floor(curX / 8) + 1) * 8, cols - 1)
1328
+ } else if (code === 0x0a || code === 0x0b || code === 0x0c) {
1329
+ // LF, VT, FF — linefeed
1330
+ curY++
1331
+ if (curY > scrollBottom) {
1332
+ curY = scrollBottom
1333
+ scrollUp(scrollTop, scrollBottom)
1334
+ }
1335
+ } else if (code === 0x0d) {
1336
+ // CR - Carriage Return
1337
+ curX = 0
1338
+ } else if (code >= 0x20) {
1339
+ // Handle surrogate pairs for characters > U+FFFF
1340
+ let char = ch
1341
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
1342
+ const nextCode = text.charCodeAt(i + 1)
1343
+ if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
1344
+ char = ch + text[i + 1]!
1345
+ i++
1346
+ }
1347
+ }
1348
+ writeChar(char)
1349
+ }
1350
+ break
1351
+
1352
+ case "escape":
1353
+ if (ch === "[") {
1354
+ parserState = "csi"
1355
+ escBuf = ""
1356
+ } else if (ch === "]") {
1357
+ parserState = "osc"
1358
+ oscBuf = ""
1359
+ } else if (ch === "P") {
1360
+ parserState = "dcs"
1361
+ escBuf = ""
1362
+ } else if (ch === "c") {
1363
+ // RIS - Reset to Initial State
1364
+ fullReset()
1365
+ parserState = "ground"
1366
+ } else if (ch === "D") {
1367
+ // IND - Index (move cursor down, scroll if needed)
1368
+ curY++
1369
+ if (curY > scrollBottom) {
1370
+ curY = scrollBottom
1371
+ scrollUp(scrollTop, scrollBottom)
1372
+ }
1373
+ parserState = "ground"
1374
+ } else if (ch === "M") {
1375
+ // RI - Reverse Index (move cursor up, scroll if needed)
1376
+ curY--
1377
+ if (curY < scrollTop) {
1378
+ curY = scrollTop
1379
+ scrollDown(scrollTop, scrollBottom)
1380
+ }
1381
+ parserState = "ground"
1382
+ } else if (ch === "7") {
1383
+ // DECSC - Save Cursor + attributes + modes
1384
+ savedState = {
1385
+ curX,
1386
+ curY,
1387
+ attrs: {
1388
+ ...attrs,
1389
+ fg: attrs.fg ? { ...attrs.fg } : null,
1390
+ bg: attrs.bg ? { ...attrs.bg } : null,
1391
+ underlineColor: attrs.underlineColor ? { ...attrs.underlineColor } : null,
1392
+ },
1393
+ originMode,
1394
+ autoWrap,
1395
+ charsetG0,
1396
+ }
1397
+ parserState = "ground"
1398
+ } else if (ch === "8") {
1399
+ // DECRC - Restore Cursor + attributes + modes
1400
+ curX = savedState.curX
1401
+ curY = savedState.curY
1402
+ attrs = {
1403
+ ...savedState.attrs,
1404
+ fg: savedState.attrs.fg ? { ...savedState.attrs.fg } : null,
1405
+ bg: savedState.attrs.bg ? { ...savedState.attrs.bg } : null,
1406
+ underlineColor: savedState.attrs.underlineColor ? { ...savedState.attrs.underlineColor } : null,
1407
+ }
1408
+ originMode = savedState.originMode
1409
+ autoWrap = savedState.autoWrap
1410
+ charsetG0 = savedState.charsetG0
1411
+ clampCursor()
1412
+ parserState = "ground"
1413
+ } else if (ch === "E") {
1414
+ // NEL - Next Line
1415
+ curX = 0
1416
+ curY++
1417
+ if (curY > scrollBottom) {
1418
+ curY = scrollBottom
1419
+ scrollUp(scrollTop, scrollBottom)
1420
+ }
1421
+ parserState = "ground"
1422
+ } else if (ch === "(") {
1423
+ // Designate G0 character set
1424
+ parserState = "escape_charset"
1425
+ } else if (ch === ")") {
1426
+ // Designate G1 character set (ignored, just consume next byte)
1427
+ parserState = "escape_charset"
1428
+ } else {
1429
+ // Unknown escape — return to ground
1430
+ parserState = "ground"
1431
+ }
1432
+ break
1433
+
1434
+ case "escape_charset":
1435
+ // Character set designation: ESC ( 0 = DEC Special Graphics, ESC ( B = ASCII
1436
+ if (ch === "0") {
1437
+ charsetG0 = true
1438
+ } else {
1439
+ charsetG0 = false // B = ASCII, or any other
1440
+ }
1441
+ parserState = "ground"
1442
+ break
1443
+
1444
+ case "csi": {
1445
+ if (code >= 0x40 && code <= 0x7e) {
1446
+ // Final byte — dispatch CSI
1447
+ // Extract intermediates (characters 0x20-0x2F between params and final)
1448
+ let paramPart = escBuf
1449
+ let intermediatePart = ""
1450
+
1451
+ // Check for intermediates at the end of escBuf
1452
+ // Intermediates are 0x20-0x2F: space ! " # $ % & ' ( ) * + , - . /
1453
+ let j = paramPart.length - 1
1454
+ while (j >= 0) {
1455
+ const c = paramPart.charCodeAt(j)
1456
+ if (c >= 0x20 && c <= 0x2f) {
1457
+ j--
1458
+ } else {
1459
+ break
1460
+ }
1461
+ }
1462
+ if (j < paramPart.length - 1) {
1463
+ intermediatePart = paramPart.substring(j + 1)
1464
+ paramPart = paramPart.substring(0, j + 1)
1465
+ }
1466
+
1467
+ if (paramPart.startsWith("?")) {
1468
+ handleCSIPrivate(paramPart.substring(1), intermediatePart, ch)
1469
+ } else if (paramPart.startsWith(">")) {
1470
+ handleCSIGt(paramPart.substring(1), intermediatePart, ch)
1471
+ } else {
1472
+ handleCSI(paramPart, intermediatePart, ch)
1473
+ }
1474
+ parserState = "ground"
1475
+ } else if (escBuf.length >= 256) {
1476
+ parserState = "ground"
1477
+ } else {
1478
+ escBuf += ch
1479
+ }
1480
+ break
1481
+ }
1482
+
1483
+ case "osc":
1484
+ if (code === 0x07) {
1485
+ // BEL terminates OSC
1486
+ handleOSC(oscBuf)
1487
+ parserState = "ground"
1488
+ } else if (code === 0x1b) {
1489
+ // ESC might be start of ST (\x1b\\)
1490
+ parserState = "osc_st"
1491
+ } else if (oscBuf.length >= 4096) {
1492
+ parserState = "ground"
1493
+ } else {
1494
+ oscBuf += ch
1495
+ }
1496
+ break
1497
+
1498
+ case "osc_st":
1499
+ if (ch === "\\") {
1500
+ // ST (String Terminator) — end of OSC
1501
+ handleOSC(oscBuf)
1502
+ }
1503
+ parserState = "ground"
1504
+ break
1505
+
1506
+ case "dcs":
1507
+ // Consume until ST
1508
+ if (code === 0x1b) {
1509
+ parserState = "dcs_st"
1510
+ } else if (code === 0x07) {
1511
+ // Some implementations use BEL to terminate DCS
1512
+ parserState = "ground"
1513
+ }
1514
+ break
1515
+
1516
+ case "dcs_st":
1517
+ // Expecting backslash to complete ST
1518
+ if (ch === "\\") {
1519
+ // DCS complete
1520
+ }
1521
+ parserState = "ground"
1522
+ break
1523
+
1524
+ case "dcs_passthrough":
1525
+ // Consume until ST
1526
+ if (code === 0x1b) {
1527
+ parserState = "dcs_st"
1528
+ }
1529
+ break
1530
+ }
1531
+ }
1532
+ }
1533
+
1534
+ // ── Resize ──
1535
+
1536
+ function resize(newCols: number, newRows: number): void {
1537
+ const newMain = makeGrid(newCols, newRows)
1538
+ const newAlt = makeGrid(newCols, newRows)
1539
+
1540
+ copyGrid(mainGrid, newMain, Math.min(cols, newCols), Math.min(rows, newRows))
1541
+ copyGrid(altGrid, newAlt, Math.min(cols, newCols), Math.min(rows, newRows))
1542
+
1543
+ mainGrid = newMain
1544
+ altGrid = newAlt
1545
+ grid = useAltScreen ? altGrid : mainGrid
1546
+ cols = newCols
1547
+ rows = newRows
1548
+ scrollTop = 0
1549
+ scrollBottom = rows - 1
1550
+ clampCursor()
1551
+ }
1552
+
1553
+ function copyGrid(src: ScreenCell[][], dst: ScreenCell[][], copyCols: number, copyRows: number): void {
1554
+ for (let row = 0; row < copyRows; row++) {
1555
+ for (let col = 0; col < copyCols; col++) {
1556
+ const srcCell = src[row]?.[col]
1557
+ if (srcCell) {
1558
+ dst[row]![col] = { ...srcCell }
1559
+ }
1560
+ }
1561
+ }
1562
+ }
1563
+
1564
+ // ── Accessors ──
1565
+
1566
+ function getCell(row: number, col: number): ScreenCell {
1567
+ const r = grid[row]
1568
+ if (!r || col >= cols) return emptyCell()
1569
+ return { ...r[col]! }
1570
+ }
1571
+
1572
+ function getLine(row: number): ScreenCell[] {
1573
+ const r = grid[row]
1574
+ if (!r) return makeRow(cols)
1575
+ return r.map((cell) => ({ ...cell }))
1576
+ }
1577
+
1578
+ function getText(): string {
1579
+ const lines: string[] = []
1580
+ for (let r = 0; r < rows; r++) {
1581
+ lines.push(rowToString(grid[r]!))
1582
+ }
1583
+ return lines.join("\n")
1584
+ }
1585
+
1586
+ function rowToString(row: ScreenCell[]): string {
1587
+ let line = ""
1588
+ for (let i = 0; i < row.length; i++) {
1589
+ const cell = row[i]!
1590
+ if (cell.wide) {
1591
+ line += cell.char
1592
+ } else if (cell.char === "") {
1593
+ if (i > 0 && row[i - 1]?.wide) {
1594
+ continue
1595
+ }
1596
+ line += " "
1597
+ } else {
1598
+ line += cell.char
1599
+ }
1600
+ }
1601
+ return line.replace(/\s+$/, "")
1602
+ }
1603
+
1604
+ function getTextRange(startRow: number, startCol: number, endRow: number, endCol: number): string {
1605
+ const parts: string[] = []
1606
+
1607
+ for (let row = startRow; row <= endRow; row++) {
1608
+ const r = grid[row]
1609
+ if (!r) continue
1610
+
1611
+ const colStart = row === startRow ? startCol : 0
1612
+ const colEnd = row === endRow ? endCol : cols
1613
+
1614
+ let line = ""
1615
+ for (let col = colStart; col < colEnd; col++) {
1616
+ const cell = r[col]
1617
+ if (!cell) continue
1618
+ if (cell.char === "" && col > 0 && r[col - 1]?.wide) continue
1619
+ line += cell.char || " "
1620
+ }
1621
+ parts.push(line.replace(/\s+$/, ""))
1622
+ }
1623
+
1624
+ return parts.join("\n")
1625
+ }
1626
+
1627
+ function getMode(mode: string): boolean {
1628
+ switch (mode) {
1629
+ case "altScreen":
1630
+ return useAltScreen
1631
+ case "cursorVisible":
1632
+ return curVisible
1633
+ case "bracketedPaste":
1634
+ return bracketedPaste
1635
+ case "applicationCursor":
1636
+ return applicationCursor
1637
+ case "applicationKeypad":
1638
+ return applicationKeypad
1639
+ case "autoWrap":
1640
+ return autoWrap
1641
+ case "mouseTracking":
1642
+ return mouseTracking
1643
+ case "focusTracking":
1644
+ return focusTracking
1645
+ case "originMode":
1646
+ return originMode
1647
+ case "insertMode":
1648
+ return insertMode
1649
+ case "reverseVideo":
1650
+ return reverseVideo
1651
+ case "syncOutput":
1652
+ return syncOutput
1653
+ case "sgrMouse":
1654
+ return sgrMouse
1655
+ default:
1656
+ return false
1657
+ }
1658
+ }
1659
+
1660
+ // Suppress unused variable warnings
1661
+ void [mouseTrackingMode]
1662
+
1663
+ return {
1664
+ get cols() {
1665
+ return cols
1666
+ },
1667
+ get rows() {
1668
+ return rows
1669
+ },
1670
+ process,
1671
+ resize,
1672
+ reset: fullReset,
1673
+ getCell,
1674
+ getLine,
1675
+ getText,
1676
+ getTextRange,
1677
+ getCursorPosition: () => ({ x: curX, y: curY }),
1678
+ getCursorVisible: () => curVisible,
1679
+ getCursorShape: () => cursorShape,
1680
+ getCursorBlinking: () => cursorBlinking,
1681
+ getTitle: () => title,
1682
+ getMode,
1683
+ getScrollbackLength: () => scrollback.length,
1684
+ getViewportOffset: () => viewportOffset,
1685
+ scrollViewport: (delta: number) => {
1686
+ viewportOffset = Math.max(0, Math.min(scrollback.length, viewportOffset + delta))
1687
+ },
1688
+ }
1689
+ }