vt100.js 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # vt100.js
2
+
3
+ Pure TypeScript VT100 terminal emulator. Zero dependencies, headless, fast.
4
+
5
+ Part of the [vterm](https://github.com/beorn/vterm) monorepo.
6
+
7
+ ## Features
8
+
9
+ - Full VT100/ANSI escape sequence parsing
10
+ - SGR attributes (bold, italic, underline styles, colors, strikethrough, etc.)
11
+ - 16-color, 256-color, and 24-bit truecolor support
12
+ - Cursor movement, save/restore (DECSC/DECRC)
13
+ - Screen modes (alternate screen, auto-wrap, origin mode, etc.)
14
+ - Scroll regions (DECSTBM) with content preservation
15
+ - Scrollback buffer with configurable limit
16
+ - Insert/delete characters and lines
17
+ - Wide character support (CJK, emoji)
18
+ - Zero dependencies — works in Bun, Node.js, and browsers
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install vt100.js
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```typescript
29
+ import { createVt100Screen } from "vt100.js"
30
+
31
+ const screen = createVt100Screen({ cols: 80, rows: 24 })
32
+ screen.process(new TextEncoder().encode("Hello, \x1b[1mBold\x1b[0m World!"))
33
+
34
+ console.log(screen.getText()) // "Hello, Bold World!"
35
+ console.log(screen.getCell(0, 7).bold) // true
36
+ console.log(screen.getCursorPosition()) // { x: 18, y: 0 }
37
+ ```
38
+
39
+ ## API
40
+
41
+ ### `createVt100Screen(options)`
42
+
43
+ | Option | Type | Default | Description |
44
+ | ----------------- | -------- | -------- | -------------------- |
45
+ | `cols` | `number` | required | Terminal width |
46
+ | `rows` | `number` | required | Terminal height |
47
+ | `scrollbackLimit` | `number` | `1000` | Max scrollback lines |
48
+
49
+ ### Screen methods
50
+
51
+ | Method | Description |
52
+ | ------------------------------ | ---------------------------------- |
53
+ | `process(data: Uint8Array)` | Feed raw terminal data |
54
+ | `getText()` | Get all text (scrollback + screen) |
55
+ | `getTextRange(sr, sc, er, ec)` | Get text in a range |
56
+ | `getLine(row)` | Get cells for a row |
57
+ | `getCell(row, col)` | Get a single cell |
58
+ | `getCursorPosition()` | Get cursor `{ x, y }` |
59
+ | `getCursorVisible()` | Check cursor visibility |
60
+ | `getMode(mode)` | Check terminal mode |
61
+ | `getTitle()` | Get window title |
62
+ | `getScrollbackLength()` | Number of scrollback lines |
63
+ | `getViewportOffset()` | Current viewport scroll offset |
64
+ | `scrollViewport(delta)` | Scroll viewport |
65
+ | `resize(cols, rows)` | Resize terminal |
66
+ | `reset()` | Reset to initial state |
67
+
68
+ ### Cell properties
69
+
70
+ ```typescript
71
+ interface ScreenCell {
72
+ char: string
73
+ fg: CellColor | null // { r, g, b }
74
+ bg: CellColor | null
75
+ bold: boolean
76
+ faint: boolean
77
+ italic: boolean
78
+ underline: "none" | "single" | "double" | "curly" | "dotted" | "dashed"
79
+ strikethrough: boolean
80
+ inverse: boolean
81
+ hidden: boolean
82
+ wide: boolean
83
+ }
84
+ ```
85
+
86
+ ## See also
87
+
88
+ - [vterm.js](../vterm/) — full-featured modern emulator (100% terminfo.dev coverage)
89
+ - [Termless](https://termless.dev) — headless terminal testing
90
+ - [Terminfo.dev](https://terminfo.dev) — terminal feature support tables
91
+
92
+ ## License
93
+
94
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vt100.js",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Pure TypeScript VT100 terminal emulator. Zero dependencies, headless, fast.",
5
5
  "keywords": [
6
6
  "ansi",
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
- * Pure TypeScript VT100 terminal emulator.
2
+ * Pure TypeScript VT100/VT220 terminal emulator.
3
3
  *
4
4
  * Inspired by the Rust vt100 crate — parses a terminal byte stream and
5
5
  * maintains an in-memory screen representation with per-cell attributes,
6
6
  * cursor tracking, and mode state.
7
7
  *
8
- * Zero dependencies. Handles: SGR (16/256/truecolor), cursor movement,
9
- * erase, scroll regions, alternate screen, modes, OSC title, and more.
8
+ * Zero dependencies. Handles: SGR (8 standard colors, bold, underline, blink,
9
+ * reverse, hidden), cursor movement, erase, scroll regions, modes, OSC title,
10
+ * DA1/DSR responses, and more. No truecolor, no 256 colors, no wide chars —
11
+ * those belong in 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,13 +38,10 @@ 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 {
@@ -55,10 +49,10 @@ function emptyCell(): ScreenCell {
55
49
  }
56
50
 
57
51
  // ═══════════════════════════════════════════════════════
58
- // ANSI 256-color palette
52
+ // ANSI 8-color palette (standard VT100/VT220 colors)
59
53
  // ═══════════════════════════════════════════════════════
60
54
 
61
- const ANSI_16: readonly CellColor[] = [
55
+ const ANSI_8: readonly CellColor[] = [
62
56
  { r: 0x00, g: 0x00, b: 0x00 }, // 0 Black
63
57
  { r: 0x80, g: 0x00, b: 0x00 }, // 1 Red
64
58
  { r: 0x00, g: 0x80, b: 0x00 }, // 2 Green
@@ -67,61 +61,8 @@ const ANSI_16: readonly CellColor[] = [
67
61
  { r: 0x80, g: 0x00, b: 0x80 }, // 5 Magenta
68
62
  { r: 0x00, g: 0x80, b: 0x80 }, // 6 Cyan
69
63
  { 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
64
  ]
79
65
 
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
66
  // ═══════════════════════════════════════════════════════
126
67
  // Screen
127
68
  // ═══════════════════════════════════════════════════════
@@ -130,16 +71,16 @@ export interface ScreenOptions {
130
71
  cols: number
131
72
  rows: number
132
73
  scrollbackLimit?: number
74
+ /** Callback for DA1/DSR responses — write these back to the PTY */
75
+ onResponse?: (data: string) => void
133
76
  }
134
77
 
135
78
  interface Attrs {
136
79
  fg: CellColor | null
137
80
  bg: CellColor | null
138
81
  bold: boolean
139
- faint: boolean
140
- italic: boolean
141
- underline: UnderlineStyle
142
- strikethrough: boolean
82
+ underline: boolean
83
+ blink: boolean
143
84
  inverse: boolean
144
85
  hidden: boolean
145
86
  }
@@ -167,11 +108,10 @@ export function createScreen(opts: ScreenOptions): Screen {
167
108
  let cols = opts.cols
168
109
  let rows = opts.rows
169
110
  const scrollbackLimit = opts.scrollbackLimit ?? 1000
111
+ const onResponse = opts.onResponse
170
112
 
171
- // Main and alternate screen buffers
172
- let mainGrid: ScreenCell[][] = makeGrid(cols, rows)
173
- let altGrid: ScreenCell[][] = makeGrid(cols, rows)
174
- let grid = mainGrid
113
+ // Main screen buffer (no alternate screen in VT100/VT220)
114
+ let grid: ScreenCell[][] = makeGrid(cols, rows)
175
115
  let scrollback: ScreenCell[][] = []
176
116
 
177
117
  // Cursor
@@ -202,13 +142,9 @@ export function createScreen(opts: ScreenOptions): Screen {
202
142
 
203
143
  // Terminal state
204
144
  let title = ""
205
- let useAltScreen = false
206
- let bracketedPaste = false
207
145
  let applicationCursor = false
208
146
  let applicationKeypad = false
209
147
  let autoWrap = true
210
- let mouseTracking = false
211
- let focusTracking = false
212
148
  let originMode = false
213
149
  let insertMode = false
214
150
  let reverseVideo = false
@@ -249,10 +185,8 @@ export function createScreen(opts: ScreenOptions): Screen {
249
185
  fg: null,
250
186
  bg: null,
251
187
  bold: false,
252
- faint: false,
253
- italic: false,
254
- underline: "none",
255
- strikethrough: false,
188
+ underline: false,
189
+ blink: false,
256
190
  inverse: false,
257
191
  hidden: false,
258
192
  }
@@ -268,8 +202,8 @@ export function createScreen(opts: ScreenOptions): Screen {
268
202
  // ── Scrolling ──
269
203
 
270
204
  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) {
205
+ // Move top row to scrollback (only if top of screen)
206
+ if (top === 0) {
273
207
  scrollback.push(grid[0]!)
274
208
  // Bulk trim when exceeding 2x limit to avoid O(n) shift() on every scroll
275
209
  if (scrollback.length > scrollbackLimit * 2) {
@@ -293,12 +227,8 @@ export function createScreen(opts: ScreenOptions): Screen {
293
227
  // ── Character writing ──
294
228
 
295
229
  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
230
  // Handle autowrap at end of line
301
- if (curX + charWidth > cols) {
231
+ if (curX >= cols) {
302
232
  if (autoWrap) {
303
233
  curX = 0
304
234
  curY++
@@ -307,17 +237,15 @@ export function createScreen(opts: ScreenOptions): Screen {
307
237
  scrollUp(scrollTop, scrollBottom)
308
238
  }
309
239
  } else {
310
- curX = cols - charWidth
240
+ curX = cols - 1
311
241
  }
312
242
  }
313
243
 
314
244
  // Insert mode: shift existing characters right before writing
315
245
  if (insertMode) {
316
246
  const row = grid[curY]!
317
- for (let i = 0; i < charWidth; i++) {
318
- row.splice(curX, 0, EMPTY_CELL)
319
- row.pop()
320
- }
247
+ row.splice(curX, 0, EMPTY_CELL)
248
+ row.pop()
321
249
  }
322
250
 
323
251
  // Copy-on-write: if cell is the shared EMPTY_CELL sentinel, create a fresh object
@@ -331,35 +259,12 @@ export function createScreen(opts: ScreenOptions): Screen {
331
259
  cell.fg = attrs.fg ? { ...attrs.fg } : null
332
260
  cell.bg = attrs.bg ? { ...attrs.bg } : null
333
261
  cell.bold = attrs.bold
334
- cell.faint = attrs.faint
335
- cell.italic = attrs.italic
336
262
  cell.underline = attrs.underline
337
- cell.strikethrough = attrs.strikethrough
263
+ cell.blink = attrs.blink
338
264
  cell.inverse = attrs.inverse
339
265
  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
266
 
362
- curX += charWidth
267
+ curX++
363
268
  }
364
269
 
365
270
  // ── CSI handler ──
@@ -454,9 +359,24 @@ export function createScreen(opts: ScreenOptions): Screen {
454
359
  curX = 0
455
360
  curY = originMode ? scrollTop : 0
456
361
  break
457
- case "n": // DSR - Device Status Report (ignore)
362
+ case "n": // DSR - Device Status Report
363
+ if (onResponse) {
364
+ if (parts[0] === 5) {
365
+ // Status report - OK
366
+ onResponse("\x1b[0n")
367
+ } else if (parts[0] === 6) {
368
+ // CPR - Cursor position report (1-based)
369
+ onResponse(`\x1b[${curY + 1};${curX + 1}R`)
370
+ }
371
+ }
458
372
  break
459
- case "c": // DA - Device Attributes (ignore)
373
+ case "c": // DA1 - Primary Device Attributes
374
+ if (onResponse) {
375
+ if (params === "" || params === "0") {
376
+ // VT100 with Advanced Video Option (AVO)
377
+ onResponse("\x1b[?1;2c")
378
+ }
379
+ }
460
380
  break
461
381
  case "s": // SCP - Save Cursor Position
462
382
  savedCurX = curX
@@ -473,6 +393,14 @@ export function createScreen(opts: ScreenOptions): Screen {
473
393
  }
474
394
  }
475
395
 
396
+ function handleCSIWithIntermediate(_params: string, intermediate: string, finalByte: string): void {
397
+ if (intermediate === "!" && finalByte === "p") {
398
+ // DECSTR - Soft Terminal Reset
399
+ softReset()
400
+ }
401
+ // Unknown intermediate sequences — ignore
402
+ }
403
+
476
404
  function handleCSIPrivate(params: string, finalByte: string): void {
477
405
  const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
478
406
  const set = finalByte === "h"
@@ -482,6 +410,12 @@ export function createScreen(opts: ScreenOptions): Screen {
482
410
  case 1: // DECCKM - Application Cursor
483
411
  applicationCursor = set
484
412
  break
413
+ case 4: // IRM - Insert Mode (via DEC private)
414
+ insertMode = set
415
+ break
416
+ case 5: // DECSCNM - Reverse Video
417
+ reverseVideo = set
418
+ break
485
419
  case 6: // DECOM - Origin Mode
486
420
  originMode = set
487
421
  break
@@ -491,51 +425,21 @@ export function createScreen(opts: ScreenOptions): Screen {
491
425
  case 25: // DECTCEM - Cursor Visible
492
426
  curVisible = set
493
427
  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
428
  case 66: // DECNKM - Application Keypad
505
429
  applicationKeypad = set
506
430
  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)
431
+ }
432
+ }
433
+ }
434
+
435
+ // Also handle standard (non-private) set/reset modes: CSI Ps h / CSI Ps l
436
+ function handleSetResetMode(params: string, finalByte: string): void {
437
+ const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
438
+ const set = finalByte === "h"
439
+
440
+ for (const code of parts) {
441
+ switch (code) {
442
+ case 4: // IRM - Insert/Replace Mode
539
443
  insertMode = set
540
444
  break
541
445
  }
@@ -635,20 +539,7 @@ export function createScreen(opts: ScreenOptions): Screen {
635
539
  // ── SGR (Select Graphic Rendition) ──
636
540
 
637
541
  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
- }
542
+ const params = rawParams.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
652
543
 
653
544
  if (params.length === 0 || (params.length === 1 && params[0] === 0)) {
654
545
  attrs = resetAttrs()
@@ -665,66 +556,26 @@ export function createScreen(opts: ScreenOptions): Screen {
665
556
  case 1:
666
557
  attrs.bold = true
667
558
  break
668
- case 2:
669
- attrs.faint = true
559
+ case 4:
560
+ attrs.underline = true
670
561
  break
671
- case 3:
672
- attrs.italic = true
562
+ case 5: // Blink
563
+ attrs.blink = true
673
564
  break
674
- case 4: {
675
- // SGR 4 with optional sub-parameter: 4:0=none, 4:1=single, 4:3=curly, etc.
676
- const subs = subParams.get(i)
677
- if (subs && subs.length > 1) {
678
- const sub = subs[1]!
679
- switch (sub) {
680
- case 0:
681
- attrs.underline = "none"
682
- break
683
- case 1:
684
- attrs.underline = "single"
685
- break
686
- case 2:
687
- attrs.underline = "double"
688
- break
689
- case 3:
690
- attrs.underline = "curly"
691
- break
692
- case 4:
693
- attrs.underline = "dotted"
694
- break
695
- case 5:
696
- attrs.underline = "dashed"
697
- break
698
- default:
699
- attrs.underline = "single"
700
- break
701
- }
702
- } else {
703
- attrs.underline = "single"
704
- }
705
- break
706
- }
707
565
  case 7:
708
566
  attrs.inverse = true
709
567
  break
710
568
  case 8: // Hidden/conceal
711
569
  attrs.hidden = true
712
570
  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)
571
+ case 22: // Normal intensity (turn off bold)
720
572
  attrs.bold = false
721
- attrs.faint = false
722
- break
723
- case 23:
724
- attrs.italic = false
725
573
  break
726
574
  case 24:
727
- attrs.underline = "none"
575
+ attrs.underline = false
576
+ break
577
+ case 25: // Blink off
578
+ attrs.blink = false
728
579
  break
729
580
  case 27:
730
581
  attrs.inverse = false
@@ -732,9 +583,6 @@ export function createScreen(opts: ScreenOptions): Screen {
732
583
  case 28: // Reveal (turn off hidden/conceal)
733
584
  attrs.hidden = false
734
585
  break
735
- case 29:
736
- attrs.strikethrough = false
737
- break
738
586
  // Foreground colors 30-37
739
587
  case 30:
740
588
  case 31:
@@ -744,17 +592,8 @@ export function createScreen(opts: ScreenOptions): Screen {
744
592
  case 35:
745
593
  case 36:
746
594
  case 37:
747
- attrs.fg = { ...PALETTE_256[code - 30]! }
748
- break
749
- case 38: {
750
- // Extended foreground: 38;5;N (256) or 38;2;R;G;B (truecolor)
751
- const result = parseExtendedColor(params, i)
752
- if (result) {
753
- attrs.fg = result.color
754
- i = result.nextIndex - 1 // -1 because loop increments
755
- }
595
+ attrs.fg = { ...ANSI_8[code - 30]! }
756
596
  break
757
- }
758
597
  case 39: // Default foreground
759
598
  attrs.fg = null
760
599
  break
@@ -767,70 +606,25 @@ export function createScreen(opts: ScreenOptions): Screen {
767
606
  case 45:
768
607
  case 46:
769
608
  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
- }
609
+ attrs.bg = { ...ANSI_8[code - 40]! }
779
610
  break
780
- }
781
611
  case 49: // Default background
782
612
  attrs.bg = null
783
613
  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]! }
614
+ // Skip extended color sequences (not supported, but must consume params)
615
+ case 38:
616
+ case 48:
617
+ if (i + 1 < params.length && params[i + 1] === 2) {
618
+ i += 4 // skip 38;2;R;G;B or 48;2;R;G;B
619
+ } else if (i + 1 < params.length && params[i + 1] === 5) {
620
+ i += 2 // skip 38;5;N or 48;5;N
621
+ }
805
622
  break
806
623
  }
807
624
  i++
808
625
  }
809
626
  }
810
627
 
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
628
  // ── OSC handler ──
835
629
 
836
630
  function handleOSC(oscString: string): void {
@@ -883,16 +677,7 @@ export function createScreen(opts: ScreenOptions): Screen {
883
677
  // CR - Carriage Return
884
678
  curX = 0
885
679
  } 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)
680
+ writeChar(ch)
896
681
  }
897
682
  break
898
683
 
@@ -957,6 +742,14 @@ export function createScreen(opts: ScreenOptions): Screen {
957
742
  scrollUp(scrollTop, scrollBottom)
958
743
  }
959
744
  parserState = "ground"
745
+ } else if (ch === "=") {
746
+ // DECKPAM - Application Keypad Mode
747
+ applicationKeypad = true
748
+ parserState = "ground"
749
+ } else if (ch === ">") {
750
+ // DECKPNM - Normal Keypad Mode
751
+ applicationKeypad = false
752
+ parserState = "ground"
960
753
  } else {
961
754
  // Unknown escape — return to ground
962
755
  parserState = "ground"
@@ -968,8 +761,27 @@ export function createScreen(opts: ScreenOptions): Screen {
968
761
  // Final byte — dispatch CSI
969
762
  if (escBuf.startsWith("?")) {
970
763
  handleCSIPrivate(escBuf.substring(1), ch)
764
+ } else if (ch === "h" || ch === "l") {
765
+ // Standard set/reset mode (non-private)
766
+ handleSetResetMode(escBuf, ch)
971
767
  } else {
972
- handleCSI(escBuf, ch)
768
+ // Check for intermediate bytes (0x20-0x2F range, e.g., "!" in CSI ! p)
769
+ // Find first intermediate byte in escBuf
770
+ let intermediateIdx = -1
771
+ for (let j = 0; j < escBuf.length; j++) {
772
+ const c = escBuf.charCodeAt(j)
773
+ if (c >= 0x20 && c <= 0x2f) {
774
+ intermediateIdx = j
775
+ break
776
+ }
777
+ }
778
+ if (intermediateIdx >= 0) {
779
+ const paramPart = escBuf.substring(0, intermediateIdx)
780
+ const intermediatePart = escBuf.substring(intermediateIdx)
781
+ handleCSIWithIntermediate(paramPart, intermediatePart, ch)
782
+ } else {
783
+ handleCSI(escBuf, ch)
784
+ }
973
785
  }
974
786
  parserState = "ground"
975
787
  } else if (escBuf.length >= 256) {
@@ -1017,9 +829,7 @@ export function createScreen(opts: ScreenOptions): Screen {
1017
829
  }
1018
830
 
1019
831
  function fullReset(): void {
1020
- mainGrid = makeGrid(cols, rows)
1021
- altGrid = makeGrid(cols, rows)
1022
- grid = mainGrid
832
+ grid = makeGrid(cols, rows)
1023
833
  scrollback = []
1024
834
  curX = 0
1025
835
  curY = 0
@@ -1029,13 +839,9 @@ export function createScreen(opts: ScreenOptions): Screen {
1029
839
  savedState = { curX: 0, curY: 0, attrs: resetAttrs(), originMode: false, autoWrap: true }
1030
840
  attrs = resetAttrs()
1031
841
  title = ""
1032
- useAltScreen = false
1033
- bracketedPaste = false
1034
842
  applicationCursor = false
1035
843
  applicationKeypad = false
1036
844
  autoWrap = true
1037
- mouseTracking = false
1038
- focusTracking = false
1039
845
  originMode = false
1040
846
  insertMode = false
1041
847
  reverseVideo = false
@@ -1047,17 +853,29 @@ export function createScreen(opts: ScreenOptions): Screen {
1047
853
  oscBuf = ""
1048
854
  }
1049
855
 
856
+ function softReset(): void {
857
+ attrs = resetAttrs()
858
+ applicationCursor = false
859
+ applicationKeypad = false
860
+ autoWrap = true
861
+ originMode = false
862
+ insertMode = false
863
+ reverseVideo = false
864
+ curVisible = true
865
+ scrollTop = 0
866
+ scrollBottom = rows - 1
867
+ savedState = { curX: 0, curY: 0, attrs: resetAttrs(), originMode: false, autoWrap: true }
868
+ savedCurX = 0
869
+ savedCurY = 0
870
+ }
871
+
1050
872
  function resize(newCols: number, newRows: number): void {
1051
- const newMain = makeGrid(newCols, newRows)
1052
- const newAlt = makeGrid(newCols, newRows)
873
+ const newGrid = makeGrid(newCols, newRows)
1053
874
 
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))
875
+ // Copy content from old grid
876
+ copyGrid(grid, newGrid, Math.min(cols, newCols), Math.min(rows, newRows))
1057
877
 
1058
- mainGrid = newMain
1059
- altGrid = newAlt
1060
- grid = useAltScreen ? altGrid : mainGrid
878
+ grid = newGrid
1061
879
  cols = newCols
1062
880
  rows = newRows
1063
881
  scrollTop = 0
@@ -1108,13 +926,7 @@ export function createScreen(opts: ScreenOptions): Screen {
1108
926
  let line = ""
1109
927
  for (let i = 0; i < row.length; i++) {
1110
928
  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
- }
929
+ if (cell.char === "") {
1118
930
  line += " "
1119
931
  } else {
1120
932
  line += cell.char
@@ -1137,7 +949,6 @@ export function createScreen(opts: ScreenOptions): Screen {
1137
949
  for (let col = colStart; col < colEnd; col++) {
1138
950
  const cell = r[col]
1139
951
  if (!cell) continue
1140
- if (cell.char === "" && col > 0 && r[col - 1]?.wide) continue // Skip spacer
1141
952
  line += cell.char || " "
1142
953
  }
1143
954
  parts.push(line.replace(/\s+$/, ""))
@@ -1148,22 +959,14 @@ export function createScreen(opts: ScreenOptions): Screen {
1148
959
 
1149
960
  function getMode(mode: string): boolean {
1150
961
  switch (mode) {
1151
- case "altScreen":
1152
- return useAltScreen
1153
962
  case "cursorVisible":
1154
963
  return curVisible
1155
- case "bracketedPaste":
1156
- return bracketedPaste
1157
964
  case "applicationCursor":
1158
965
  return applicationCursor
1159
966
  case "applicationKeypad":
1160
967
  return applicationKeypad
1161
968
  case "autoWrap":
1162
969
  return autoWrap
1163
- case "mouseTracking":
1164
- return mouseTracking
1165
- case "focusTracking":
1166
- return focusTracking
1167
970
  case "originMode":
1168
971
  return originMode
1169
972
  case "insertMode":