vterm.js 0.3.0 → 0.4.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/src/screen.ts DELETED
@@ -1,2355 +0,0 @@
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
- * - Kitty keyboard protocol (CSI u / progressive enhancement)
20
- * - Scrollback buffer with configurable limit
21
- * - Wide character support (CJK, emoji ZWJ, regional indicators, VS-16)
22
- * - OSC sequences (title, hyperlinks, clipboard, colors)
23
- * - DCS sequences (XTVERSION, DECRQSS, XTGETTCAP, Sixel)
24
- * - APC sequences (Kitty graphics protocol — parsed, query responses)
25
- * - DA1/DA2/DA3 device attribute responses
26
- * - DSR (device status report) responses
27
- * - DECRPM (mode reporting)
28
- * - Character sets (DEC Special Graphics, UTF-8)
29
- * - Full C0/C1 control code handling
30
- * - DECSTR soft terminal reset
31
- *
32
- * @see https://terminfo.dev for the feature matrix
33
- * @see https://github.com/beorn/vterm for the monorepo
34
- */
35
-
36
- // ── Types ──────────────────────────────────────────────────────────────
37
-
38
- export interface CellColor {
39
- r: number
40
- g: number
41
- b: number
42
- }
43
-
44
- export type UnderlineStyle = "none" | "single" | "double" | "curly" | "dotted" | "dashed"
45
-
46
- export interface ScreenCell {
47
- char: string
48
- fg: CellColor | null
49
- bg: CellColor | null
50
- bold: boolean
51
- faint: boolean
52
- italic: boolean
53
- underline: UnderlineStyle
54
- underlineColor: CellColor | null
55
- overline: boolean
56
- strikethrough: boolean
57
- inverse: boolean
58
- hidden: boolean
59
- blink: boolean
60
- wide: boolean
61
- url: string | null
62
- }
63
-
64
- export interface ScreenOptions {
65
- cols?: number
66
- rows?: number
67
- scrollbackLimit?: number
68
- /** Callback for DA1/DA2/DSR responses — write these back to the PTY */
69
- onResponse?: (data: string) => void
70
- }
71
-
72
- export interface SemanticZone {
73
- type: "prompt" | "command" | "output"
74
- startRow: number
75
- startCol: number
76
- }
77
-
78
- export interface SixelImage {
79
- data: string
80
- row: number // cursor row when sixel started
81
- col: number // cursor col when sixel started
82
- }
83
-
84
- export interface Screen {
85
- readonly cols: number
86
- readonly rows: number
87
-
88
- process(data: Uint8Array): void
89
- resize(cols: number, rows: number): void
90
- reset(): void
91
-
92
- getCell(row: number, col: number): ScreenCell
93
- getLine(row: number): ScreenCell[]
94
- getText(): string
95
- getTextRange(startRow: number, startCol: number, endRow: number, endCol: number): string
96
-
97
- getCursorPosition(): { x: number; y: number }
98
- getCursorVisible(): boolean
99
- getCursorShape(): "block" | "underline" | "bar"
100
- getCursorBlinking(): boolean
101
-
102
- getTitle(): string
103
- getMode(mode: string): boolean
104
- getClipboard(): string
105
- getCwd(): string
106
- getNotifications(): string[]
107
-
108
- getScrollbackLength(): number
109
- getViewportOffset(): number
110
- scrollViewport(delta: number): void
111
-
112
- getSemanticZones(): SemanticZone[]
113
- getSixelImages(): SixelImage[]
114
- }
115
-
116
- // ── Implementation ─────────────────────────────────────────────────────
117
-
118
- /** Frozen sentinel for unwritten cells — never mutate, copy-on-write in writeChar(). */
119
- const EMPTY_CELL: ScreenCell = Object.freeze({
120
- char: "",
121
- fg: null,
122
- bg: null,
123
- bold: false,
124
- faint: false,
125
- italic: false,
126
- underline: "none" as UnderlineStyle,
127
- underlineColor: null,
128
- overline: false,
129
- strikethrough: false,
130
- inverse: false,
131
- hidden: false,
132
- blink: false,
133
- wide: false,
134
- url: null,
135
- })
136
-
137
- function emptyCell(): ScreenCell {
138
- return { ...EMPTY_CELL }
139
- }
140
-
141
- // ── ANSI 256-color palette ─────────────────────────────────────────────
142
-
143
- const ANSI_16: readonly CellColor[] = [
144
- { r: 0x00, g: 0x00, b: 0x00 }, // 0 Black
145
- { r: 0x80, g: 0x00, b: 0x00 }, // 1 Red
146
- { r: 0x00, g: 0x80, b: 0x00 }, // 2 Green
147
- { r: 0x80, g: 0x80, b: 0x00 }, // 3 Yellow
148
- { r: 0x00, g: 0x00, b: 0x80 }, // 4 Blue
149
- { r: 0x80, g: 0x00, b: 0x80 }, // 5 Magenta
150
- { r: 0x00, g: 0x80, b: 0x80 }, // 6 Cyan
151
- { r: 0xc0, g: 0xc0, b: 0xc0 }, // 7 White
152
- { r: 0x80, g: 0x80, b: 0x80 }, // 8 Bright Black
153
- { r: 0xff, g: 0x00, b: 0x00 }, // 9 Bright Red
154
- { r: 0x00, g: 0xff, b: 0x00 }, // 10 Bright Green
155
- { r: 0xff, g: 0xff, b: 0x00 }, // 11 Bright Yellow
156
- { r: 0x00, g: 0x00, b: 0xff }, // 12 Bright Blue
157
- { r: 0xff, g: 0x00, b: 0xff }, // 13 Bright Magenta
158
- { r: 0x00, g: 0xff, b: 0xff }, // 14 Bright Cyan
159
- { r: 0xff, g: 0xff, b: 0xff }, // 15 Bright White
160
- ]
161
-
162
- function buildPalette256(): CellColor[] {
163
- const palette: CellColor[] = [...ANSI_16]
164
- const levels = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]
165
- for (let r = 0; r < 6; r++) {
166
- for (let g = 0; g < 6; g++) {
167
- for (let b = 0; b < 6; b++) {
168
- palette.push({ r: levels[r]!, g: levels[g]!, b: levels[b]! })
169
- }
170
- }
171
- }
172
- for (let i = 0; i < 24; i++) {
173
- const v = 8 + i * 10
174
- palette.push({ r: v, g: v, b: v })
175
- }
176
- return palette
177
- }
178
-
179
- const PALETTE_256 = buildPalette256()
180
-
181
- // ── DEC Special Graphics character set ─────────────────────────────────
182
-
183
- const DEC_SPECIAL_GRAPHICS: Record<string, string> = {
184
- j: "\u2518", // ┘
185
- k: "\u2510", // ┐
186
- l: "\u250c", // ┌
187
- m: "\u2514", // └
188
- n: "\u253c", // ┼
189
- q: "\u2500", // ─
190
- t: "\u251c", // ├
191
- u: "\u2524", // ┤
192
- v: "\u2534", // ┴
193
- w: "\u252c", // ┬
194
- x: "\u2502", // │
195
- a: "\u2592", // ▒
196
- f: "\u00b0", // °
197
- g: "\u00b1", // ±
198
- "~": "\u00b7", // ·
199
- y: "\u2264", // ≤
200
- z: "\u2265", // ≥
201
- "{": "\u03c0", // π
202
- "|": "\u2260", // ≠
203
- "}": "\u00a3", // £
204
- }
205
-
206
- // ── Unicode width & character classification ────────────────────────────
207
-
208
- function isWide(codePoint: number): boolean {
209
- return (
210
- (codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
211
- (codePoint >= 0x231a && codePoint <= 0x231b) || // Watch, Hourglass
212
- (codePoint >= 0x2614 && codePoint <= 0x2615) || // Umbrella, Hot Beverage
213
- (codePoint >= 0x2648 && codePoint <= 0x2653) || // Zodiac signs
214
- codePoint === 0x267f || // Wheelchair
215
- codePoint === 0x2693 || // Anchor
216
- codePoint === 0x26a1 || // High Voltage
217
- codePoint === 0x26ce || // Ophiuchus
218
- codePoint === 0x26d4 || // No Entry
219
- codePoint === 0x2705 || // Check Mark
220
- codePoint === 0x2728 || // Sparkles
221
- codePoint === 0x274c || // Cross Mark
222
- codePoint === 0x274e || // Cross Mark variant
223
- (codePoint >= 0x2753 && codePoint <= 0x2755) || // Question marks
224
- (codePoint >= 0x2795 && codePoint <= 0x2797) || // Plus, Minus, Division
225
- codePoint === 0x27b0 || // Curly Loop
226
- codePoint === 0x27bf || // Double Curly Loop
227
- (codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK Radicals
228
- (codePoint >= 0x3041 && codePoint <= 0x33bf) || // Hiragana, Katakana, Bopomofo, etc.
229
- (codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK Unified Extension A
230
- (codePoint >= 0x4e00 && codePoint <= 0xa4cf) || // CJK Unified Ideographs
231
- (codePoint >= 0xa960 && codePoint <= 0xa97c) || // Hangul Jamo Extended-A
232
- (codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul Syllables
233
- (codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK Compatibility Ideographs
234
- (codePoint >= 0xfe10 && codePoint <= 0xfe19) || // Vertical Forms
235
- (codePoint >= 0xfe30 && codePoint <= 0xfe6b) || // CJK Compatibility Forms
236
- (codePoint >= 0xff01 && codePoint <= 0xff60) || // Fullwidth Forms
237
- (codePoint >= 0xffe0 && codePoint <= 0xffe6) || // Fullwidth Signs
238
- codePoint === 0x1f004 || // Mahjong Tile
239
- codePoint === 0x1f0cf || // Playing Card
240
- (codePoint >= 0x1f170 && codePoint <= 0x1f171) || // A/B buttons
241
- (codePoint >= 0x1f17e && codePoint <= 0x1f17f) || // O/P buttons
242
- codePoint === 0x1f18e || // AB button
243
- (codePoint >= 0x1f191 && codePoint <= 0x1f19a) || // Squared symbols
244
- (codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff) || // Regional Indicators
245
- (codePoint >= 0x1f200 && codePoint <= 0x1f202) || // Enclosed CJK
246
- (codePoint >= 0x1f300 && codePoint <= 0x1f9ff) || // Misc Symbols/Emoticons
247
- (codePoint >= 0x1fa00 && codePoint <= 0x1faff) || // Extended Symbols & Pictographs
248
- (codePoint >= 0x20000 && codePoint <= 0x2fffd) || // CJK Extension B-F
249
- (codePoint >= 0x30000 && codePoint <= 0x3fffd) // CJK Extension G+
250
- )
251
- }
252
-
253
- function isCombining(cp: number): boolean {
254
- return (
255
- (cp >= 0x0300 && cp <= 0x036f) || // Combining Diacritical Marks
256
- (cp >= 0x0483 && cp <= 0x0489) || // Cyrillic combining
257
- (cp >= 0x0591 && cp <= 0x05bd) || // Hebrew
258
- cp === 0x05bf || // Hebrew
259
- (cp >= 0x05c1 && cp <= 0x05c2) || // Hebrew
260
- (cp >= 0x05c4 && cp <= 0x05c5) || // Hebrew
261
- cp === 0x05c7 || // Hebrew
262
- (cp >= 0x0610 && cp <= 0x061a) || // Arabic
263
- (cp >= 0x064b && cp <= 0x065f) || // Arabic
264
- cp === 0x0670 || // Arabic
265
- (cp >= 0x06d6 && cp <= 0x06dc) || // Arabic
266
- (cp >= 0x06df && cp <= 0x06e4) || // Arabic
267
- (cp >= 0x06e7 && cp <= 0x06e8) || // Arabic
268
- (cp >= 0x06ea && cp <= 0x06ed) || // Arabic
269
- (cp >= 0x0730 && cp <= 0x074a) || // Syriac
270
- (cp >= 0x0900 && cp <= 0x0903) || // Devanagari
271
- (cp >= 0x093a && cp <= 0x094f) || // Devanagari
272
- (cp >= 0x0951 && cp <= 0x0957) || // Devanagari
273
- (cp >= 0x0962 && cp <= 0x0963) || // Devanagari
274
- cp === 0x0e31 || // Thai
275
- (cp >= 0x0e34 && cp <= 0x0e3a) || // Thai
276
- (cp >= 0x0e47 && cp <= 0x0e4e) || // Thai
277
- (cp >= 0x1ab0 && cp <= 0x1aff) || // Combining Diacritical Marks Extended
278
- (cp >= 0x1dc0 && cp <= 0x1dff) || // Combining Diacritical Marks Supplement
279
- (cp >= 0x20d0 && cp <= 0x20ff) || // Combining Diacritical Marks for Symbols
280
- (cp >= 0xfe00 && cp <= 0xfe0f) || // Variation Selectors
281
- (cp >= 0xe0100 && cp <= 0xe01ef) // Variation Selectors Supplement
282
- )
283
- }
284
-
285
- function isRegionalIndicator(cp: number): boolean {
286
- return cp >= 0x1f1e6 && cp <= 0x1f1ff
287
- }
288
-
289
- function isZWJ(cp: number): boolean {
290
- return cp === 0x200d
291
- }
292
-
293
- function isVS16(cp: number): boolean {
294
- return cp === 0xfe0f
295
- }
296
-
297
- function isEmojiModifier(cp: number): boolean {
298
- return cp >= 0x1f3fb && cp <= 0x1f3ff // Skin tone modifiers
299
- }
300
-
301
- // ── Internal attrs interface ───────────────────────────────────────────
302
-
303
- interface Attrs {
304
- fg: CellColor | null
305
- bg: CellColor | null
306
- bold: boolean
307
- faint: boolean
308
- italic: boolean
309
- underline: UnderlineStyle
310
- underlineColor: CellColor | null
311
- overline: boolean
312
- strikethrough: boolean
313
- inverse: boolean
314
- hidden: boolean
315
- blink: boolean
316
- url: string | null
317
- }
318
-
319
- // ── Screen factory ─────────────────────────────────────────────────────
320
-
321
- export function createScreen(options: ScreenOptions = {}): Screen {
322
- let cols = options.cols ?? 80
323
- let rows = options.rows ?? 24
324
- const scrollbackLimit = options.scrollbackLimit ?? 1000
325
- const onResponse = options.onResponse
326
-
327
- // Main and alternate screen buffers
328
- let mainGrid: ScreenCell[][] = makeGrid(cols, rows)
329
- let altGrid: ScreenCell[][] = makeGrid(cols, rows)
330
- let grid = mainGrid
331
- let scrollback: ScreenCell[][] = []
332
-
333
- // Cursor
334
- let curX = 0
335
- let curY = 0
336
- let curVisible = true
337
- let cursorShape: "block" | "underline" | "bar" = "block"
338
- let cursorBlinking = true
339
-
340
- // DECSC/DECRC saved state
341
- interface SavedState {
342
- curX: number
343
- curY: number
344
- attrs: Attrs
345
- originMode: boolean
346
- autoWrap: boolean
347
- charsetG0: boolean // true = DEC Special Graphics
348
- }
349
- let savedState: SavedState = {
350
- curX: 0,
351
- curY: 0,
352
- attrs: resetAttrs(),
353
- originMode: false,
354
- autoWrap: true,
355
- charsetG0: false,
356
- }
357
-
358
- // Saved cursor for alt screen (separate from DECSC)
359
- let savedCurX = 0
360
- let savedCurY = 0
361
-
362
- // Current drawing attributes
363
- let attrs: Attrs = resetAttrs()
364
-
365
- // Terminal state
366
- let title = ""
367
- let useAltScreen = false
368
- let bracketedPaste = false
369
- let applicationCursor = false
370
- let applicationKeypad = false
371
- let autoWrap = true
372
- let mouseTracking = false
373
- let mouseTrackingMode = 0 // 1000, 1002, 1003
374
- let sgrMouse = false
375
- let focusTracking = false
376
- let originMode = false
377
- let insertMode = false
378
- let reverseVideo = false
379
- let syncOutput = false
380
-
381
- // Kitty keyboard protocol
382
- // Headless design: we implement the full push/pop/query state machine so that
383
- // applications can negotiate keyboard encoding modes. The flags are stored and
384
- // queryable (CSI ? u responds correctly), and a host can read them to decide
385
- // how to encode keypresses. The actual key encoding lives in the host layer
386
- // (e.g. termless's encodeKeyToAnsi), not here — separation of concerns.
387
- let kittyKeyboardFlags = 0
388
- let kittyKeyboardStack: number[] = []
389
-
390
- // Kitty graphics protocol
391
- // Headless design: we parse the APC G protocol and respond to queries so that
392
- // applications relying on the query→response handshake work correctly. We don't
393
- // store image data because there's no pixel framebuffer — unlike sixel where we
394
- // preserve the raw data for consumers, kitty graphics payloads are chunked and
395
- // stateful, making storage without rendering impractical.
396
- let hasKittyGraphics = false
397
-
398
- // Scroll region (inclusive, 0-based)
399
- let scrollTop = 0
400
- let scrollBottom = rows - 1
401
-
402
- // Viewport scroll offset
403
- let viewportOffset = 0
404
-
405
- // Character set: true = DEC Special Graphics (G0)
406
- let charsetG0 = false
407
-
408
- // Clipboard (OSC 52)
409
- let clipboard = ""
410
-
411
- // Current working directory (OSC 7)
412
- let cwd = ""
413
-
414
- // Notifications (OSC 9)
415
- let notifications: string[] = []
416
-
417
- // Semantic prompt zones (OSC 133 / OSC 633)
418
- let semanticZones: SemanticZone[] = []
419
-
420
- // Sixel graphics (DCS q)
421
- // Headless design: we parse sixel data and store it for consumers (e.g. a GUI
422
- // renderer could use getSixelImages() to display them). We don't decode pixels
423
- // because there's no framebuffer to render into — but preserving the data means
424
- // a host application gets full fidelity without re-parsing the stream.
425
- let hasSixel = false
426
- let sixelImages: SixelImage[] = []
427
-
428
- // Soft-wrap tracking: true if the line break at end of this row was caused by auto-wrap
429
- let mainSoftWrapped: boolean[] = new Array(rows).fill(false)
430
- let altSoftWrapped: boolean[] = new Array(rows).fill(false)
431
- let softWrapped = mainSoftWrapped
432
-
433
- // Last printed character for REP
434
- let lastChar = ""
435
-
436
- // Unicode sequence state
437
- let pendingRegionalIndicator: string | null = null // First RI waiting for pair
438
- let afterZWJ = false // Next character should join with previous cell
439
-
440
- // Parser state
441
- let parserState:
442
- | "ground"
443
- | "escape"
444
- | "escape_charset"
445
- | "csi"
446
- | "osc"
447
- | "dcs"
448
- | "dcs_passthrough"
449
- | "osc_st"
450
- | "dcs_st"
451
- | "apc"
452
- | "apc_st" = "ground"
453
- let escBuf = ""
454
- let oscBuf = ""
455
- let dcsBuf = ""
456
- let dcsStartRow = 0
457
- let dcsStartCol = 0
458
- let apcBuf = ""
459
-
460
- // Decoder for incoming bytes
461
- const decoder = new TextDecoder()
462
-
463
- // ── Grid helpers ──
464
-
465
- function makeGrid(c: number, r: number): ScreenCell[][] {
466
- const g: ScreenCell[][] = []
467
- for (let row = 0; row < r; row++) {
468
- g.push(makeRow(c))
469
- }
470
- return g
471
- }
472
-
473
- function makeRow(c: number): ScreenCell[] {
474
- const row: ScreenCell[] = []
475
- for (let col = 0; col < c; col++) {
476
- row.push(EMPTY_CELL)
477
- }
478
- return row
479
- }
480
-
481
- function resetAttrs(): Attrs {
482
- return {
483
- fg: null,
484
- bg: null,
485
- bold: false,
486
- faint: false,
487
- italic: false,
488
- underline: "none",
489
- underlineColor: null,
490
- overline: false,
491
- strikethrough: false,
492
- inverse: false,
493
- hidden: false,
494
- blink: false,
495
- url: null,
496
- }
497
- }
498
-
499
- function clampCursor(): void {
500
- if (curX < 0) curX = 0
501
- if (curX >= cols) curX = cols - 1
502
- if (curY < 0) curY = 0
503
- if (curY >= rows) curY = rows - 1
504
- }
505
-
506
- // ── Scrolling ──
507
-
508
- function scrollUp(top: number, bottom: number): void {
509
- // Move top row to scrollback (only if main screen & top of screen)
510
- if (grid === mainGrid && top === 0) {
511
- scrollback.push(grid[0]!)
512
- if (scrollback.length > scrollbackLimit * 2) {
513
- scrollback.splice(0, scrollback.length - scrollbackLimit)
514
- }
515
- }
516
- for (let i = top; i < bottom; i++) {
517
- grid[i] = grid[i + 1]!
518
- softWrapped[i] = softWrapped[i + 1]!
519
- }
520
- grid[bottom] = makeRow(cols)
521
- softWrapped[bottom] = false
522
- }
523
-
524
- function scrollDown(top: number, bottom: number): void {
525
- for (let i = bottom; i > top; i--) {
526
- grid[i] = grid[i - 1]!
527
- softWrapped[i] = softWrapped[i - 1]!
528
- }
529
- grid[top] = makeRow(cols)
530
- softWrapped[top] = false
531
- }
532
-
533
- // ── Character writing ──
534
-
535
- /** Find the previous non-spacer cell (the cell before curX, skipping wide-char spacers) */
536
- function getPrevCell(): { cell: ScreenCell; col: number } | null {
537
- if (curX === 0 && curY === 0) return null
538
- let prevCol = curX - 1
539
- let prevRow = curY
540
- if (prevCol < 0) {
541
- prevRow--
542
- if (prevRow < 0) return null
543
- prevCol = cols - 1
544
- }
545
- const row = grid[prevRow]!
546
- let cell = row[prevCol]!
547
- // If we landed on a spacer (empty char after a wide character), go back one more
548
- if (cell !== EMPTY_CELL && cell.char === "" && prevCol > 0) {
549
- prevCol--
550
- cell = row[prevCol]!
551
- }
552
- if (cell === EMPTY_CELL) return null
553
- return { cell, col: prevCol }
554
- }
555
-
556
- /** Widen a cell to 2 columns, adding a spacer cell after it */
557
- function widenCell(row: ScreenCell[], col: number, cell: ScreenCell): void {
558
- cell.wide = true
559
- if (col + 1 < cols) {
560
- let spacer = row[col + 1]!
561
- if (spacer === EMPTY_CELL) {
562
- spacer = { ...EMPTY_CELL }
563
- row[col + 1] = spacer
564
- }
565
- spacer.char = ""
566
- spacer.fg = null
567
- spacer.bg = null
568
- spacer.bold = false
569
- spacer.faint = false
570
- spacer.italic = false
571
- spacer.underline = "none"
572
- spacer.underlineColor = null
573
- spacer.overline = false
574
- spacer.strikethrough = false
575
- spacer.inverse = false
576
- spacer.hidden = false
577
- spacer.blink = false
578
- spacer.wide = false
579
- spacer.url = null
580
- }
581
- }
582
-
583
- function writeChar(ch: string): void {
584
- // Apply DEC Special Graphics character mapping
585
- if (charsetG0 && ch.length === 1) {
586
- const mapped = DEC_SPECIAL_GRAPHICS[ch]
587
- if (mapped) ch = mapped
588
- }
589
-
590
- const codePoint = ch.codePointAt(0) ?? 0
591
-
592
- // ── VS-16 (U+FE0F): widen previous character to emoji presentation ──
593
- if (isVS16(codePoint)) {
594
- const prev = getPrevCell()
595
- if (prev && !prev.cell.wide) {
596
- prev.cell.char += ch
597
- const row = grid[curY === 0 && curX === 0 ? 0 : curY]!
598
- widenCell(row, prev.col, prev.cell)
599
- // Advance cursor past the spacer
600
- curX = prev.col + 2
601
- if (curX >= cols) curX = cols - 1
602
- }
603
- return
604
- }
605
-
606
- // ── Combining characters: append to previous cell, zero width ──
607
- if (isCombining(codePoint) && !isVS16(codePoint)) {
608
- const prev = getPrevCell()
609
- if (prev) {
610
- prev.cell.char += ch
611
- }
612
- return
613
- }
614
-
615
- // ── Emoji modifier (skin tone): append to previous cell, zero width ──
616
- if (isEmojiModifier(codePoint)) {
617
- const prev = getPrevCell()
618
- if (prev) {
619
- prev.cell.char += ch
620
- }
621
- return
622
- }
623
-
624
- // ── ZWJ (U+200D): append to previous cell, flag for next char ──
625
- if (isZWJ(codePoint)) {
626
- const prev = getPrevCell()
627
- if (prev) {
628
- prev.cell.char += ch
629
- afterZWJ = true
630
- }
631
- return
632
- }
633
-
634
- // ── After ZWJ: append this character to the previous cell ──
635
- if (afterZWJ) {
636
- afterZWJ = false
637
- const prev = getPrevCell()
638
- if (prev) {
639
- prev.cell.char += ch
640
- // The ZWJ sequence stays in the same wide cell
641
- return
642
- }
643
- }
644
-
645
- // ── Regional Indicators: pair into flag emoji ──
646
- if (isRegionalIndicator(codePoint)) {
647
- if (pendingRegionalIndicator !== null) {
648
- // Second RI: combine with first to form a flag, render as wide
649
- const flag = pendingRegionalIndicator + ch
650
- pendingRegionalIndicator = null
651
- // Write the combined flag as a wide character
652
- writeCharCore(flag, true)
653
- return
654
- } else {
655
- // First RI: store and wait for second
656
- pendingRegionalIndicator = ch
657
- return
658
- }
659
- }
660
-
661
- // Flush any pending regional indicator that wasn't paired
662
- if (pendingRegionalIndicator !== null) {
663
- const ri = pendingRegionalIndicator
664
- pendingRegionalIndicator = null
665
- writeCharCore(ri, true)
666
- }
667
-
668
- const wide = isWide(codePoint)
669
- writeCharCore(ch, wide)
670
- }
671
-
672
- function writeCharCore(ch: string, wide: boolean): void {
673
- const charWidth = wide ? 2 : 1
674
-
675
- // Handle autowrap at end of line
676
- if (curX + charWidth > cols) {
677
- if (autoWrap) {
678
- // Mark this row as soft-wrapped (auto-wrap caused the line break)
679
- softWrapped[curY] = true
680
- curX = 0
681
- curY++
682
- if (curY > scrollBottom) {
683
- curY = scrollBottom
684
- scrollUp(scrollTop, scrollBottom)
685
- }
686
- } else {
687
- curX = cols - charWidth
688
- }
689
- }
690
-
691
- // Insert mode: shift existing characters right before writing
692
- if (insertMode) {
693
- const row = grid[curY]!
694
- for (let i = 0; i < charWidth; i++) {
695
- row.splice(curX, 0, EMPTY_CELL)
696
- row.pop()
697
- }
698
- }
699
-
700
- // Copy-on-write: if cell is the shared EMPTY_CELL sentinel, create a fresh object
701
- const row = grid[curY]!
702
- let cell = row[curX]!
703
- if (cell === EMPTY_CELL) {
704
- cell = { ...EMPTY_CELL }
705
- row[curX] = cell
706
- }
707
- cell.char = ch
708
- cell.fg = attrs.fg ? { ...attrs.fg } : null
709
- cell.bg = attrs.bg ? { ...attrs.bg } : null
710
- cell.bold = attrs.bold
711
- cell.faint = attrs.faint
712
- cell.italic = attrs.italic
713
- cell.underline = attrs.underline
714
- cell.underlineColor = attrs.underlineColor ? { ...attrs.underlineColor } : null
715
- cell.overline = attrs.overline
716
- cell.strikethrough = attrs.strikethrough
717
- cell.inverse = attrs.inverse
718
- cell.hidden = attrs.hidden
719
- cell.blink = attrs.blink
720
- cell.wide = wide
721
- cell.url = attrs.url
722
-
723
- if (wide) {
724
- widenCell(row, curX, cell)
725
- }
726
-
727
- curX += charWidth
728
- lastChar = ch
729
- }
730
-
731
- // ── CSI handler ──
732
-
733
- function handleCSI(params: string, intermediates: string, finalByte: string): void {
734
- const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
735
-
736
- // CSI with intermediates
737
- if (intermediates === " ") {
738
- if (finalByte === "q") {
739
- // DECSCUSR - Set Cursor Shape
740
- const ps = parts[0] ?? 0
741
- switch (ps) {
742
- case 0:
743
- case 1:
744
- cursorShape = "block"
745
- cursorBlinking = true
746
- break
747
- case 2:
748
- cursorShape = "block"
749
- cursorBlinking = false
750
- break
751
- case 3:
752
- cursorShape = "underline"
753
- cursorBlinking = true
754
- break
755
- case 4:
756
- cursorShape = "underline"
757
- cursorBlinking = false
758
- break
759
- case 5:
760
- cursorShape = "bar"
761
- cursorBlinking = true
762
- break
763
- case 6:
764
- cursorShape = "bar"
765
- cursorBlinking = false
766
- break
767
- }
768
- }
769
- return
770
- }
771
-
772
- if (intermediates === "!") {
773
- if (finalByte === "p") {
774
- // DECSTR - Soft Terminal Reset
775
- softReset()
776
- }
777
- return
778
- }
779
-
780
- if (intermediates === "$") {
781
- // Mode reporting is handled in CSI private
782
- return
783
- }
784
-
785
- switch (finalByte) {
786
- case "A": // CUU - Cursor Up
787
- curY -= Math.max(parts[0] ?? 1, 1)
788
- clampCursor()
789
- break
790
- case "B": // CUD - Cursor Down
791
- curY += Math.max(parts[0] ?? 1, 1)
792
- clampCursor()
793
- break
794
- case "C": // CUF - Cursor Forward
795
- curX += Math.max(parts[0] ?? 1, 1)
796
- clampCursor()
797
- break
798
- case "D": // CUB - Cursor Back
799
- curX -= Math.max(parts[0] ?? 1, 1)
800
- clampCursor()
801
- break
802
- case "E": // CNL - Cursor Next Line
803
- curY += Math.max(parts[0] ?? 1, 1)
804
- curX = 0
805
- clampCursor()
806
- break
807
- case "F": // CPL - Cursor Previous Line
808
- curY -= Math.max(parts[0] ?? 1, 1)
809
- curX = 0
810
- clampCursor()
811
- break
812
- case "G": // CHA - Cursor Horizontal Absolute
813
- curX = (parts[0] ?? 1) - 1
814
- clampCursor()
815
- break
816
- case "H": // CUP - Cursor Position
817
- case "f": // HVP - same as CUP
818
- if (originMode) {
819
- // DECOM: positions are relative to scroll region
820
- curY = scrollTop + (parts[0] ?? 1) - 1
821
- curX = (parts[1] ?? 1) - 1
822
- // Clamp to scroll region bounds
823
- if (curY < scrollTop) curY = scrollTop
824
- if (curY > scrollBottom) curY = scrollBottom
825
- if (curX < 0) curX = 0
826
- if (curX >= cols) curX = cols - 1
827
- } else {
828
- curY = (parts[0] ?? 1) - 1
829
- curX = (parts[1] ?? 1) - 1
830
- clampCursor()
831
- }
832
- break
833
- case "J": // ED - Erase in Display
834
- handleEraseDisplay(parts[0] ?? 0)
835
- break
836
- case "K": // EL - Erase in Line
837
- handleEraseLine(parts[0] ?? 0)
838
- break
839
- case "L": // IL - Insert Lines
840
- handleInsertLines(Math.max(parts[0] ?? 1, 1))
841
- break
842
- case "M": // DL - Delete Lines
843
- handleDeleteLines(Math.max(parts[0] ?? 1, 1))
844
- break
845
- case "P": // DCH - Delete Characters
846
- handleDeleteChars(Math.max(parts[0] ?? 1, 1))
847
- break
848
- case "@": // ICH - Insert Characters
849
- handleInsertChars(Math.max(parts[0] ?? 1, 1))
850
- break
851
- case "X": // ECH - Erase Characters
852
- handleEraseChars(Math.max(parts[0] ?? 1, 1))
853
- break
854
- case "S": // SU - Scroll Up
855
- for (let i = 0; i < Math.max(parts[0] ?? 1, 1); i++) {
856
- scrollUp(scrollTop, scrollBottom)
857
- }
858
- break
859
- case "T": // SD - Scroll Down
860
- for (let i = 0; i < Math.max(parts[0] ?? 1, 1); i++) {
861
- scrollDown(scrollTop, scrollBottom)
862
- }
863
- break
864
- case "b": // REP - Repeat preceding character
865
- if (lastChar) {
866
- const count = Math.max(parts[0] ?? 1, 1)
867
- for (let i = 0; i < count; i++) {
868
- writeChar(lastChar)
869
- }
870
- }
871
- break
872
- case "d": // VPA - Line Position Absolute
873
- curY = (parts[0] ?? 1) - 1
874
- clampCursor()
875
- break
876
- case "m": // SGR - Select Graphic Rendition
877
- handleSGR(params)
878
- break
879
- case "r": // DECSTBM - Set Scrolling Region
880
- scrollTop = (parts[0] ?? 1) - 1
881
- scrollBottom = (parts[1] ?? rows) - 1
882
- if (scrollTop < 0) scrollTop = 0
883
- if (scrollBottom >= rows) scrollBottom = rows - 1
884
- if (scrollTop > scrollBottom) {
885
- scrollTop = 0
886
- scrollBottom = rows - 1
887
- }
888
- curX = 0
889
- curY = originMode ? scrollTop : 0
890
- break
891
- case "n": // DSR - Device Status Report
892
- if (onResponse) {
893
- if (parts[0] === 5) {
894
- // Status report - OK
895
- onResponse("\x1b[0n")
896
- } else if (parts[0] === 6) {
897
- // Cursor position report
898
- onResponse(`\x1b[${curY + 1};${curX + 1}R`)
899
- }
900
- }
901
- break
902
- case "c": // DA1 - Primary Device Attributes
903
- if (onResponse) {
904
- if (params === "" || params === "0") {
905
- // VT200 (62) + sixel (4). We include sixel because we parse and
906
- // preserve sixel data via getSixelImages() — applications checking
907
- // DA1 for sixel support before sending image data will work correctly.
908
- onResponse("\x1b[?62;4c")
909
- }
910
- }
911
- break
912
- case "h": // SM - Set Mode (non-private)
913
- for (const code of parts) {
914
- if (code === 4) insertMode = true // IRM - Insert/Replace Mode
915
- }
916
- break
917
- case "l": // RM - Reset Mode (non-private)
918
- for (const code of parts) {
919
- if (code === 4) insertMode = false
920
- }
921
- break
922
- case "s": // SCP - Save Cursor Position
923
- savedCurX = curX
924
- savedCurY = curY
925
- break
926
- case "u": // RCP - Restore Cursor Position
927
- curX = savedCurX
928
- curY = savedCurY
929
- clampCursor()
930
- break
931
- default:
932
- break
933
- }
934
- }
935
-
936
- function handleCSIGt(params: string, _intermediates: string, finalByte: string): void {
937
- const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
938
-
939
- // CSI > sequences
940
- if (finalByte === "c") {
941
- // DA2 - Secondary Device Attributes
942
- if (onResponse) {
943
- if (params === "" || params === "0") {
944
- onResponse("\x1b[>1;100;0c")
945
- }
946
- }
947
- } else if (finalByte === "q") {
948
- // XTVERSION
949
- if (onResponse) {
950
- if (params === "" || params === "0") {
951
- onResponse("\x1bP>|vterm.js 0.1.0\x1b\\")
952
- }
953
- }
954
- } else if (finalByte === "u") {
955
- // CSI > flags u — Push keyboard mode (Kitty keyboard protocol)
956
- kittyKeyboardStack.push(kittyKeyboardFlags)
957
- kittyKeyboardFlags = parts[0] ?? 0
958
- }
959
- }
960
-
961
- function handleCSILt(_params: string, _intermediates: string, finalByte: string): void {
962
- // CSI < sequences
963
- if (finalByte === "u") {
964
- // CSI < u — Pop keyboard mode (Kitty keyboard protocol)
965
- kittyKeyboardFlags = kittyKeyboardStack.pop() ?? 0
966
- }
967
- }
968
-
969
- function handleCSIEq(params: string, _intermediates: string, finalByte: string): void {
970
- // CSI = sequences
971
- if (finalByte === "c") {
972
- // DA3 - Tertiary Device Attributes
973
- if (onResponse) {
974
- if (params === "" || params === "0") {
975
- onResponse("\x1bP!|00000000\x1b\\")
976
- }
977
- }
978
- }
979
- }
980
-
981
- function handleCSIPrivate(params: string, intermediates: string, finalByte: string): void {
982
- const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
983
-
984
- // CSI ? u — Query keyboard mode (Kitty keyboard protocol)
985
- if (finalByte === "u") {
986
- if (onResponse) {
987
- onResponse(`\x1b[?${kittyKeyboardFlags}u`)
988
- }
989
- return
990
- }
991
-
992
- // DECRPM - Mode reporting: CSI ? Pd $ p
993
- if (intermediates === "$" && finalByte === "p") {
994
- if (onResponse) {
995
- const mode = parts[0] ?? 0
996
- let value = 0 // 0 = not recognized
997
- switch (mode) {
998
- case 1:
999
- value = applicationCursor ? 1 : 2
1000
- break
1001
- case 6:
1002
- value = originMode ? 1 : 2
1003
- break
1004
- case 7:
1005
- value = autoWrap ? 1 : 2
1006
- break
1007
- case 25:
1008
- value = curVisible ? 1 : 2
1009
- break
1010
- case 47:
1011
- case 1047:
1012
- case 1049:
1013
- value = useAltScreen ? 1 : 2
1014
- break
1015
- case 66:
1016
- value = applicationKeypad ? 1 : 2
1017
- break
1018
- case 1000:
1019
- case 1002:
1020
- case 1003:
1021
- value = mouseTracking ? 1 : 2
1022
- break
1023
- case 1004:
1024
- value = focusTracking ? 1 : 2
1025
- break
1026
- case 1006:
1027
- value = sgrMouse ? 1 : 2
1028
- break
1029
- case 2004:
1030
- value = bracketedPaste ? 1 : 2
1031
- break
1032
- case 2026:
1033
- value = syncOutput ? 1 : 2
1034
- break
1035
- case 5:
1036
- value = reverseVideo ? 1 : 2
1037
- break
1038
- case 4:
1039
- value = insertMode ? 1 : 2
1040
- break
1041
- }
1042
- onResponse(`\x1b[?${mode};${value}$y`)
1043
- }
1044
- return
1045
- }
1046
-
1047
- // DECSED / DECSEL — selective erase (CSI ? J / CSI ? K)
1048
- // We treat selective erase same as normal erase (no DECSCA protection tracking)
1049
- if (finalByte === "J") {
1050
- handleEraseDisplay(parts[0] ?? 0)
1051
- return
1052
- }
1053
- if (finalByte === "K") {
1054
- handleEraseLine(parts[0] ?? 0)
1055
- return
1056
- }
1057
-
1058
- const set = finalByte === "h"
1059
-
1060
- for (const code of parts) {
1061
- switch (code) {
1062
- case 1: // DECCKM - Application Cursor
1063
- applicationCursor = set
1064
- break
1065
- case 4: // IRM - Insert Mode (via DEC private)
1066
- insertMode = set
1067
- break
1068
- case 5: // DECSCNM - Reverse Video
1069
- reverseVideo = set
1070
- break
1071
- case 6: // DECOM - Origin Mode
1072
- originMode = set
1073
- break
1074
- case 7: // DECAWM - Autowrap Mode
1075
- autoWrap = set
1076
- break
1077
- case 25: // DECTCEM - Cursor Visible
1078
- curVisible = set
1079
- break
1080
- case 47: // Alternate screen buffer (old)
1081
- case 1047: // Alternate screen buffer
1082
- if (set && !useAltScreen) {
1083
- useAltScreen = true
1084
- grid = altGrid
1085
- softWrapped = altSoftWrapped
1086
- } else if (!set && useAltScreen) {
1087
- useAltScreen = false
1088
- grid = mainGrid
1089
- softWrapped = mainSoftWrapped
1090
- }
1091
- break
1092
- case 66: // DECNKM - Application Keypad
1093
- applicationKeypad = set
1094
- break
1095
- case 9: // X10 mouse tracking
1096
- mouseTracking = set
1097
- mouseTrackingMode = set ? 9 : 0
1098
- break
1099
- case 1000: // Mouse tracking (basic)
1100
- mouseTracking = set
1101
- mouseTrackingMode = set ? 1000 : 0
1102
- break
1103
- case 1002: // Mouse tracking (button events)
1104
- mouseTracking = set
1105
- mouseTrackingMode = set ? 1002 : 0
1106
- break
1107
- case 1003: // Mouse tracking (all events)
1108
- mouseTracking = set
1109
- mouseTrackingMode = set ? 1003 : 0
1110
- break
1111
- case 1015: // urxvt mouse encoding
1112
- mouseTracking = set
1113
- mouseTrackingMode = set ? 1015 : 0
1114
- break
1115
- case 1016: // SGR pixel mouse
1116
- mouseTracking = set
1117
- mouseTrackingMode = set ? 1016 : 0
1118
- break
1119
- case 1004: // Focus tracking
1120
- focusTracking = set
1121
- break
1122
- case 1006: // SGR mouse mode
1123
- sgrMouse = set
1124
- break
1125
- case 1049: // Alternate screen buffer + save/restore cursor
1126
- if (set && !useAltScreen) {
1127
- savedCurX = curX
1128
- savedCurY = curY
1129
- useAltScreen = true
1130
- altGrid = makeGrid(cols, rows)
1131
- altSoftWrapped = new Array(rows).fill(false)
1132
- grid = altGrid
1133
- softWrapped = altSoftWrapped
1134
- curX = 0
1135
- curY = 0
1136
- } else if (!set && useAltScreen) {
1137
- useAltScreen = false
1138
- grid = mainGrid
1139
- softWrapped = mainSoftWrapped
1140
- curX = savedCurX
1141
- curY = savedCurY
1142
- clampCursor()
1143
- }
1144
- break
1145
- case 2004: // Bracketed paste
1146
- bracketedPaste = set
1147
- break
1148
- case 2026: // Synchronized output
1149
- syncOutput = set
1150
- break
1151
- }
1152
- }
1153
- }
1154
-
1155
- // ── Erase operations ──
1156
-
1157
- function handleEraseDisplay(mode: number): void {
1158
- switch (mode) {
1159
- case 0: // Erase from cursor to end
1160
- eraseCells(curY, curX, curY, cols - 1)
1161
- for (let row = curY + 1; row < rows; row++) {
1162
- eraseCells(row, 0, row, cols - 1)
1163
- }
1164
- break
1165
- case 1: // Erase from start to cursor
1166
- for (let row = 0; row < curY; row++) {
1167
- eraseCells(row, 0, row, cols - 1)
1168
- }
1169
- eraseCells(curY, 0, curY, curX)
1170
- break
1171
- case 2: // Erase entire display
1172
- case 3: // Erase entire display + scrollback
1173
- for (let row = 0; row < rows; row++) {
1174
- eraseCells(row, 0, row, cols - 1)
1175
- }
1176
- if (mode === 3) {
1177
- scrollback.length = 0
1178
- }
1179
- break
1180
- }
1181
- }
1182
-
1183
- function handleEraseLine(mode: number): void {
1184
- switch (mode) {
1185
- case 0: // Erase from cursor to end of line
1186
- eraseCells(curY, curX, curY, cols - 1)
1187
- break
1188
- case 1: // Erase from start to cursor
1189
- eraseCells(curY, 0, curY, curX)
1190
- break
1191
- case 2: // Erase entire line
1192
- eraseCells(curY, 0, curY, cols - 1)
1193
- break
1194
- }
1195
- }
1196
-
1197
- function eraseCells(row: number, startCol: number, _endRow: number, endCol: number): void {
1198
- const r = grid[row]
1199
- if (!r) return
1200
- for (let col = startCol; col <= endCol && col < cols; col++) {
1201
- const cell = emptyCell()
1202
- // Fill erased cells with the current background color
1203
- if (attrs.bg) {
1204
- cell.bg = { ...attrs.bg }
1205
- }
1206
- r[col] = cell
1207
- }
1208
- }
1209
-
1210
- function handleInsertLines(count: number): void {
1211
- if (curY < scrollTop || curY > scrollBottom) return
1212
- for (let i = 0; i < count; i++) {
1213
- scrollDown(curY, scrollBottom)
1214
- }
1215
- }
1216
-
1217
- function handleDeleteLines(count: number): void {
1218
- if (curY < scrollTop || curY > scrollBottom) return
1219
- for (let i = 0; i < count; i++) {
1220
- scrollUp(curY, scrollBottom)
1221
- }
1222
- }
1223
-
1224
- function handleDeleteChars(count: number): void {
1225
- const row = grid[curY]
1226
- if (!row) return
1227
- for (let i = 0; i < count; i++) {
1228
- if (curX < cols) {
1229
- row.splice(curX, 1)
1230
- row.push(emptyCell())
1231
- }
1232
- }
1233
- }
1234
-
1235
- function handleInsertChars(count: number): void {
1236
- const row = grid[curY]
1237
- if (!row) return
1238
- for (let i = 0; i < count; i++) {
1239
- row.splice(curX, 0, emptyCell())
1240
- row.pop()
1241
- }
1242
- }
1243
-
1244
- function handleEraseChars(count: number): void {
1245
- const row = grid[curY]
1246
- if (!row) return
1247
- for (let i = 0; i < count && curX + i < cols; i++) {
1248
- row[curX + i] = emptyCell()
1249
- }
1250
- }
1251
-
1252
- // ── SGR (Select Graphic Rendition) ──
1253
-
1254
- function handleSGR(rawParams: string): void {
1255
- const segments = rawParams.split(";")
1256
- const params: number[] = []
1257
- const subParams = new Map<number, number[]>()
1258
- for (const seg of segments) {
1259
- if (seg.includes(":")) {
1260
- const subs = seg.split(":").map((s) => (s === "" ? 0 : parseInt(s, 10)))
1261
- subParams.set(params.length, subs)
1262
- params.push(subs[0]!)
1263
- } else {
1264
- params.push(seg === "" ? 0 : parseInt(seg, 10))
1265
- }
1266
- }
1267
-
1268
- if (params.length === 0 || (params.length === 1 && params[0] === 0)) {
1269
- attrs = resetAttrs()
1270
- return
1271
- }
1272
-
1273
- let i = 0
1274
- while (i < params.length) {
1275
- const code = params[i]!
1276
- switch (code) {
1277
- case 0:
1278
- attrs = resetAttrs()
1279
- break
1280
- case 1:
1281
- attrs.bold = true
1282
- break
1283
- case 2:
1284
- attrs.faint = true
1285
- break
1286
- case 3:
1287
- attrs.italic = true
1288
- break
1289
- case 4: {
1290
- // SGR 4 with optional sub-parameter: 4:0=none, 4:1=single, 4:3=curly, etc.
1291
- const subs = subParams.get(i)
1292
- if (subs && subs.length > 1) {
1293
- const sub = subs[1]!
1294
- switch (sub) {
1295
- case 0:
1296
- attrs.underline = "none"
1297
- break
1298
- case 1:
1299
- attrs.underline = "single"
1300
- break
1301
- case 2:
1302
- attrs.underline = "double"
1303
- break
1304
- case 3:
1305
- attrs.underline = "curly"
1306
- break
1307
- case 4:
1308
- attrs.underline = "dotted"
1309
- break
1310
- case 5:
1311
- attrs.underline = "dashed"
1312
- break
1313
- default:
1314
- attrs.underline = "single"
1315
- break
1316
- }
1317
- } else {
1318
- attrs.underline = "single"
1319
- }
1320
- break
1321
- }
1322
- case 5: // Slow blink
1323
- attrs.blink = true
1324
- break
1325
- case 6: // Rapid blink (treat same as blink)
1326
- attrs.blink = true
1327
- break
1328
- case 7:
1329
- attrs.inverse = true
1330
- break
1331
- case 8: // Hidden/conceal
1332
- attrs.hidden = true
1333
- break
1334
- case 9:
1335
- attrs.strikethrough = true
1336
- break
1337
- case 21: // Double underline
1338
- attrs.underline = "double"
1339
- break
1340
- case 22: // Normal intensity (neither bold nor faint)
1341
- attrs.bold = false
1342
- attrs.faint = false
1343
- break
1344
- case 23:
1345
- attrs.italic = false
1346
- break
1347
- case 24:
1348
- attrs.underline = "none"
1349
- break
1350
- case 25: // Blink off
1351
- attrs.blink = false
1352
- break
1353
- case 27:
1354
- attrs.inverse = false
1355
- break
1356
- case 28: // Reveal (turn off hidden/conceal)
1357
- attrs.hidden = false
1358
- break
1359
- case 29:
1360
- attrs.strikethrough = false
1361
- break
1362
- // Foreground colors 30-37
1363
- case 30:
1364
- case 31:
1365
- case 32:
1366
- case 33:
1367
- case 34:
1368
- case 35:
1369
- case 36:
1370
- case 37:
1371
- attrs.fg = { ...PALETTE_256[code - 30]! }
1372
- break
1373
- case 38: {
1374
- // Extended foreground: 38;5;N (256) or 38;2;R;G;B (truecolor)
1375
- // Also handle colon form: 38:5:N or 38:2:R:G:B
1376
- const subs = subParams.get(i)
1377
- if (subs && subs.length >= 3) {
1378
- const result = parseExtendedColorFromSubs(subs)
1379
- if (result) attrs.fg = result
1380
- } else {
1381
- const result = parseExtendedColor(params, i)
1382
- if (result) {
1383
- attrs.fg = result.color
1384
- i = result.nextIndex - 1
1385
- }
1386
- }
1387
- break
1388
- }
1389
- case 39: // Default foreground
1390
- attrs.fg = null
1391
- break
1392
- // Background colors 40-47
1393
- case 40:
1394
- case 41:
1395
- case 42:
1396
- case 43:
1397
- case 44:
1398
- case 45:
1399
- case 46:
1400
- case 47:
1401
- attrs.bg = { ...PALETTE_256[code - 40]! }
1402
- break
1403
- case 48: {
1404
- // Extended background: 48;5;N (256) or 48;2;R;G;B (truecolor)
1405
- const subs = subParams.get(i)
1406
- if (subs && subs.length >= 3) {
1407
- const result = parseExtendedColorFromSubs(subs)
1408
- if (result) attrs.bg = result
1409
- } else {
1410
- const result = parseExtendedColor(params, i)
1411
- if (result) {
1412
- attrs.bg = result.color
1413
- i = result.nextIndex - 1
1414
- }
1415
- }
1416
- break
1417
- }
1418
- case 49: // Default background
1419
- attrs.bg = null
1420
- break
1421
- case 53: // Overline
1422
- attrs.overline = true
1423
- break
1424
- case 55: // Overline off
1425
- attrs.overline = false
1426
- break
1427
- case 58: {
1428
- // Underline color: 58;5;N (256) or 58;2;R;G;B (truecolor)
1429
- // Also handle colon form: 58:5:N or 58:2:R:G:B
1430
- const subs = subParams.get(i)
1431
- if (subs && subs.length >= 3) {
1432
- const result = parseExtendedColorFromSubs(subs)
1433
- if (result) attrs.underlineColor = result
1434
- } else {
1435
- const result = parseExtendedColor(params, i)
1436
- if (result) {
1437
- attrs.underlineColor = result.color
1438
- i = result.nextIndex - 1
1439
- }
1440
- }
1441
- break
1442
- }
1443
- case 59: // Default underline color
1444
- attrs.underlineColor = null
1445
- break
1446
- // Bright foreground 90-97
1447
- case 90:
1448
- case 91:
1449
- case 92:
1450
- case 93:
1451
- case 94:
1452
- case 95:
1453
- case 96:
1454
- case 97:
1455
- attrs.fg = { ...PALETTE_256[code - 90 + 8]! }
1456
- break
1457
- // Bright background 100-107
1458
- case 100:
1459
- case 101:
1460
- case 102:
1461
- case 103:
1462
- case 104:
1463
- case 105:
1464
- case 106:
1465
- case 107:
1466
- attrs.bg = { ...PALETTE_256[code - 100 + 8]! }
1467
- break
1468
- }
1469
- i++
1470
- }
1471
- }
1472
-
1473
- function parseExtendedColor(params: number[], startIndex: number): { color: CellColor; nextIndex: number } | null {
1474
- if (startIndex + 1 >= params.length) return null
1475
-
1476
- const type = params[startIndex + 1]
1477
- if (type === 5 && startIndex + 2 < params.length) {
1478
- const idx = params[startIndex + 2]!
1479
- const color = PALETTE_256[idx] ?? { r: 0, g: 0, b: 0 }
1480
- return { color: { ...color }, nextIndex: startIndex + 3 }
1481
- } else if (type === 2 && startIndex + 4 < params.length) {
1482
- return {
1483
- color: {
1484
- r: params[startIndex + 2]!,
1485
- g: params[startIndex + 3]!,
1486
- b: params[startIndex + 4]!,
1487
- },
1488
- nextIndex: startIndex + 5,
1489
- }
1490
- }
1491
- return null
1492
- }
1493
-
1494
- /** Parse extended color from colon sub-parameters (e.g., 38:2:R:G:B or 38:5:N) */
1495
- function parseExtendedColorFromSubs(subs: number[]): CellColor | null {
1496
- if (subs.length < 3) return null
1497
- const type = subs[1]
1498
- if (type === 5 && subs.length >= 3) {
1499
- const idx = subs[2]!
1500
- const color = PALETTE_256[idx] ?? { r: 0, g: 0, b: 0 }
1501
- return { ...color }
1502
- } else if (type === 2) {
1503
- // Can be 38:2:R:G:B or 38:2:colorspace:R:G:B
1504
- if (subs.length >= 5) {
1505
- // 38:2:R:G:B (no colorspace) or 38:2:cs:R:G:B
1506
- // If subs.length >= 6, assume colorspace variant
1507
- if (subs.length >= 6) {
1508
- return { r: subs[3]!, g: subs[4]!, b: subs[5]! }
1509
- }
1510
- return { r: subs[2]!, g: subs[3]!, b: subs[4]! }
1511
- } else if (subs.length >= 4) {
1512
- return { r: subs[2]!, g: subs[3]!, b: 0 }
1513
- }
1514
- }
1515
- return null
1516
- }
1517
-
1518
- // ── OSC handler ──
1519
-
1520
- function handleOSC(oscString: string): void {
1521
- const semicolonIdx = oscString.indexOf(";")
1522
- if (semicolonIdx === -1) return
1523
-
1524
- const code = parseInt(oscString.substring(0, semicolonIdx), 10)
1525
- const value = oscString.substring(semicolonIdx + 1)
1526
-
1527
- switch (code) {
1528
- case 0: // Set icon name and window title
1529
- case 2: // Set window title
1530
- title = value
1531
- break
1532
- case 133: {
1533
- // Semantic prompt markers (FinalTerm / shell integration)
1534
- // Value format: "X" or "X;params" where X is A/B/C/D
1535
- const marker = value.charAt(0)
1536
- switch (marker) {
1537
- case "A": // Start of prompt
1538
- semanticZones.push({ type: "prompt", startRow: curY, startCol: curX })
1539
- break
1540
- case "B": // End of prompt / start of command
1541
- semanticZones.push({ type: "command", startRow: curY, startCol: curX })
1542
- break
1543
- case "C": // End of command / start of output
1544
- semanticZones.push({ type: "output", startRow: curY, startCol: curX })
1545
- break
1546
- case "D": // End of output (exit code in params, ignored for storage)
1547
- break
1548
- }
1549
- break
1550
- }
1551
- case 1: // Set icon name (ignore)
1552
- break
1553
- case 7: // Current working directory: OSC 7 ; file://host/path ST
1554
- cwd = value
1555
- break
1556
- case 8: {
1557
- // Hyperlink: OSC 8 ; params ; url ST
1558
- // Format: 8;params;url or 8;;url or 8;; (close)
1559
- const secondSemicolon = value.indexOf(";")
1560
- if (secondSemicolon !== -1) {
1561
- const url = value.substring(secondSemicolon + 1)
1562
- attrs.url = url || null
1563
- }
1564
- break
1565
- }
1566
- case 10: // Foreground color query
1567
- if (value === "?" && onResponse) {
1568
- // Default foreground (white-ish)
1569
- onResponse("\x1b]10;rgb:ffff/ffff/ffff\x1b\\")
1570
- }
1571
- break
1572
- case 11: // Background color query
1573
- if (value === "?" && onResponse) {
1574
- // Default background (black)
1575
- onResponse("\x1b]11;rgb:0000/0000/0000\x1b\\")
1576
- }
1577
- break
1578
- case 52: {
1579
- // Clipboard: OSC 52 ; selection ; base64-data ST
1580
- const clipSemi = value.indexOf(";")
1581
- if (clipSemi !== -1) {
1582
- const data = value.substring(clipSemi + 1)
1583
- if (data === "?") {
1584
- // Query clipboard
1585
- if (onResponse) {
1586
- const encoded = btoa(clipboard)
1587
- onResponse(`\x1b]52;c;${encoded}\x1b\\`)
1588
- }
1589
- } else {
1590
- // Set clipboard
1591
- try {
1592
- clipboard = atob(data)
1593
- } catch {
1594
- clipboard = ""
1595
- }
1596
- if (onResponse) {
1597
- onResponse(`\x1b]52;c;${data}\x1b\\`)
1598
- }
1599
- }
1600
- }
1601
- break
1602
- }
1603
- case 9: // Notifications: OSC 9 ; message ST (iTerm2/ConEmu convention)
1604
- notifications.push(value)
1605
- break
1606
- case 633: {
1607
- // VS Code shell integration (OSC 633 is a superset of OSC 133)
1608
- // Maps to the same semantic zone model as FinalTerm markers.
1609
- const marker = value.charAt(0)
1610
- switch (marker) {
1611
- case "A":
1612
- semanticZones.push({ type: "prompt", startRow: curY, startCol: curX })
1613
- break
1614
- case "B":
1615
- semanticZones.push({ type: "command", startRow: curY, startCol: curX })
1616
- break
1617
- case "C":
1618
- semanticZones.push({ type: "output", startRow: curY, startCol: curX })
1619
- break
1620
- case "D":
1621
- break
1622
- }
1623
- break
1624
- }
1625
- case 1337: {
1626
- // iTerm2 proprietary sequences
1627
- if (value === "ReportCellSize" && onResponse) {
1628
- // Report default 8x16 pixel cell size (standard monospace ratio)
1629
- onResponse("\x1b]1337;ReportCellSize=16;8\x1b\\")
1630
- } else if (value === "RequestCapabilities" && onResponse) {
1631
- // Report empty capabilities (protocol supported, no iTerm2-specific features)
1632
- onResponse("\x1b]1337;Capabilities=\x1b\\")
1633
- }
1634
- // Inline images (File=...) are silently consumed — no pixel framebuffer
1635
- break
1636
- }
1637
- }
1638
- }
1639
-
1640
- // ── DCS handler ──
1641
-
1642
- function handleDCS(data: string): void {
1643
- // Sixel graphics: DCS [Ps;Ps;Ps] q [sixel-data]
1644
- const match = data.match(/^(\d*(?:;\d*)*)q(.*)$/s)
1645
- if (match) {
1646
- hasSixel = true
1647
- sixelImages.push({
1648
- data: match[2]!,
1649
- row: dcsStartRow,
1650
- col: dcsStartCol,
1651
- })
1652
- return
1653
- }
1654
-
1655
- // DECRQSS: DCS $ q Pt ST → response DCS Ps $ r Pt ST
1656
- if (data.startsWith("$q") && onResponse) {
1657
- const pt = data.substring(2)
1658
- if (pt === '"p') {
1659
- // DECSCL - Conformance level: VT200 mode, 8-bit controls
1660
- onResponse('\x1bP1$r62;1"p\x1b\\')
1661
- } else {
1662
- // Not recognized
1663
- onResponse("\x1bP0$r\x1b\\")
1664
- }
1665
- return
1666
- }
1667
-
1668
- // XTGETTCAP: DCS + q hex ST → response DCS 1 + r hex = hexvalue ST
1669
- if (data.startsWith("+q") && onResponse) {
1670
- const hexName = data.substring(2)
1671
- if (hexName === "544e") {
1672
- // "TN" = terminal name → "vterm"
1673
- const hexValue = Array.from(new TextEncoder().encode("vterm"))
1674
- .map((b) => b.toString(16).padStart(2, "0"))
1675
- .join("")
1676
- onResponse(`\x1bP1+r544e=${hexValue}\x1b\\`)
1677
- } else {
1678
- // Unknown capability
1679
- onResponse("\x1bP0+r\x1b\\")
1680
- }
1681
- return
1682
- }
1683
-
1684
- // Other DCS sequences are consumed and ignored
1685
- }
1686
-
1687
- // ── APC handler ──
1688
-
1689
- function handleAPC(data: string): void {
1690
- if (!data.startsWith("G")) return
1691
-
1692
- hasKittyGraphics = true
1693
-
1694
- // Parse key=value pairs
1695
- const semicolonIdx = data.indexOf(";")
1696
- const kvPart = semicolonIdx >= 0 ? data.substring(1, semicolonIdx) : data.substring(1)
1697
-
1698
- const params: Record<string, string> = {}
1699
- for (const pair of kvPart.split(",")) {
1700
- const eqIdx = pair.indexOf("=")
1701
- if (eqIdx >= 0) {
1702
- params[pair.substring(0, eqIdx)] = pair.substring(eqIdx + 1)
1703
- }
1704
- }
1705
-
1706
- // Handle query action
1707
- if (params.a === "q" && onResponse) {
1708
- // Respond: OK
1709
- onResponse(`\x1b_Gi=${params.i ?? "0"};OK\x1b\\`)
1710
- }
1711
- }
1712
-
1713
- // ── Soft reset (DECSTR) ──
1714
-
1715
- function softReset(): void {
1716
- // Reset modes to defaults
1717
- insertMode = false
1718
- originMode = false
1719
- autoWrap = true
1720
- curVisible = true
1721
- kittyKeyboardFlags = 0
1722
- kittyKeyboardStack = []
1723
- cursorShape = "block"
1724
- cursorBlinking = true
1725
- applicationCursor = false
1726
- applicationKeypad = false
1727
- reverseVideo = false
1728
-
1729
- // Reset scroll region
1730
- scrollTop = 0
1731
- scrollBottom = rows - 1
1732
-
1733
- // Reset attributes
1734
- attrs = resetAttrs()
1735
-
1736
- // Reset character set
1737
- charsetG0 = false
1738
-
1739
- // Reset cursor to home
1740
- curX = 0
1741
- curY = 0
1742
- }
1743
-
1744
- // ── Full reset ──
1745
-
1746
- function fullReset(): void {
1747
- mainGrid = makeGrid(cols, rows)
1748
- altGrid = makeGrid(cols, rows)
1749
- grid = mainGrid
1750
- scrollback = []
1751
- curX = 0
1752
- curY = 0
1753
- curVisible = true
1754
- cursorShape = "block"
1755
- cursorBlinking = true
1756
- savedCurX = 0
1757
- savedCurY = 0
1758
- savedState = { curX: 0, curY: 0, attrs: resetAttrs(), originMode: false, autoWrap: true, charsetG0: false }
1759
- attrs = resetAttrs()
1760
- title = ""
1761
- useAltScreen = false
1762
- bracketedPaste = false
1763
- applicationCursor = false
1764
- applicationKeypad = false
1765
- autoWrap = true
1766
- mouseTracking = false
1767
- mouseTrackingMode = 0
1768
- sgrMouse = false
1769
- focusTracking = false
1770
- originMode = false
1771
- insertMode = false
1772
- reverseVideo = false
1773
- syncOutput = false
1774
- kittyKeyboardFlags = 0
1775
- kittyKeyboardStack = []
1776
- hasKittyGraphics = false
1777
- hasSixel = false
1778
- sixelImages = []
1779
- scrollTop = 0
1780
- scrollBottom = rows - 1
1781
- viewportOffset = 0
1782
- charsetG0 = false
1783
- clipboard = ""
1784
- cwd = ""
1785
- notifications = []
1786
- lastChar = ""
1787
- pendingRegionalIndicator = null
1788
- afterZWJ = false
1789
- parserState = "ground"
1790
- escBuf = ""
1791
- oscBuf = ""
1792
- apcBuf = ""
1793
- semanticZones = []
1794
- mainSoftWrapped = new Array(rows).fill(false)
1795
- altSoftWrapped = new Array(rows).fill(false)
1796
- softWrapped = mainSoftWrapped
1797
- }
1798
-
1799
- // ── Main parser ──
1800
-
1801
- function process(data: Uint8Array): void {
1802
- const text = decoder.decode(data, { stream: true })
1803
-
1804
- for (let i = 0; i < text.length; i++) {
1805
- const ch = text[i]!
1806
- const code = text.charCodeAt(i)
1807
-
1808
- switch (parserState) {
1809
- case "ground":
1810
- if (code === 0x1b) {
1811
- parserState = "escape"
1812
- escBuf = ""
1813
- } else if (code === 0x07) {
1814
- // BEL — ignore
1815
- } else if (code === 0x08) {
1816
- // BS - Backspace
1817
- if (curX > 0) curX--
1818
- } else if (code === 0x09) {
1819
- // TAB
1820
- curX = Math.min((Math.floor(curX / 8) + 1) * 8, cols - 1)
1821
- } else if (code === 0x0a || code === 0x0b || code === 0x0c) {
1822
- // LF, VT, FF — linefeed (hard break — clear any soft-wrap flag)
1823
- softWrapped[curY] = false
1824
- curY++
1825
- if (curY > scrollBottom) {
1826
- curY = scrollBottom
1827
- scrollUp(scrollTop, scrollBottom)
1828
- }
1829
- } else if (code === 0x0d) {
1830
- // CR - Carriage Return
1831
- curX = 0
1832
- } else if (code >= 0x20) {
1833
- // Handle surrogate pairs for characters > U+FFFF
1834
- let char = ch
1835
- if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
1836
- const nextCode = text.charCodeAt(i + 1)
1837
- if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
1838
- char = ch + text[i + 1]!
1839
- i++
1840
- }
1841
- }
1842
- writeChar(char)
1843
- }
1844
- break
1845
-
1846
- case "escape":
1847
- if (ch === "[") {
1848
- parserState = "csi"
1849
- escBuf = ""
1850
- } else if (ch === "]") {
1851
- parserState = "osc"
1852
- oscBuf = ""
1853
- } else if (ch === "P") {
1854
- parserState = "dcs"
1855
- escBuf = ""
1856
- dcsBuf = ""
1857
- dcsStartRow = curY
1858
- dcsStartCol = curX
1859
- } else if (ch === "c") {
1860
- // RIS - Reset to Initial State
1861
- fullReset()
1862
- parserState = "ground"
1863
- } else if (ch === "D") {
1864
- // IND - Index (move cursor down, scroll if needed)
1865
- curY++
1866
- if (curY > scrollBottom) {
1867
- curY = scrollBottom
1868
- scrollUp(scrollTop, scrollBottom)
1869
- }
1870
- parserState = "ground"
1871
- } else if (ch === "M") {
1872
- // RI - Reverse Index (move cursor up, scroll if needed)
1873
- curY--
1874
- if (curY < scrollTop) {
1875
- curY = scrollTop
1876
- scrollDown(scrollTop, scrollBottom)
1877
- }
1878
- parserState = "ground"
1879
- } else if (ch === "7") {
1880
- // DECSC - Save Cursor + attributes + modes
1881
- savedState = {
1882
- curX,
1883
- curY,
1884
- attrs: {
1885
- ...attrs,
1886
- fg: attrs.fg ? { ...attrs.fg } : null,
1887
- bg: attrs.bg ? { ...attrs.bg } : null,
1888
- underlineColor: attrs.underlineColor ? { ...attrs.underlineColor } : null,
1889
- },
1890
- originMode,
1891
- autoWrap,
1892
- charsetG0,
1893
- }
1894
- parserState = "ground"
1895
- } else if (ch === "8") {
1896
- // DECRC - Restore Cursor + attributes + modes
1897
- curX = savedState.curX
1898
- curY = savedState.curY
1899
- attrs = {
1900
- ...savedState.attrs,
1901
- fg: savedState.attrs.fg ? { ...savedState.attrs.fg } : null,
1902
- bg: savedState.attrs.bg ? { ...savedState.attrs.bg } : null,
1903
- underlineColor: savedState.attrs.underlineColor ? { ...savedState.attrs.underlineColor } : null,
1904
- }
1905
- originMode = savedState.originMode
1906
- autoWrap = savedState.autoWrap
1907
- charsetG0 = savedState.charsetG0
1908
- clampCursor()
1909
- parserState = "ground"
1910
- } else if (ch === "E") {
1911
- // NEL - Next Line
1912
- curX = 0
1913
- curY++
1914
- if (curY > scrollBottom) {
1915
- curY = scrollBottom
1916
- scrollUp(scrollTop, scrollBottom)
1917
- }
1918
- parserState = "ground"
1919
- } else if (ch === "(") {
1920
- // Designate G0 character set
1921
- parserState = "escape_charset"
1922
- } else if (ch === ")") {
1923
- // Designate G1 character set (ignored, just consume next byte)
1924
- parserState = "escape_charset"
1925
- } else if (ch === "=") {
1926
- // DECKPAM - Application Keypad Mode
1927
- applicationKeypad = true
1928
- parserState = "ground"
1929
- } else if (ch === ">") {
1930
- // DECKPNM - Normal Keypad Mode
1931
- applicationKeypad = false
1932
- parserState = "ground"
1933
- } else if (ch === "_") {
1934
- // APC - Application Program Command
1935
- parserState = "apc"
1936
- apcBuf = ""
1937
- } else {
1938
- // Unknown escape — return to ground
1939
- parserState = "ground"
1940
- }
1941
- break
1942
-
1943
- case "escape_charset":
1944
- // Character set designation: ESC ( 0 = DEC Special Graphics, ESC ( B = ASCII
1945
- if (ch === "0") {
1946
- charsetG0 = true
1947
- } else {
1948
- charsetG0 = false // B = ASCII, or any other
1949
- }
1950
- parserState = "ground"
1951
- break
1952
-
1953
- case "csi": {
1954
- if (code >= 0x40 && code <= 0x7e) {
1955
- // Final byte — dispatch CSI
1956
- // Extract intermediates (characters 0x20-0x2F between params and final)
1957
- let paramPart = escBuf
1958
- let intermediatePart = ""
1959
-
1960
- // Check for intermediates at the end of escBuf
1961
- // Intermediates are 0x20-0x2F: space ! " # $ % & ' ( ) * + , - . /
1962
- let j = paramPart.length - 1
1963
- while (j >= 0) {
1964
- const c = paramPart.charCodeAt(j)
1965
- if (c >= 0x20 && c <= 0x2f) {
1966
- j--
1967
- } else {
1968
- break
1969
- }
1970
- }
1971
- if (j < paramPart.length - 1) {
1972
- intermediatePart = paramPart.substring(j + 1)
1973
- paramPart = paramPart.substring(0, j + 1)
1974
- }
1975
-
1976
- if (paramPart.startsWith("?")) {
1977
- handleCSIPrivate(paramPart.substring(1), intermediatePart, ch)
1978
- } else if (paramPart.startsWith(">")) {
1979
- handleCSIGt(paramPart.substring(1), intermediatePart, ch)
1980
- } else if (paramPart.startsWith("<")) {
1981
- handleCSILt(paramPart.substring(1), intermediatePart, ch)
1982
- } else if (paramPart.startsWith("=")) {
1983
- handleCSIEq(paramPart.substring(1), intermediatePart, ch)
1984
- } else {
1985
- handleCSI(paramPart, intermediatePart, ch)
1986
- }
1987
- parserState = "ground"
1988
- } else if (escBuf.length >= 256) {
1989
- parserState = "ground"
1990
- } else {
1991
- escBuf += ch
1992
- }
1993
- break
1994
- }
1995
-
1996
- case "osc":
1997
- if (code === 0x07) {
1998
- // BEL terminates OSC
1999
- handleOSC(oscBuf)
2000
- parserState = "ground"
2001
- } else if (code === 0x1b) {
2002
- // ESC might be start of ST (\x1b\\)
2003
- parserState = "osc_st"
2004
- } else if (oscBuf.length >= 4096) {
2005
- parserState = "ground"
2006
- } else {
2007
- oscBuf += ch
2008
- }
2009
- break
2010
-
2011
- case "osc_st":
2012
- if (ch === "\\") {
2013
- // ST (String Terminator) — end of OSC
2014
- handleOSC(oscBuf)
2015
- }
2016
- parserState = "ground"
2017
- break
2018
-
2019
- case "dcs":
2020
- // Accumulate DCS data until ST (ESC \) or BEL
2021
- if (code === 0x1b) {
2022
- parserState = "dcs_st"
2023
- } else if (code === 0x07) {
2024
- // BEL terminates DCS
2025
- handleDCS(dcsBuf)
2026
- parserState = "ground"
2027
- } else {
2028
- dcsBuf += ch
2029
- }
2030
- break
2031
-
2032
- case "dcs_st":
2033
- // Expecting backslash to complete ST
2034
- if (ch === "\\") {
2035
- // ST (String Terminator) — end of DCS
2036
- handleDCS(dcsBuf)
2037
- }
2038
- parserState = "ground"
2039
- break
2040
-
2041
- case "dcs_passthrough":
2042
- // Consume until ST
2043
- if (code === 0x1b) {
2044
- parserState = "dcs_st"
2045
- }
2046
- break
2047
-
2048
- case "apc":
2049
- if (code === 0x1b) {
2050
- parserState = "apc_st"
2051
- } else if (code === 0x07) {
2052
- // BEL terminates APC
2053
- handleAPC(apcBuf)
2054
- parserState = "ground"
2055
- } else {
2056
- apcBuf += ch
2057
- }
2058
- break
2059
-
2060
- case "apc_st":
2061
- if (ch === "\\") {
2062
- // ST (String Terminator) — end of APC
2063
- handleAPC(apcBuf)
2064
- }
2065
- parserState = "ground"
2066
- break
2067
- }
2068
- }
2069
- }
2070
-
2071
- // ── Resize ──
2072
-
2073
- /**
2074
- * Reconstruct logical lines from a grid, joining rows that were soft-wrapped.
2075
- * Returns an array of logical lines, each being an array of ScreenCells (may be longer than cols).
2076
- */
2077
- function getLogicalLines(srcGrid: ScreenCell[][], srcSoftWrapped: boolean[], srcRows: number): ScreenCell[][] {
2078
- const logical: ScreenCell[][] = []
2079
- let currentLine: ScreenCell[] = []
2080
-
2081
- for (let r = 0; r < srcRows; r++) {
2082
- const row = srcGrid[r]
2083
- if (!row) continue
2084
- // Append this row's cells to the current logical line
2085
- for (let c = 0; c < row.length; c++) {
2086
- currentLine.push(row[c]!)
2087
- }
2088
- if (srcSoftWrapped[r]) {
2089
- // This row was soft-wrapped — continue accumulating into same logical line
2090
- continue
2091
- }
2092
- // Hard break (or last row): finalize this logical line
2093
- logical.push(currentLine)
2094
- currentLine = []
2095
- }
2096
- // If there's a dangling line (shouldn't happen, but be safe)
2097
- if (currentLine.length > 0) {
2098
- logical.push(currentLine)
2099
- }
2100
- return logical
2101
- }
2102
-
2103
- /**
2104
- * Re-wrap logical lines to a new column width, producing grid rows and soft-wrap flags.
2105
- */
2106
- function rewrapLines(logicalLines: ScreenCell[][], newCols: number): { rows: ScreenCell[][]; wrapped: boolean[] } {
2107
- const outRows: ScreenCell[][] = []
2108
- const outWrapped: boolean[] = []
2109
-
2110
- for (const line of logicalLines) {
2111
- // Trim trailing empty cells from logical line
2112
- let lineLen = line.length
2113
- while (lineLen > 0) {
2114
- const cell = line[lineLen - 1]!
2115
- if (cell === EMPTY_CELL || (cell.char === "" && !cell.wide)) {
2116
- lineLen--
2117
- } else {
2118
- break
2119
- }
2120
- }
2121
-
2122
- if (lineLen === 0) {
2123
- // Empty logical line — produce one empty row
2124
- outRows.push(makeRow(newCols))
2125
- outWrapped.push(false)
2126
- continue
2127
- }
2128
-
2129
- // Wrap the logical line content into rows of newCols width
2130
- let pos = 0
2131
- while (pos < lineLen) {
2132
- const row = makeRow(newCols)
2133
- let col = 0
2134
- while (col < newCols && pos < lineLen) {
2135
- const cell = line[pos]!
2136
- if (cell.wide && col + 2 > newCols) {
2137
- // Wide char doesn't fit — leave rest of row empty, wrap to next
2138
- break
2139
- }
2140
- row[col] = cell === EMPTY_CELL ? EMPTY_CELL : { ...cell }
2141
- col++
2142
- pos++
2143
- // If cell was wide, the next cell in the logical line is the spacer
2144
- // which we already advanced past via pos++
2145
- }
2146
- const moreContent = pos < lineLen
2147
- outRows.push(row)
2148
- outWrapped.push(moreContent) // soft-wrapped if there's more content to come
2149
- }
2150
- }
2151
-
2152
- return { rows: outRows, wrapped: outWrapped }
2153
- }
2154
-
2155
- /**
2156
- * Trim trailing empty rows from reflowed result, so they don't push content off the top
2157
- * when we take the last newRows rows.
2158
- */
2159
- function trimTrailingEmptyRows(result: { rows: ScreenCell[][]; wrapped: boolean[] }): void {
2160
- while (result.rows.length > 1) {
2161
- const lastRow = result.rows[result.rows.length - 1]!
2162
- const isEmpty = lastRow.every((cell) => cell === EMPTY_CELL || (cell.char === "" && !cell.wide))
2163
- if (isEmpty && !result.wrapped[result.rows.length - 2]) {
2164
- // The row before wasn't soft-wrapped and this row is empty — trim it
2165
- result.rows.pop()
2166
- result.wrapped.pop()
2167
- } else {
2168
- break
2169
- }
2170
- }
2171
- }
2172
-
2173
- function resize(newCols: number, newRows: number): void {
2174
- // Reflow main grid
2175
- const mainLogical = getLogicalLines(mainGrid, mainSoftWrapped, rows)
2176
- const mainResult = rewrapLines(mainLogical, newCols)
2177
- trimTrailingEmptyRows(mainResult)
2178
-
2179
- // Reflow alt grid (usually not reflowed, but do it for consistency)
2180
- const altLogical = getLogicalLines(altGrid, altSoftWrapped, rows)
2181
- const altResult = rewrapLines(altLogical, newCols)
2182
- trimTrailingEmptyRows(altResult)
2183
-
2184
- // Build new grids: if reflowed content fits, place at top; if it overflows, take the last newRows
2185
- const newMain = makeGrid(newCols, newRows)
2186
- const newMainWrapped: boolean[] = new Array(newRows).fill(false)
2187
- const mainStartRow = Math.max(0, mainResult.rows.length - newRows)
2188
- for (let r = 0; r < newRows && mainStartRow + r < mainResult.rows.length; r++) {
2189
- newMain[r] = mainResult.rows[mainStartRow + r]!
2190
- newMainWrapped[r] = mainResult.wrapped[mainStartRow + r]!
2191
- }
2192
-
2193
- // Build new alt grid
2194
- const newAlt = makeGrid(newCols, newRows)
2195
- const newAltWrapped: boolean[] = new Array(newRows).fill(false)
2196
- const altStartRow = Math.max(0, altResult.rows.length - newRows)
2197
- for (let r = 0; r < newRows && altStartRow + r < altResult.rows.length; r++) {
2198
- newAlt[r] = altResult.rows[altStartRow + r]!
2199
- newAltWrapped[r] = altResult.wrapped[altStartRow + r]!
2200
- }
2201
-
2202
- mainGrid = newMain
2203
- altGrid = newAlt
2204
- mainSoftWrapped = newMainWrapped
2205
- altSoftWrapped = newAltWrapped
2206
- grid = useAltScreen ? altGrid : mainGrid
2207
- softWrapped = useAltScreen ? altSoftWrapped : mainSoftWrapped
2208
- cols = newCols
2209
- rows = newRows
2210
- scrollTop = 0
2211
- scrollBottom = rows - 1
2212
- clampCursor()
2213
- }
2214
-
2215
- // ── Accessors ──
2216
-
2217
- function getCell(row: number, col: number): ScreenCell {
2218
- const r = grid[row]
2219
- if (!r || col >= cols) return emptyCell()
2220
- return { ...r[col]! }
2221
- }
2222
-
2223
- function getLine(row: number): ScreenCell[] {
2224
- const r = grid[row]
2225
- if (!r) return makeRow(cols)
2226
- return r.map((cell) => ({ ...cell }))
2227
- }
2228
-
2229
- function getText(): string {
2230
- const lines: string[] = []
2231
- for (let r = 0; r < rows; r++) {
2232
- lines.push(rowToString(grid[r]!))
2233
- }
2234
- return lines.join("\n")
2235
- }
2236
-
2237
- function rowToString(row: ScreenCell[]): string {
2238
- let line = ""
2239
- for (let i = 0; i < row.length; i++) {
2240
- const cell = row[i]!
2241
- if (cell.wide) {
2242
- line += cell.char
2243
- } else if (cell.char === "") {
2244
- if (i > 0 && row[i - 1]?.wide) {
2245
- continue
2246
- }
2247
- line += " "
2248
- } else {
2249
- line += cell.char
2250
- }
2251
- }
2252
- return line.replace(/\s+$/, "")
2253
- }
2254
-
2255
- function getTextRange(startRow: number, startCol: number, endRow: number, endCol: number): string {
2256
- const parts: string[] = []
2257
-
2258
- for (let row = startRow; row <= endRow; row++) {
2259
- const r = grid[row]
2260
- if (!r) continue
2261
-
2262
- const colStart = row === startRow ? startCol : 0
2263
- const colEnd = row === endRow ? endCol : cols
2264
-
2265
- let line = ""
2266
- for (let col = colStart; col < colEnd; col++) {
2267
- const cell = r[col]
2268
- if (!cell) continue
2269
- if (cell.char === "" && col > 0 && r[col - 1]?.wide) continue
2270
- line += cell.char || " "
2271
- }
2272
- parts.push(line.replace(/\s+$/, ""))
2273
- }
2274
-
2275
- return parts.join("\n")
2276
- }
2277
-
2278
- function getMode(mode: string): boolean {
2279
- switch (mode) {
2280
- case "altScreen":
2281
- return useAltScreen
2282
- case "cursorVisible":
2283
- return curVisible
2284
- case "bracketedPaste":
2285
- return bracketedPaste
2286
- case "applicationCursor":
2287
- return applicationCursor
2288
- case "applicationKeypad":
2289
- return applicationKeypad
2290
- case "autoWrap":
2291
- return autoWrap
2292
- case "mouseTracking":
2293
- return mouseTracking
2294
- case "focusTracking":
2295
- return focusTracking
2296
- case "originMode":
2297
- return originMode
2298
- case "insertMode":
2299
- return insertMode
2300
- case "reverseVideo":
2301
- return reverseVideo
2302
- case "syncOutput":
2303
- return syncOutput
2304
- case "sgrMouse":
2305
- return sgrMouse
2306
- case "pixelMouse":
2307
- return mouseTrackingMode === 1016
2308
- case "leftRightMargin":
2309
- return false // Not implemented yet
2310
- case "kittyKeyboard":
2311
- return kittyKeyboardFlags > 0
2312
- case "kittyGraphics":
2313
- return hasKittyGraphics
2314
- case "sixel":
2315
- return hasSixel
2316
- default:
2317
- return false
2318
- }
2319
- }
2320
-
2321
- // Suppress unused variable warnings
2322
- void [mouseTrackingMode]
2323
-
2324
- return {
2325
- get cols() {
2326
- return cols
2327
- },
2328
- get rows() {
2329
- return rows
2330
- },
2331
- process,
2332
- resize,
2333
- reset: fullReset,
2334
- getCell,
2335
- getLine,
2336
- getText,
2337
- getTextRange,
2338
- getCursorPosition: () => ({ x: curX, y: curY }),
2339
- getCursorVisible: () => curVisible,
2340
- getCursorShape: () => cursorShape,
2341
- getCursorBlinking: () => cursorBlinking,
2342
- getTitle: () => title,
2343
- getMode,
2344
- getClipboard: () => clipboard,
2345
- getCwd: () => cwd,
2346
- getNotifications: () => [...notifications],
2347
- getScrollbackLength: () => scrollback.length,
2348
- getViewportOffset: () => viewportOffset,
2349
- scrollViewport: (delta: number) => {
2350
- viewportOffset = Math.max(0, Math.min(scrollback.length, viewportOffset + delta))
2351
- },
2352
- getSemanticZones: () => semanticZones.map((z) => ({ ...z })),
2353
- getSixelImages: () => sixelImages.map((img) => ({ ...img })),
2354
- }
2355
- }