vt220.js 0.0.1 → 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.
Files changed (3) hide show
  1. package/package.json +29 -5
  2. package/src/index.ts +7 -0
  3. package/src/screen.ts +1037 -0
package/package.json CHANGED
@@ -1,12 +1,36 @@
1
1
  {
2
2
  "name": "vt220.js",
3
- "version": "0.0.1",
4
- "description": "VT220-era terminal emulator — configurable profile of vterm.js",
5
- "author": "Bjørn Stabell <bjorn@stabell.org>",
3
+ "version": "0.1.0",
4
+ "description": "VT220 terminal emulator — 8 colors, insert/delete, selective erase. Pure TypeScript, zero dependencies.",
5
+ "keywords": [
6
+ "ansi",
7
+ "headless",
8
+ "terminal-emulator",
9
+ "tui",
10
+ "typescript",
11
+ "vt220",
12
+ "zero-dependency"
13
+ ],
14
+ "homepage": "https://github.com/beorn/vterm/tree/main/packages/vt220",
15
+ "bugs": "https://github.com/beorn/vterm/issues",
6
16
  "license": "MIT",
17
+ "author": "Bjørn Stabell",
7
18
  "repository": {
8
19
  "type": "git",
9
- "url": "https://github.com/beorn/vt100"
20
+ "url": "https://github.com/beorn/vterm.git",
21
+ "directory": "packages/vt220"
10
22
  },
11
- "keywords": ["terminal", "emulator", "vt220", "ansi", "xterm"]
23
+ "files": [
24
+ "src"
25
+ ],
26
+ "type": "module",
27
+ "exports": {
28
+ ".": "./src/index.ts"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "engines": {
34
+ "node": ">=23.6.0"
35
+ }
12
36
  }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export {
2
+ createScreen as createVt220Screen,
3
+ type Screen as Vt220Screen,
4
+ type ScreenOptions as Vt220ScreenOptions,
5
+ type ScreenCell,
6
+ type CellColor,
7
+ } from "./screen.ts"
package/src/screen.ts ADDED
@@ -0,0 +1,1037 @@
1
+ /**
2
+ * Pure TypeScript VT220 terminal emulator.
3
+ *
4
+ * Extends VT100 with VT220 features: 8 standard colors (SGR 30-37/40-47),
5
+ * insert/replace mode (IRM), insert/delete characters (ICH/DCH),
6
+ * insert/delete lines (IL/DL), erase characters (ECH), selective erase
7
+ * (DECSED/DECSEL), hidden/conceal (SGR 8/28), and soft reset (DECSTR).
8
+ *
9
+ * Zero dependencies. No truecolor, no 256 colors, no wide chars —
10
+ * those belong in vterm.js.
11
+ */
12
+
13
+ // ═══════════════════════════════════════════════════════
14
+ // Internal cell representation
15
+ // ═══════════════════════════════════════════════════════
16
+
17
+ export interface CellColor {
18
+ r: number
19
+ g: number
20
+ b: number
21
+ }
22
+
23
+ export interface ScreenCell {
24
+ char: string
25
+ fg: CellColor | null
26
+ bg: CellColor | null
27
+ bold: boolean
28
+ underline: boolean
29
+ blink: boolean
30
+ inverse: boolean
31
+ hidden: boolean
32
+ }
33
+
34
+ /** Frozen sentinel for unwritten cells — never mutate, copy-on-write in writeChar(). */
35
+ const EMPTY_CELL: ScreenCell = Object.freeze({
36
+ char: "",
37
+ fg: null,
38
+ bg: null,
39
+ bold: false,
40
+ underline: false,
41
+ blink: false,
42
+ inverse: false,
43
+ hidden: false,
44
+ })
45
+
46
+ function emptyCell(): ScreenCell {
47
+ return { ...EMPTY_CELL }
48
+ }
49
+
50
+ // ═══════════════════════════════════════════════════════
51
+ // ANSI 8-color palette (standard VT220 colors)
52
+ // ═══════════════════════════════════════════════════════
53
+
54
+ const ANSI_8: readonly CellColor[] = [
55
+ { r: 0x00, g: 0x00, b: 0x00 }, // 0 Black
56
+ { r: 0x80, g: 0x00, b: 0x00 }, // 1 Red
57
+ { r: 0x00, g: 0x80, b: 0x00 }, // 2 Green
58
+ { r: 0x80, g: 0x80, b: 0x00 }, // 3 Yellow
59
+ { r: 0x00, g: 0x00, b: 0x80 }, // 4 Blue
60
+ { r: 0x80, g: 0x00, b: 0x80 }, // 5 Magenta
61
+ { r: 0x00, g: 0x80, b: 0x80 }, // 6 Cyan
62
+ { r: 0xc0, g: 0xc0, b: 0xc0 }, // 7 White
63
+ ]
64
+
65
+ // ═══════════════════════════════════════════════════════
66
+ // Screen
67
+ // ═══════════════════════════════════════════════════════
68
+
69
+ export interface ScreenOptions {
70
+ cols: number
71
+ rows: number
72
+ scrollbackLimit?: number
73
+ /** Callback for DA1/DA2/DSR responses — write these back to the PTY */
74
+ onResponse?: (data: string) => void
75
+ }
76
+
77
+ interface Attrs {
78
+ fg: CellColor | null
79
+ bg: CellColor | null
80
+ bold: boolean
81
+ underline: boolean
82
+ blink: boolean
83
+ inverse: boolean
84
+ hidden: boolean
85
+ }
86
+
87
+ export interface Screen {
88
+ readonly cols: number
89
+ readonly rows: number
90
+ process(data: Uint8Array): void
91
+ resize(cols: number, rows: number): void
92
+ reset(): void
93
+ getCell(row: number, col: number): ScreenCell
94
+ getLine(row: number): ScreenCell[]
95
+ getText(): string
96
+ getTextRange(startRow: number, startCol: number, endRow: number, endCol: number): string
97
+ getCursorPosition(): { x: number; y: number }
98
+ getCursorVisible(): boolean
99
+ getTitle(): string
100
+ getMode(mode: string): boolean
101
+ getScrollbackLength(): number
102
+ getViewportOffset(): number
103
+ scrollViewport(delta: number): void
104
+ }
105
+
106
+ export function createScreen(opts: ScreenOptions): Screen {
107
+ let cols = opts.cols
108
+ let rows = opts.rows
109
+ const scrollbackLimit = opts.scrollbackLimit ?? 1000
110
+ const onResponse = opts.onResponse
111
+
112
+ // Main screen buffer (no alternate screen in VT220)
113
+ let grid: ScreenCell[][] = makeGrid(cols, rows)
114
+ let scrollback: ScreenCell[][] = []
115
+
116
+ // Cursor
117
+ let curX = 0
118
+ let curY = 0
119
+ let curVisible = true
120
+ let savedCurX = 0
121
+ let savedCurY = 0
122
+
123
+ // DECSC/DECRC saved state (cursor + attrs + modes)
124
+ interface SavedState {
125
+ curX: number
126
+ curY: number
127
+ attrs: Attrs
128
+ originMode: boolean
129
+ autoWrap: boolean
130
+ }
131
+ let savedState: SavedState = {
132
+ curX: 0,
133
+ curY: 0,
134
+ attrs: resetAttrs(),
135
+ originMode: false,
136
+ autoWrap: true,
137
+ }
138
+
139
+ // Current drawing attributes
140
+ let attrs: Attrs = resetAttrs()
141
+
142
+ // Terminal state
143
+ let title = ""
144
+ let applicationCursor = false
145
+ let applicationKeypad = false
146
+ let autoWrap = true
147
+ let originMode = false
148
+ let insertMode = false
149
+ let reverseVideo = false
150
+
151
+ // Scroll region (inclusive, 0-based)
152
+ let scrollTop = 0
153
+ let scrollBottom = rows - 1
154
+
155
+ // Viewport scroll offset for scrollViewport()
156
+ let viewportOffset = 0
157
+
158
+ // Parser state
159
+ let parserState: "ground" | "escape" | "csi" | "osc" | "dcs" | "oscString" = "ground"
160
+ let escBuf = ""
161
+ let oscBuf = ""
162
+
163
+ // Decoder for incoming bytes
164
+ const decoder = new TextDecoder()
165
+
166
+ function makeGrid(c: number, r: number): ScreenCell[][] {
167
+ const g: ScreenCell[][] = []
168
+ for (let row = 0; row < r; row++) {
169
+ g.push(makeRow(c))
170
+ }
171
+ return g
172
+ }
173
+
174
+ function makeRow(c: number): ScreenCell[] {
175
+ const row: ScreenCell[] = []
176
+ for (let col = 0; col < c; col++) {
177
+ row.push(EMPTY_CELL)
178
+ }
179
+ return row
180
+ }
181
+
182
+ function resetAttrs(): Attrs {
183
+ return {
184
+ fg: null,
185
+ bg: null,
186
+ bold: false,
187
+ underline: false,
188
+ blink: false,
189
+ inverse: false,
190
+ hidden: false,
191
+ }
192
+ }
193
+
194
+ function clampCursor(): void {
195
+ if (curX < 0) curX = 0
196
+ if (curX >= cols) curX = cols - 1
197
+ if (curY < 0) curY = 0
198
+ if (curY >= rows) curY = rows - 1
199
+ }
200
+
201
+ // ── Scrolling ──
202
+
203
+ function scrollUp(top: number, bottom: number): void {
204
+ // Move top row to scrollback (only if top of screen)
205
+ if (top === 0) {
206
+ scrollback.push(grid[0]!)
207
+ // Bulk trim when exceeding 2x limit to avoid O(n) shift() on every scroll
208
+ if (scrollback.length > scrollbackLimit * 2) {
209
+ scrollback.splice(0, scrollback.length - scrollbackLimit)
210
+ }
211
+ }
212
+ // Shift rows up within the region
213
+ for (let i = top; i < bottom; i++) {
214
+ grid[i] = grid[i + 1]!
215
+ }
216
+ grid[bottom] = makeRow(cols)
217
+ }
218
+
219
+ function scrollDown(top: number, bottom: number): void {
220
+ for (let i = bottom; i > top; i--) {
221
+ grid[i] = grid[i - 1]!
222
+ }
223
+ grid[top] = makeRow(cols)
224
+ }
225
+
226
+ // ── Character writing ──
227
+
228
+ function writeChar(ch: string): void {
229
+ // Handle autowrap at end of line
230
+ if (curX >= cols) {
231
+ if (autoWrap) {
232
+ curX = 0
233
+ curY++
234
+ if (curY > scrollBottom) {
235
+ curY = scrollBottom
236
+ scrollUp(scrollTop, scrollBottom)
237
+ }
238
+ } else {
239
+ curX = cols - 1
240
+ }
241
+ }
242
+
243
+ // Insert mode: shift existing characters right before writing
244
+ if (insertMode) {
245
+ const row = grid[curY]!
246
+ row.splice(curX, 0, EMPTY_CELL)
247
+ row.pop()
248
+ }
249
+
250
+ // Copy-on-write: if cell is the shared EMPTY_CELL sentinel, create a fresh object
251
+ const row = grid[curY]!
252
+ let cell = row[curX]!
253
+ if (cell === EMPTY_CELL) {
254
+ cell = { ...EMPTY_CELL }
255
+ row[curX] = cell
256
+ }
257
+ cell.char = ch
258
+ cell.fg = attrs.fg ? { ...attrs.fg } : null
259
+ cell.bg = attrs.bg ? { ...attrs.bg } : null
260
+ cell.bold = attrs.bold
261
+ cell.underline = attrs.underline
262
+ cell.blink = attrs.blink
263
+ cell.inverse = attrs.inverse
264
+ cell.hidden = attrs.hidden
265
+
266
+ curX++
267
+ }
268
+
269
+ // ── CSI handler ──
270
+
271
+ function handleCSI(params: string, finalByte: string): void {
272
+ const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
273
+
274
+ switch (finalByte) {
275
+ case "A": // CUU - Cursor Up
276
+ curY -= Math.max(parts[0] ?? 1, 1)
277
+ clampCursor()
278
+ break
279
+ case "B": // CUD - Cursor Down
280
+ curY += Math.max(parts[0] ?? 1, 1)
281
+ clampCursor()
282
+ break
283
+ case "C": // CUF - Cursor Forward
284
+ curX += Math.max(parts[0] ?? 1, 1)
285
+ clampCursor()
286
+ break
287
+ case "D": // CUB - Cursor Back
288
+ curX -= Math.max(parts[0] ?? 1, 1)
289
+ clampCursor()
290
+ break
291
+ case "E": // CNL - Cursor Next Line
292
+ curY += Math.max(parts[0] ?? 1, 1)
293
+ curX = 0
294
+ clampCursor()
295
+ break
296
+ case "F": // CPL - Cursor Previous Line
297
+ curY -= Math.max(parts[0] ?? 1, 1)
298
+ curX = 0
299
+ clampCursor()
300
+ break
301
+ case "G": // CHA - Cursor Horizontal Absolute
302
+ curX = (parts[0] ?? 1) - 1
303
+ clampCursor()
304
+ break
305
+ case "H": // CUP - Cursor Position
306
+ case "f": // HVP - same as CUP
307
+ curY = (parts[0] ?? 1) - 1
308
+ curX = (parts[1] ?? 1) - 1
309
+ clampCursor()
310
+ break
311
+ case "J": // ED - Erase in Display
312
+ handleEraseDisplay(parts[0] ?? 0)
313
+ break
314
+ case "K": // EL - Erase in Line
315
+ handleEraseLine(parts[0] ?? 0)
316
+ break
317
+ case "L": // IL - Insert Lines
318
+ handleInsertLines(Math.max(parts[0] ?? 1, 1))
319
+ break
320
+ case "M": // DL - Delete Lines
321
+ handleDeleteLines(Math.max(parts[0] ?? 1, 1))
322
+ break
323
+ case "P": // DCH - Delete Characters
324
+ handleDeleteChars(Math.max(parts[0] ?? 1, 1))
325
+ break
326
+ case "@": // ICH - Insert Characters
327
+ handleInsertChars(Math.max(parts[0] ?? 1, 1))
328
+ break
329
+ case "X": // ECH - Erase Characters
330
+ handleEraseChars(Math.max(parts[0] ?? 1, 1))
331
+ break
332
+ case "S": // SU - Scroll Up
333
+ for (let i = 0; i < Math.max(parts[0] ?? 1, 1); i++) {
334
+ scrollUp(scrollTop, scrollBottom)
335
+ }
336
+ break
337
+ case "T": // SD - Scroll Down
338
+ for (let i = 0; i < Math.max(parts[0] ?? 1, 1); i++) {
339
+ scrollDown(scrollTop, scrollBottom)
340
+ }
341
+ break
342
+ case "d": // VPA - Line Position Absolute
343
+ curY = (parts[0] ?? 1) - 1
344
+ clampCursor()
345
+ break
346
+ case "m": // SGR - Select Graphic Rendition
347
+ handleSGR(params)
348
+ break
349
+ case "r": // DECSTBM - Set Scrolling Region
350
+ scrollTop = (parts[0] ?? 1) - 1
351
+ scrollBottom = (parts[1] ?? rows) - 1
352
+ if (scrollTop < 0) scrollTop = 0
353
+ if (scrollBottom >= rows) scrollBottom = rows - 1
354
+ if (scrollTop > scrollBottom) {
355
+ scrollTop = 0
356
+ scrollBottom = rows - 1
357
+ }
358
+ curX = 0
359
+ curY = originMode ? scrollTop : 0
360
+ break
361
+ case "n": // DSR - Device Status Report
362
+ if (onResponse) {
363
+ if (parts[0] === 5) {
364
+ // Status report - OK
365
+ onResponse("\x1b[0n")
366
+ } else if (parts[0] === 6) {
367
+ // CPR - Cursor position report (1-based)
368
+ onResponse(`\x1b[${curY + 1};${curX + 1}R`)
369
+ }
370
+ }
371
+ break
372
+ case "c": // DA1 - Primary Device Attributes
373
+ if (onResponse) {
374
+ if (params === "" || params === "0") {
375
+ // VT220: class 2 with features: printer (1), selective erase (2),
376
+ // user windows (6), horizontal scrolling (7), ANSI color (8), NRCS (9)
377
+ onResponse("\x1b[?62;1;2;6;7;8;9c")
378
+ }
379
+ }
380
+ break
381
+ case "s": // SCP - Save Cursor Position
382
+ savedCurX = curX
383
+ savedCurY = curY
384
+ break
385
+ case "u": // RCP - Restore Cursor Position
386
+ curX = savedCurX
387
+ curY = savedCurY
388
+ clampCursor()
389
+ break
390
+ default:
391
+ // Unknown CSI sequence — ignore
392
+ break
393
+ }
394
+ }
395
+
396
+ function handleCSIWithIntermediate(params: string, intermediate: string, finalByte: string): void {
397
+ if (intermediate === "!" && finalByte === "p") {
398
+ // DECSTR - Soft Terminal Reset
399
+ softReset()
400
+ } else if (intermediate === ">" && finalByte === "c") {
401
+ // DA2 - Secondary Device Attributes
402
+ if (onResponse) {
403
+ // VT220: type 1, firmware version 10, ROM cartridge 0
404
+ onResponse("\x1b[>1;10;0c")
405
+ }
406
+ }
407
+ // Unknown intermediate sequences — ignore
408
+ }
409
+
410
+ function handleCSIPrivate(params: string, finalByte: string): void {
411
+ const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
412
+
413
+ if (finalByte === "J") {
414
+ // DECSED - Selective Erase in Display
415
+ handleSelectiveEraseDisplay(parts[0] ?? 0)
416
+ return
417
+ }
418
+ if (finalByte === "K") {
419
+ // DECSEL - Selective Erase in Line
420
+ handleSelectiveEraseLine(parts[0] ?? 0)
421
+ return
422
+ }
423
+
424
+ const set = finalByte === "h"
425
+
426
+ for (const code of parts) {
427
+ switch (code) {
428
+ case 1: // DECCKM - Application Cursor
429
+ applicationCursor = set
430
+ break
431
+ case 4: // IRM - Insert Mode (via DEC private)
432
+ insertMode = set
433
+ break
434
+ case 5: // DECSCNM - Reverse Video
435
+ reverseVideo = set
436
+ break
437
+ case 6: // DECOM - Origin Mode
438
+ originMode = set
439
+ break
440
+ case 7: // DECAWM - Autowrap Mode
441
+ autoWrap = set
442
+ break
443
+ case 25: // DECTCEM - Cursor Visible
444
+ curVisible = set
445
+ break
446
+ case 66: // DECNKM - Application Keypad
447
+ applicationKeypad = set
448
+ break
449
+ }
450
+ }
451
+ }
452
+
453
+ // Also handle standard (non-private) set/reset modes: CSI Ps h / CSI Ps l
454
+ function handleSetResetMode(params: string, finalByte: string): void {
455
+ const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
456
+ const set = finalByte === "h"
457
+
458
+ for (const code of parts) {
459
+ switch (code) {
460
+ case 4: // IRM - Insert/Replace Mode
461
+ insertMode = set
462
+ break
463
+ }
464
+ }
465
+ }
466
+
467
+ function handleEraseDisplay(mode: number): void {
468
+ switch (mode) {
469
+ case 0: // Erase from cursor to end
470
+ eraseCells(curY, curX, curY, cols - 1)
471
+ for (let row = curY + 1; row < rows; row++) {
472
+ eraseCells(row, 0, row, cols - 1)
473
+ }
474
+ break
475
+ case 1: // Erase from start to cursor
476
+ for (let row = 0; row < curY; row++) {
477
+ eraseCells(row, 0, row, cols - 1)
478
+ }
479
+ eraseCells(curY, 0, curY, curX)
480
+ break
481
+ case 2: // Erase entire display
482
+ case 3: // Erase entire display + scrollback
483
+ for (let row = 0; row < rows; row++) {
484
+ eraseCells(row, 0, row, cols - 1)
485
+ }
486
+ if (mode === 3) {
487
+ scrollback.length = 0
488
+ }
489
+ break
490
+ }
491
+ }
492
+
493
+ function handleEraseLine(mode: number): void {
494
+ switch (mode) {
495
+ case 0: // Erase from cursor to end of line
496
+ eraseCells(curY, curX, curY, cols - 1)
497
+ break
498
+ case 1: // Erase from start to cursor
499
+ eraseCells(curY, 0, curY, curX)
500
+ break
501
+ case 2: // Erase entire line
502
+ eraseCells(curY, 0, curY, cols - 1)
503
+ break
504
+ }
505
+ }
506
+
507
+ // ── Selective erase (DECSED/DECSEL) ──
508
+ // These only erase cells that do NOT have the "protected" attribute.
509
+ // Since we don't track DECSCA (protected attribute), selective erase
510
+ // behaves identically to normal erase for now.
511
+
512
+ function handleSelectiveEraseDisplay(mode: number): void {
513
+ handleEraseDisplay(mode)
514
+ }
515
+
516
+ function handleSelectiveEraseLine(mode: number): void {
517
+ handleEraseLine(mode)
518
+ }
519
+
520
+ function eraseCells(row: number, startCol: number, _endRow: number, endCol: number): void {
521
+ const r = grid[row]
522
+ if (!r) return
523
+ for (let col = startCol; col <= endCol && col < cols; col++) {
524
+ r[col] = emptyCell()
525
+ }
526
+ }
527
+
528
+ function handleInsertLines(count: number): void {
529
+ if (curY < scrollTop || curY > scrollBottom) return
530
+ for (let i = 0; i < count; i++) {
531
+ scrollDown(curY, scrollBottom)
532
+ }
533
+ }
534
+
535
+ function handleDeleteLines(count: number): void {
536
+ if (curY < scrollTop || curY > scrollBottom) return
537
+ for (let i = 0; i < count; i++) {
538
+ scrollUp(curY, scrollBottom)
539
+ }
540
+ }
541
+
542
+ function handleDeleteChars(count: number): void {
543
+ const row = grid[curY]
544
+ if (!row) return
545
+ for (let i = 0; i < count; i++) {
546
+ if (curX < cols) {
547
+ row.splice(curX, 1)
548
+ row.push(emptyCell())
549
+ }
550
+ }
551
+ }
552
+
553
+ function handleInsertChars(count: number): void {
554
+ const row = grid[curY]
555
+ if (!row) return
556
+ for (let i = 0; i < count; i++) {
557
+ row.splice(curX, 0, emptyCell())
558
+ row.pop()
559
+ }
560
+ }
561
+
562
+ function handleEraseChars(count: number): void {
563
+ const row = grid[curY]
564
+ if (!row) return
565
+ for (let i = 0; i < count && curX + i < cols; i++) {
566
+ row[curX + i] = emptyCell()
567
+ }
568
+ }
569
+
570
+ // ── SGR (Select Graphic Rendition) ──
571
+
572
+ function handleSGR(rawParams: string): void {
573
+ const params = rawParams.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
574
+
575
+ if (params.length === 0 || (params.length === 1 && params[0] === 0)) {
576
+ attrs = resetAttrs()
577
+ return
578
+ }
579
+
580
+ let i = 0
581
+ while (i < params.length) {
582
+ const code = params[i]!
583
+ switch (code) {
584
+ case 0:
585
+ attrs = resetAttrs()
586
+ break
587
+ case 1:
588
+ attrs.bold = true
589
+ break
590
+ case 4:
591
+ attrs.underline = true
592
+ break
593
+ case 5: // Blink
594
+ attrs.blink = true
595
+ break
596
+ case 7:
597
+ attrs.inverse = true
598
+ break
599
+ case 8: // Hidden/conceal
600
+ attrs.hidden = true
601
+ break
602
+ case 22: // Normal intensity (turn off bold)
603
+ attrs.bold = false
604
+ break
605
+ case 24:
606
+ attrs.underline = false
607
+ break
608
+ case 25: // Blink off
609
+ attrs.blink = false
610
+ break
611
+ case 27:
612
+ attrs.inverse = false
613
+ break
614
+ case 28: // Reveal (turn off hidden/conceal)
615
+ attrs.hidden = false
616
+ break
617
+ // Foreground colors 30-37
618
+ case 30:
619
+ case 31:
620
+ case 32:
621
+ case 33:
622
+ case 34:
623
+ case 35:
624
+ case 36:
625
+ case 37:
626
+ attrs.fg = { ...ANSI_8[code - 30]! }
627
+ break
628
+ case 39: // Default foreground
629
+ attrs.fg = null
630
+ break
631
+ // Background colors 40-47
632
+ case 40:
633
+ case 41:
634
+ case 42:
635
+ case 43:
636
+ case 44:
637
+ case 45:
638
+ case 46:
639
+ case 47:
640
+ attrs.bg = { ...ANSI_8[code - 40]! }
641
+ break
642
+ case 49: // Default background
643
+ attrs.bg = null
644
+ break
645
+ // Skip extended color sequences (not supported, but must consume params)
646
+ case 38:
647
+ case 48:
648
+ if (i + 1 < params.length && params[i + 1] === 2) {
649
+ i += 4 // skip 38;2;R;G;B or 48;2;R;G;B
650
+ } else if (i + 1 < params.length && params[i + 1] === 5) {
651
+ i += 2 // skip 38;5;N or 48;5;N
652
+ }
653
+ break
654
+ }
655
+ i++
656
+ }
657
+ }
658
+
659
+ // ── OSC handler ──
660
+
661
+ function handleOSC(oscString: string): void {
662
+ const semicolonIdx = oscString.indexOf(";")
663
+ if (semicolonIdx === -1) return
664
+
665
+ const code = parseInt(oscString.substring(0, semicolonIdx), 10)
666
+ const value = oscString.substring(semicolonIdx + 1)
667
+
668
+ switch (code) {
669
+ case 0: // Set icon name and window title
670
+ case 2: // Set window title
671
+ title = value
672
+ break
673
+ case 1: // Set icon name (ignore)
674
+ break
675
+ }
676
+ }
677
+
678
+ // ── Main parser ──
679
+
680
+ function process(data: Uint8Array): void {
681
+ const text = decoder.decode(data, { stream: true })
682
+
683
+ for (let i = 0; i < text.length; i++) {
684
+ const ch = text[i]!
685
+ const code = text.charCodeAt(i)
686
+
687
+ switch (parserState) {
688
+ case "ground":
689
+ if (code === 0x1b) {
690
+ parserState = "escape"
691
+ escBuf = ""
692
+ } else if (code === 0x07) {
693
+ // BEL — ignore
694
+ } else if (code === 0x08) {
695
+ // BS - Backspace
696
+ if (curX > 0) curX--
697
+ } else if (code === 0x09) {
698
+ // TAB
699
+ curX = Math.min((Math.floor(curX / 8) + 1) * 8, cols - 1)
700
+ } else if (code === 0x0a || code === 0x0b || code === 0x0c) {
701
+ // LF, VT, FF — linefeed
702
+ curY++
703
+ if (curY > scrollBottom) {
704
+ curY = scrollBottom
705
+ scrollUp(scrollTop, scrollBottom)
706
+ }
707
+ } else if (code === 0x0d) {
708
+ // CR - Carriage Return
709
+ curX = 0
710
+ } else if (code >= 0x20) {
711
+ writeChar(ch)
712
+ }
713
+ break
714
+
715
+ case "escape":
716
+ if (ch === "[") {
717
+ parserState = "csi"
718
+ escBuf = ""
719
+ } else if (ch === "]") {
720
+ parserState = "osc"
721
+ oscBuf = ""
722
+ } else if (ch === "P") {
723
+ parserState = "dcs"
724
+ escBuf = ""
725
+ } else if (ch === "c") {
726
+ // RIS - Reset to Initial State
727
+ fullReset()
728
+ } else if (ch === "D") {
729
+ // IND - Index (move cursor down, scroll if needed)
730
+ curY++
731
+ if (curY > scrollBottom) {
732
+ curY = scrollBottom
733
+ scrollUp(scrollTop, scrollBottom)
734
+ }
735
+ parserState = "ground"
736
+ } else if (ch === "M") {
737
+ // RI - Reverse Index (move cursor up, scroll if needed)
738
+ curY--
739
+ if (curY < scrollTop) {
740
+ curY = scrollTop
741
+ scrollDown(scrollTop, scrollBottom)
742
+ }
743
+ parserState = "ground"
744
+ } else if (ch === "7") {
745
+ // DECSC - Save Cursor + attributes + modes
746
+ savedState = {
747
+ curX,
748
+ curY,
749
+ attrs: { ...attrs, fg: attrs.fg ? { ...attrs.fg } : null, bg: attrs.bg ? { ...attrs.bg } : null },
750
+ originMode,
751
+ autoWrap,
752
+ }
753
+ parserState = "ground"
754
+ } else if (ch === "8") {
755
+ // DECRC - Restore Cursor + attributes + modes
756
+ curX = savedState.curX
757
+ curY = savedState.curY
758
+ attrs = {
759
+ ...savedState.attrs,
760
+ fg: savedState.attrs.fg ? { ...savedState.attrs.fg } : null,
761
+ bg: savedState.attrs.bg ? { ...savedState.attrs.bg } : null,
762
+ }
763
+ originMode = savedState.originMode
764
+ autoWrap = savedState.autoWrap
765
+ clampCursor()
766
+ parserState = "ground"
767
+ } else if (ch === "E") {
768
+ // NEL - Next Line
769
+ curX = 0
770
+ curY++
771
+ if (curY > scrollBottom) {
772
+ curY = scrollBottom
773
+ scrollUp(scrollTop, scrollBottom)
774
+ }
775
+ parserState = "ground"
776
+ } else if (ch === "=") {
777
+ // DECKPAM - Application Keypad Mode
778
+ applicationKeypad = true
779
+ parserState = "ground"
780
+ } else if (ch === ">") {
781
+ // DECKPNM - Normal Keypad Mode
782
+ applicationKeypad = false
783
+ parserState = "ground"
784
+ } else {
785
+ // Unknown escape — return to ground
786
+ parserState = "ground"
787
+ }
788
+ break
789
+
790
+ case "csi":
791
+ if (code >= 0x40 && code <= 0x7e) {
792
+ // Final byte — dispatch CSI
793
+ if (escBuf.startsWith("?")) {
794
+ handleCSIPrivate(escBuf.substring(1), ch)
795
+ } else if (ch === "h" || ch === "l") {
796
+ // Standard set/reset mode (non-private)
797
+ handleSetResetMode(escBuf, ch)
798
+ } else {
799
+ // Check for intermediate bytes (0x20-0x2F range, e.g., "!" in CSI ! p)
800
+ // Also check for ">" prefix for DA2 (CSI > c)
801
+ let intermediateIdx = -1
802
+ for (let j = 0; j < escBuf.length; j++) {
803
+ const c = escBuf.charCodeAt(j)
804
+ if ((c >= 0x20 && c <= 0x2f) || c === 0x3e) {
805
+ // 0x3e = '>'
806
+ intermediateIdx = j
807
+ break
808
+ }
809
+ }
810
+ if (intermediateIdx >= 0) {
811
+ const paramPart = escBuf.substring(0, intermediateIdx)
812
+ const intermediatePart = escBuf.substring(intermediateIdx)
813
+ handleCSIWithIntermediate(paramPart, intermediatePart, ch)
814
+ } else {
815
+ handleCSI(escBuf, ch)
816
+ }
817
+ }
818
+ parserState = "ground"
819
+ } else if (escBuf.length >= 256) {
820
+ // Buffer overflow — drop to ground to avoid unbounded accumulation
821
+ parserState = "ground"
822
+ } else {
823
+ // Parameter or intermediate byte
824
+ escBuf += ch
825
+ }
826
+ break
827
+
828
+ case "osc":
829
+ if (code === 0x07) {
830
+ // BEL terminates OSC
831
+ handleOSC(oscBuf)
832
+ parserState = "ground"
833
+ } else if (code === 0x1b) {
834
+ // ESC might be start of ST (\x1b\\)
835
+ parserState = "oscString"
836
+ } else if (oscBuf.length >= 4096) {
837
+ // Buffer overflow — drop to ground to avoid unbounded accumulation
838
+ parserState = "ground"
839
+ } else {
840
+ oscBuf += ch
841
+ }
842
+ break
843
+
844
+ case "oscString":
845
+ if (ch === "\\") {
846
+ // ST (String Terminator) — end of OSC
847
+ handleOSC(oscBuf)
848
+ }
849
+ // Either way, back to ground
850
+ parserState = "ground"
851
+ break
852
+
853
+ case "dcs":
854
+ // Consume until ST
855
+ if (code === 0x1b) {
856
+ parserState = "oscString" // Reuse ST detection
857
+ }
858
+ break
859
+ }
860
+ }
861
+ }
862
+
863
+ function fullReset(): void {
864
+ grid = makeGrid(cols, rows)
865
+ scrollback = []
866
+ curX = 0
867
+ curY = 0
868
+ curVisible = true
869
+ savedCurX = 0
870
+ savedCurY = 0
871
+ savedState = { curX: 0, curY: 0, attrs: resetAttrs(), originMode: false, autoWrap: true }
872
+ attrs = resetAttrs()
873
+ title = ""
874
+ applicationCursor = false
875
+ applicationKeypad = false
876
+ autoWrap = true
877
+ originMode = false
878
+ insertMode = false
879
+ reverseVideo = false
880
+ scrollTop = 0
881
+ scrollBottom = rows - 1
882
+ viewportOffset = 0
883
+ parserState = "ground"
884
+ escBuf = ""
885
+ oscBuf = ""
886
+ }
887
+
888
+ function softReset(): void {
889
+ attrs = resetAttrs()
890
+ applicationCursor = false
891
+ applicationKeypad = false
892
+ autoWrap = true
893
+ originMode = false
894
+ insertMode = false
895
+ reverseVideo = false
896
+ curVisible = true
897
+ scrollTop = 0
898
+ scrollBottom = rows - 1
899
+ savedState = { curX: 0, curY: 0, attrs: resetAttrs(), originMode: false, autoWrap: true }
900
+ savedCurX = 0
901
+ savedCurY = 0
902
+ }
903
+
904
+ function resize(newCols: number, newRows: number): void {
905
+ const newGrid = makeGrid(newCols, newRows)
906
+
907
+ // Copy content from old grid
908
+ copyGrid(grid, newGrid, Math.min(cols, newCols), Math.min(rows, newRows))
909
+
910
+ grid = newGrid
911
+ cols = newCols
912
+ rows = newRows
913
+ scrollTop = 0
914
+ scrollBottom = rows - 1
915
+ clampCursor()
916
+ }
917
+
918
+ function copyGrid(src: ScreenCell[][], dst: ScreenCell[][], copyCols: number, copyRows: number): void {
919
+ for (let row = 0; row < copyRows; row++) {
920
+ for (let col = 0; col < copyCols; col++) {
921
+ const srcCell = src[row]?.[col]
922
+ if (srcCell) {
923
+ dst[row]![col] = { ...srcCell }
924
+ }
925
+ }
926
+ }
927
+ }
928
+
929
+ function getCell(row: number, col: number): ScreenCell {
930
+ const r = grid[row]
931
+ if (!r || col >= cols) return emptyCell()
932
+ return { ...r[col]! }
933
+ }
934
+
935
+ function getLine(row: number): ScreenCell[] {
936
+ const r = grid[row]
937
+ if (!r) return makeRow(cols)
938
+ return r.map((cell) => ({ ...cell }))
939
+ }
940
+
941
+ function getText(): string {
942
+ const lines: string[] = []
943
+
944
+ // Scrollback
945
+ for (const row of scrollback) {
946
+ lines.push(rowToString(row))
947
+ }
948
+
949
+ // Screen
950
+ for (let r = 0; r < rows; r++) {
951
+ lines.push(rowToString(grid[r]!))
952
+ }
953
+
954
+ return lines.join("\n")
955
+ }
956
+
957
+ function rowToString(row: ScreenCell[]): string {
958
+ let line = ""
959
+ for (let i = 0; i < row.length; i++) {
960
+ const cell = row[i]!
961
+ if (cell.char === "") {
962
+ line += " "
963
+ } else {
964
+ line += cell.char
965
+ }
966
+ }
967
+ return line.replace(/\s+$/, "") // Trim trailing whitespace
968
+ }
969
+
970
+ function getTextRange(startRow: number, startCol: number, endRow: number, endCol: number): string {
971
+ const parts: string[] = []
972
+
973
+ for (let row = startRow; row <= endRow; row++) {
974
+ const r = grid[row]
975
+ if (!r) continue
976
+
977
+ const colStart = row === startRow ? startCol : 0
978
+ const colEnd = row === endRow ? endCol : cols
979
+
980
+ let line = ""
981
+ for (let col = colStart; col < colEnd; col++) {
982
+ const cell = r[col]
983
+ if (!cell) continue
984
+ line += cell.char || " "
985
+ }
986
+ parts.push(line.replace(/\s+$/, ""))
987
+ }
988
+
989
+ return parts.join("\n")
990
+ }
991
+
992
+ function getMode(mode: string): boolean {
993
+ switch (mode) {
994
+ case "cursorVisible":
995
+ return curVisible
996
+ case "applicationCursor":
997
+ return applicationCursor
998
+ case "applicationKeypad":
999
+ return applicationKeypad
1000
+ case "autoWrap":
1001
+ return autoWrap
1002
+ case "originMode":
1003
+ return originMode
1004
+ case "insertMode":
1005
+ return insertMode
1006
+ case "reverseVideo":
1007
+ return reverseVideo
1008
+ default:
1009
+ return false
1010
+ }
1011
+ }
1012
+
1013
+ return {
1014
+ get cols() {
1015
+ return cols
1016
+ },
1017
+ get rows() {
1018
+ return rows
1019
+ },
1020
+ process,
1021
+ resize,
1022
+ reset: fullReset,
1023
+ getCell,
1024
+ getLine,
1025
+ getText,
1026
+ getTextRange,
1027
+ getCursorPosition: () => ({ x: curX, y: curY }),
1028
+ getCursorVisible: () => curVisible,
1029
+ getTitle: () => title,
1030
+ getMode,
1031
+ getScrollbackLength: () => scrollback.length,
1032
+ getViewportOffset: () => viewportOffset,
1033
+ scrollViewport: (delta: number) => {
1034
+ viewportOffset = Math.max(0, Math.min(scrollback.length, viewportOffset + delta))
1035
+ },
1036
+ }
1037
+ }