vt100.js 0.2.2 → 0.3.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vt100.js",
3
- "version": "0.2.2",
4
- "description": "Pure TypeScript VT100 terminal emulator. Zero dependencies, headless, fast.",
3
+ "version": "0.3.0",
4
+ "description": "VT100 terminal emulator monochrome, cursor, scroll regions. Pure TypeScript, zero dependencies.",
5
5
  "keywords": [
6
6
  "ansi",
7
7
  "headless",
package/src/index.ts CHANGED
@@ -4,5 +4,4 @@ export {
4
4
  type ScreenOptions as Vt100ScreenOptions,
5
5
  type ScreenCell,
6
6
  type CellColor,
7
- type UnderlineStyle,
8
7
  } from "./screen.ts"
package/src/screen.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * Pure TypeScript VT100 terminal emulator.
3
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.
4
+ * Strict DEC VT100 (1978) implementation: monochrome display, cursor movement,
5
+ * scroll regions, auto-wrap, origin mode, DEC special graphics charset.
6
+ * SGR supports bold, underline, blink, and reverse — NO colors (VT100 is
7
+ * monochrome). No insert mode, no insert/delete chars/lines — those are
8
+ * VT102/VT220 features.
7
9
  *
8
- * Zero dependencies. Handles: SGR (16/256/truecolor), cursor movement,
9
- * erase, scroll regions, alternate screen, modes, OSC title, and more.
10
+ * Zero dependencies. For colors and VT220 features, use vt220.js.
11
+ * For truecolor, 256 colors, and wide chars, use vterm.js.
10
12
  */
11
13
 
12
14
  // ═══════════════════════════════════════════════════════
@@ -19,20 +21,15 @@ export interface CellColor {
19
21
  b: number
20
22
  }
21
23
 
22
- export type UnderlineStyle = "none" | "single" | "double" | "curly" | "dotted" | "dashed"
23
-
24
24
  export interface ScreenCell {
25
25
  char: string
26
26
  fg: CellColor | null
27
27
  bg: CellColor | null
28
28
  bold: boolean
29
- faint: boolean
30
- italic: boolean
31
- underline: UnderlineStyle
32
- strikethrough: boolean
29
+ underline: boolean
30
+ blink: boolean
33
31
  inverse: boolean
34
32
  hidden: boolean
35
- wide: boolean
36
33
  }
37
34
 
38
35
  /** Frozen sentinel for unwritten cells — never mutate, copy-on-write in writeChar(). */
@@ -41,87 +38,16 @@ const EMPTY_CELL: ScreenCell = Object.freeze({
41
38
  fg: null,
42
39
  bg: null,
43
40
  bold: false,
44
- faint: false,
45
- italic: false,
46
- underline: "none" as UnderlineStyle,
47
- strikethrough: false,
41
+ underline: false,
42
+ blink: false,
48
43
  inverse: false,
49
44
  hidden: false,
50
- wide: false,
51
45
  })
52
46
 
53
47
  function emptyCell(): ScreenCell {
54
48
  return { ...EMPTY_CELL }
55
49
  }
56
50
 
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
51
  // ═══════════════════════════════════════════════════════
126
52
  // Screen
127
53
  // ═══════════════════════════════════════════════════════
@@ -130,16 +56,16 @@ export interface ScreenOptions {
130
56
  cols: number
131
57
  rows: number
132
58
  scrollbackLimit?: number
59
+ /** Callback for DA1/DSR responses — write these back to the PTY */
60
+ onResponse?: (data: string) => void
133
61
  }
134
62
 
135
63
  interface Attrs {
136
64
  fg: CellColor | null
137
65
  bg: CellColor | null
138
66
  bold: boolean
139
- faint: boolean
140
- italic: boolean
141
- underline: UnderlineStyle
142
- strikethrough: boolean
67
+ underline: boolean
68
+ blink: boolean
143
69
  inverse: boolean
144
70
  hidden: boolean
145
71
  }
@@ -167,11 +93,10 @@ export function createScreen(opts: ScreenOptions): Screen {
167
93
  let cols = opts.cols
168
94
  let rows = opts.rows
169
95
  const scrollbackLimit = opts.scrollbackLimit ?? 1000
96
+ const onResponse = opts.onResponse
170
97
 
171
- // Main and alternate screen buffers
172
- let mainGrid: ScreenCell[][] = makeGrid(cols, rows)
173
- let altGrid: ScreenCell[][] = makeGrid(cols, rows)
174
- let grid = mainGrid
98
+ // Main screen buffer (no alternate screen in VT100)
99
+ let grid: ScreenCell[][] = makeGrid(cols, rows)
175
100
  let scrollback: ScreenCell[][] = []
176
101
 
177
102
  // Cursor
@@ -202,15 +127,10 @@ export function createScreen(opts: ScreenOptions): Screen {
202
127
 
203
128
  // Terminal state
204
129
  let title = ""
205
- let useAltScreen = false
206
- let bracketedPaste = false
207
130
  let applicationCursor = false
208
131
  let applicationKeypad = false
209
132
  let autoWrap = true
210
- let mouseTracking = false
211
- let focusTracking = false
212
133
  let originMode = false
213
- let insertMode = false
214
134
  let reverseVideo = false
215
135
 
216
136
  // Scroll region (inclusive, 0-based)
@@ -249,10 +169,8 @@ export function createScreen(opts: ScreenOptions): Screen {
249
169
  fg: null,
250
170
  bg: null,
251
171
  bold: false,
252
- faint: false,
253
- italic: false,
254
- underline: "none",
255
- strikethrough: false,
172
+ underline: false,
173
+ blink: false,
256
174
  inverse: false,
257
175
  hidden: false,
258
176
  }
@@ -268,8 +186,8 @@ export function createScreen(opts: ScreenOptions): Screen {
268
186
  // ── Scrolling ──
269
187
 
270
188
  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) {
189
+ // Move top row to scrollback (only if top of screen)
190
+ if (top === 0) {
273
191
  scrollback.push(grid[0]!)
274
192
  // Bulk trim when exceeding 2x limit to avoid O(n) shift() on every scroll
275
193
  if (scrollback.length > scrollbackLimit * 2) {
@@ -293,12 +211,8 @@ export function createScreen(opts: ScreenOptions): Screen {
293
211
  // ── Character writing ──
294
212
 
295
213
  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
214
  // Handle autowrap at end of line
301
- if (curX + charWidth > cols) {
215
+ if (curX >= cols) {
302
216
  if (autoWrap) {
303
217
  curX = 0
304
218
  curY++
@@ -307,16 +221,7 @@ export function createScreen(opts: ScreenOptions): Screen {
307
221
  scrollUp(scrollTop, scrollBottom)
308
222
  }
309
223
  } 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()
224
+ curX = cols - 1
320
225
  }
321
226
  }
322
227
 
@@ -331,35 +236,12 @@ export function createScreen(opts: ScreenOptions): Screen {
331
236
  cell.fg = attrs.fg ? { ...attrs.fg } : null
332
237
  cell.bg = attrs.bg ? { ...attrs.bg } : null
333
238
  cell.bold = attrs.bold
334
- cell.faint = attrs.faint
335
- cell.italic = attrs.italic
336
239
  cell.underline = attrs.underline
337
- cell.strikethrough = attrs.strikethrough
240
+ cell.blink = attrs.blink
338
241
  cell.inverse = attrs.inverse
339
242
  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
243
 
362
- curX += charWidth
244
+ curX++
363
245
  }
364
246
 
365
247
  // ── CSI handler ──
@@ -410,21 +292,6 @@ export function createScreen(opts: ScreenOptions): Screen {
410
292
  case "K": // EL - Erase in Line
411
293
  handleEraseLine(parts[0] ?? 0)
412
294
  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
295
  case "S": // SU - Scroll Up
429
296
  for (let i = 0; i < Math.max(parts[0] ?? 1, 1); i++) {
430
297
  scrollUp(scrollTop, scrollBottom)
@@ -454,9 +321,24 @@ export function createScreen(opts: ScreenOptions): Screen {
454
321
  curX = 0
455
322
  curY = originMode ? scrollTop : 0
456
323
  break
457
- case "n": // DSR - Device Status Report (ignore)
324
+ case "n": // DSR - Device Status Report
325
+ if (onResponse) {
326
+ if (parts[0] === 5) {
327
+ // Status report - OK
328
+ onResponse("\x1b[0n")
329
+ } else if (parts[0] === 6) {
330
+ // CPR - Cursor position report (1-based)
331
+ onResponse(`\x1b[${curY + 1};${curX + 1}R`)
332
+ }
333
+ }
458
334
  break
459
- case "c": // DA - Device Attributes (ignore)
335
+ case "c": // DA1 - Primary Device Attributes
336
+ if (onResponse) {
337
+ if (params === "" || params === "0") {
338
+ // VT100 with Advanced Video Option (AVO)
339
+ onResponse("\x1b[?1;2c")
340
+ }
341
+ }
460
342
  break
461
343
  case "s": // SCP - Save Cursor Position
462
344
  savedCurX = curX
@@ -482,6 +364,9 @@ export function createScreen(opts: ScreenOptions): Screen {
482
364
  case 1: // DECCKM - Application Cursor
483
365
  applicationCursor = set
484
366
  break
367
+ case 5: // DECSCNM - Reverse Video
368
+ reverseVideo = set
369
+ break
485
370
  case 6: // DECOM - Origin Mode
486
371
  originMode = set
487
372
  break
@@ -491,53 +376,9 @@ export function createScreen(opts: ScreenOptions): Screen {
491
376
  case 25: // DECTCEM - Cursor Visible
492
377
  curVisible = set
493
378
  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
379
  case 66: // DECNKM - Application Keypad
505
380
  applicationKeypad = set
506
381
  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
382
  }
542
383
  }
543
384
  }
@@ -590,65 +431,12 @@ export function createScreen(opts: ScreenOptions): Screen {
590
431
  }
591
432
  }
592
433
 
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
434
  // ── SGR (Select Graphic Rendition) ──
435
+ // VT100 is monochrome: only bold (1), underline (4), blink (5), reverse (7).
436
+ // Color codes are silently ignored for forward compatibility.
636
437
 
637
438
  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
- }
439
+ const params = rawParams.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
652
440
 
653
441
  if (params.length === 0 || (params.length === 1 && params[0] === 0)) {
654
442
  attrs = resetAttrs()
@@ -665,172 +453,44 @@ export function createScreen(opts: ScreenOptions): Screen {
665
453
  case 1:
666
454
  attrs.bold = true
667
455
  break
668
- case 2:
669
- attrs.faint = true
670
- break
671
- case 3:
672
- attrs.italic = true
456
+ case 4:
457
+ attrs.underline = true
673
458
  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
- }
459
+ case 5: // Blink
460
+ attrs.blink = true
705
461
  break
706
- }
707
462
  case 7:
708
463
  attrs.inverse = true
709
464
  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)
465
+ case 22: // Normal intensity (turn off bold)
720
466
  attrs.bold = false
721
- attrs.faint = false
722
- break
723
- case 23:
724
- attrs.italic = false
725
467
  break
726
468
  case 24:
727
- attrs.underline = "none"
469
+ attrs.underline = false
470
+ break
471
+ case 25: // Blink off
472
+ attrs.blink = false
728
473
  break
729
474
  case 27:
730
475
  attrs.inverse = false
731
476
  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
477
+ // Color codes silently ignored VT100 is monochrome
478
+ // Skip extended color sequences to consume params correctly
479
+ case 38:
480
+ case 48:
481
+ if (i + 1 < params.length && params[i + 1] === 2) {
482
+ i += 4 // skip 38;2;R;G;B or 48;2;R;G;B
483
+ } else if (i + 1 < params.length && params[i + 1] === 5) {
484
+ i += 2 // skip 38;5;N or 48;5;N
755
485
  }
756
486
  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
487
+ // All other SGR codes (colors 30-37, 40-47, 39, 49, 8, 28, etc.)
488
+ // are silently ignored for forward compatibility
806
489
  }
807
490
  i++
808
491
  }
809
492
  }
810
493
 
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
494
  // ── OSC handler ──
835
495
 
836
496
  function handleOSC(oscString: string): void {
@@ -883,16 +543,7 @@ export function createScreen(opts: ScreenOptions): Screen {
883
543
  // CR - Carriage Return
884
544
  curX = 0
885
545
  } 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)
546
+ writeChar(ch)
896
547
  }
897
548
  break
898
549
 
@@ -957,6 +608,14 @@ export function createScreen(opts: ScreenOptions): Screen {
957
608
  scrollUp(scrollTop, scrollBottom)
958
609
  }
959
610
  parserState = "ground"
611
+ } else if (ch === "=") {
612
+ // DECKPAM - Application Keypad Mode
613
+ applicationKeypad = true
614
+ parserState = "ground"
615
+ } else if (ch === ">") {
616
+ // DECKPNM - Normal Keypad Mode
617
+ applicationKeypad = false
618
+ parserState = "ground"
960
619
  } else {
961
620
  // Unknown escape — return to ground
962
621
  parserState = "ground"
@@ -1017,9 +676,7 @@ export function createScreen(opts: ScreenOptions): Screen {
1017
676
  }
1018
677
 
1019
678
  function fullReset(): void {
1020
- mainGrid = makeGrid(cols, rows)
1021
- altGrid = makeGrid(cols, rows)
1022
- grid = mainGrid
679
+ grid = makeGrid(cols, rows)
1023
680
  scrollback = []
1024
681
  curX = 0
1025
682
  curY = 0
@@ -1029,15 +686,10 @@ export function createScreen(opts: ScreenOptions): Screen {
1029
686
  savedState = { curX: 0, curY: 0, attrs: resetAttrs(), originMode: false, autoWrap: true }
1030
687
  attrs = resetAttrs()
1031
688
  title = ""
1032
- useAltScreen = false
1033
- bracketedPaste = false
1034
689
  applicationCursor = false
1035
690
  applicationKeypad = false
1036
691
  autoWrap = true
1037
- mouseTracking = false
1038
- focusTracking = false
1039
692
  originMode = false
1040
- insertMode = false
1041
693
  reverseVideo = false
1042
694
  scrollTop = 0
1043
695
  scrollBottom = rows - 1
@@ -1048,16 +700,12 @@ export function createScreen(opts: ScreenOptions): Screen {
1048
700
  }
1049
701
 
1050
702
  function resize(newCols: number, newRows: number): void {
1051
- const newMain = makeGrid(newCols, newRows)
1052
- const newAlt = makeGrid(newCols, newRows)
703
+ const newGrid = makeGrid(newCols, newRows)
1053
704
 
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))
705
+ // Copy content from old grid
706
+ copyGrid(grid, newGrid, Math.min(cols, newCols), Math.min(rows, newRows))
1057
707
 
1058
- mainGrid = newMain
1059
- altGrid = newAlt
1060
- grid = useAltScreen ? altGrid : mainGrid
708
+ grid = newGrid
1061
709
  cols = newCols
1062
710
  rows = newRows
1063
711
  scrollTop = 0
@@ -1108,13 +756,7 @@ export function createScreen(opts: ScreenOptions): Screen {
1108
756
  let line = ""
1109
757
  for (let i = 0; i < row.length; i++) {
1110
758
  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
- }
759
+ if (cell.char === "") {
1118
760
  line += " "
1119
761
  } else {
1120
762
  line += cell.char
@@ -1137,7 +779,6 @@ export function createScreen(opts: ScreenOptions): Screen {
1137
779
  for (let col = colStart; col < colEnd; col++) {
1138
780
  const cell = r[col]
1139
781
  if (!cell) continue
1140
- if (cell.char === "" && col > 0 && r[col - 1]?.wide) continue // Skip spacer
1141
782
  line += cell.char || " "
1142
783
  }
1143
784
  parts.push(line.replace(/\s+$/, ""))
@@ -1148,26 +789,16 @@ export function createScreen(opts: ScreenOptions): Screen {
1148
789
 
1149
790
  function getMode(mode: string): boolean {
1150
791
  switch (mode) {
1151
- case "altScreen":
1152
- return useAltScreen
1153
792
  case "cursorVisible":
1154
793
  return curVisible
1155
- case "bracketedPaste":
1156
- return bracketedPaste
1157
794
  case "applicationCursor":
1158
795
  return applicationCursor
1159
796
  case "applicationKeypad":
1160
797
  return applicationKeypad
1161
798
  case "autoWrap":
1162
799
  return autoWrap
1163
- case "mouseTracking":
1164
- return mouseTracking
1165
- case "focusTracking":
1166
- return focusTracking
1167
800
  case "originMode":
1168
801
  return originMode
1169
- case "insertMode":
1170
- return insertMode
1171
802
  case "reverseVideo":
1172
803
  return reverseVideo
1173
804
  default: