vterm.js 0.3.1 → 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,2518 +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
- // Left/right margin mode (DECLRMM, DECSET ?69)
403
- let leftRightMarginMode = false
404
- let leftMargin = 0
405
- let rightMargin = cols - 1
406
-
407
- // Color scheme reporting (mode 2031)
408
- let colorSchemeReporting = false
409
-
410
- // Text scale (OSC 66)
411
- let textScale = 1
412
-
413
- // Advanced clipboard (OSC 5522 — Kitty clipboard protocol)
414
- let advancedClipboard = ""
415
-
416
- // Viewport scroll offset
417
- let viewportOffset = 0
418
-
419
- // Character set: true = DEC Special Graphics (G0)
420
- let charsetG0 = false
421
-
422
- // Clipboard (OSC 52)
423
- let clipboard = ""
424
-
425
- // Current working directory (OSC 7)
426
- let cwd = ""
427
-
428
- // Notifications (OSC 9)
429
- let notifications: string[] = []
430
-
431
- // Semantic prompt zones (OSC 133 / OSC 633)
432
- let semanticZones: SemanticZone[] = []
433
-
434
- // Sixel graphics (DCS q)
435
- // Headless design: we parse sixel data and store it for consumers (e.g. a GUI
436
- // renderer could use getSixelImages() to display them). We don't decode pixels
437
- // because there's no framebuffer to render into — but preserving the data means
438
- // a host application gets full fidelity without re-parsing the stream.
439
- let hasSixel = false
440
- let sixelImages: SixelImage[] = []
441
-
442
- // Soft-wrap tracking: true if the line break at end of this row was caused by auto-wrap
443
- let mainSoftWrapped: boolean[] = new Array(rows).fill(false)
444
- let altSoftWrapped: boolean[] = new Array(rows).fill(false)
445
- let softWrapped = mainSoftWrapped
446
-
447
- // Last printed character for REP
448
- let lastChar = ""
449
-
450
- // Unicode sequence state
451
- let pendingRegionalIndicator: string | null = null // First RI waiting for pair
452
- let afterZWJ = false // Next character should join with previous cell
453
-
454
- // Parser state
455
- let parserState:
456
- | "ground"
457
- | "escape"
458
- | "escape_charset"
459
- | "csi"
460
- | "osc"
461
- | "dcs"
462
- | "dcs_passthrough"
463
- | "osc_st"
464
- | "dcs_st"
465
- | "apc"
466
- | "apc_st" = "ground"
467
- let escBuf = ""
468
- let oscBuf = ""
469
- let dcsBuf = ""
470
- let dcsStartRow = 0
471
- let dcsStartCol = 0
472
- let apcBuf = ""
473
-
474
- // Decoder for incoming bytes
475
- const decoder = new TextDecoder()
476
-
477
- // ── Grid helpers ──
478
-
479
- function makeGrid(c: number, r: number): ScreenCell[][] {
480
- const g: ScreenCell[][] = []
481
- for (let row = 0; row < r; row++) {
482
- g.push(makeRow(c))
483
- }
484
- return g
485
- }
486
-
487
- function makeRow(c: number): ScreenCell[] {
488
- const row: ScreenCell[] = []
489
- for (let col = 0; col < c; col++) {
490
- row.push(EMPTY_CELL)
491
- }
492
- return row
493
- }
494
-
495
- function resetAttrs(): Attrs {
496
- return {
497
- fg: null,
498
- bg: null,
499
- bold: false,
500
- faint: false,
501
- italic: false,
502
- underline: "none",
503
- underlineColor: null,
504
- overline: false,
505
- strikethrough: false,
506
- inverse: false,
507
- hidden: false,
508
- blink: false,
509
- url: null,
510
- }
511
- }
512
-
513
- function clampCursor(): void {
514
- if (curX < 0) curX = 0
515
- if (curX >= cols) curX = cols - 1
516
- if (curY < 0) curY = 0
517
- if (curY >= rows) curY = rows - 1
518
- }
519
-
520
- // ── Scrolling ──
521
-
522
- function scrollUp(top: number, bottom: number): void {
523
- if (leftRightMarginMode && (leftMargin > 0 || rightMargin < cols - 1)) {
524
- // Scroll only within the left/right margin columns
525
- const lm = leftMargin
526
- const rm = rightMargin
527
- for (let r = top; r < bottom; r++) {
528
- const srcRow = grid[r + 1]!
529
- const dstRow = grid[r]!
530
- for (let c = lm; c <= rm && c < cols; c++) {
531
- dstRow[c] = srcRow[c]!
532
- }
533
- }
534
- // Clear the bottom row within margins
535
- const bottomRow = grid[bottom]!
536
- for (let c = lm; c <= rm && c < cols; c++) {
537
- bottomRow[c] = EMPTY_CELL
538
- }
539
- } else {
540
- // Full-width scroll
541
- // Move top row to scrollback (only if main screen & top of screen)
542
- if (grid === mainGrid && top === 0) {
543
- scrollback.push(grid[0]!)
544
- if (scrollback.length > scrollbackLimit * 2) {
545
- scrollback.splice(0, scrollback.length - scrollbackLimit)
546
- }
547
- }
548
- for (let i = top; i < bottom; i++) {
549
- grid[i] = grid[i + 1]!
550
- softWrapped[i] = softWrapped[i + 1]!
551
- }
552
- grid[bottom] = makeRow(cols)
553
- softWrapped[bottom] = false
554
- }
555
- }
556
-
557
- function scrollDown(top: number, bottom: number): void {
558
- if (leftRightMarginMode && (leftMargin > 0 || rightMargin < cols - 1)) {
559
- // Scroll only within the left/right margin columns
560
- const lm = leftMargin
561
- const rm = rightMargin
562
- for (let r = bottom; r > top; r--) {
563
- const srcRow = grid[r - 1]!
564
- const dstRow = grid[r]!
565
- for (let c = lm; c <= rm && c < cols; c++) {
566
- dstRow[c] = srcRow[c]!
567
- }
568
- }
569
- // Clear the top row within margins
570
- const topRow = grid[top]!
571
- for (let c = lm; c <= rm && c < cols; c++) {
572
- topRow[c] = EMPTY_CELL
573
- }
574
- } else {
575
- for (let i = bottom; i > top; i--) {
576
- grid[i] = grid[i - 1]!
577
- softWrapped[i] = softWrapped[i - 1]!
578
- }
579
- grid[top] = makeRow(cols)
580
- softWrapped[top] = false
581
- }
582
- }
583
-
584
- // ── Character writing ──
585
-
586
- /** Find the previous non-spacer cell (the cell before curX, skipping wide-char spacers) */
587
- function getPrevCell(): { cell: ScreenCell; col: number } | null {
588
- if (curX === 0 && curY === 0) return null
589
- let prevCol = curX - 1
590
- let prevRow = curY
591
- if (prevCol < 0) {
592
- prevRow--
593
- if (prevRow < 0) return null
594
- prevCol = cols - 1
595
- }
596
- const row = grid[prevRow]!
597
- let cell = row[prevCol]!
598
- // If we landed on a spacer (empty char after a wide character), go back one more
599
- if (cell !== EMPTY_CELL && cell.char === "" && prevCol > 0) {
600
- prevCol--
601
- cell = row[prevCol]!
602
- }
603
- if (cell === EMPTY_CELL) return null
604
- return { cell, col: prevCol }
605
- }
606
-
607
- /** Widen a cell to 2 columns, adding a spacer cell after it */
608
- function widenCell(row: ScreenCell[], col: number, cell: ScreenCell): void {
609
- cell.wide = true
610
- if (col + 1 < cols) {
611
- let spacer = row[col + 1]!
612
- if (spacer === EMPTY_CELL) {
613
- spacer = { ...EMPTY_CELL }
614
- row[col + 1] = spacer
615
- }
616
- spacer.char = ""
617
- spacer.fg = null
618
- spacer.bg = null
619
- spacer.bold = false
620
- spacer.faint = false
621
- spacer.italic = false
622
- spacer.underline = "none"
623
- spacer.underlineColor = null
624
- spacer.overline = false
625
- spacer.strikethrough = false
626
- spacer.inverse = false
627
- spacer.hidden = false
628
- spacer.blink = false
629
- spacer.wide = false
630
- spacer.url = null
631
- }
632
- }
633
-
634
- function writeChar(ch: string): void {
635
- // Apply DEC Special Graphics character mapping
636
- if (charsetG0 && ch.length === 1) {
637
- const mapped = DEC_SPECIAL_GRAPHICS[ch]
638
- if (mapped) ch = mapped
639
- }
640
-
641
- const codePoint = ch.codePointAt(0) ?? 0
642
-
643
- // ── VS-16 (U+FE0F): widen previous character to emoji presentation ──
644
- if (isVS16(codePoint)) {
645
- const prev = getPrevCell()
646
- if (prev && !prev.cell.wide) {
647
- prev.cell.char += ch
648
- const row = grid[curY === 0 && curX === 0 ? 0 : curY]!
649
- widenCell(row, prev.col, prev.cell)
650
- // Advance cursor past the spacer
651
- curX = prev.col + 2
652
- if (curX >= cols) curX = cols - 1
653
- }
654
- return
655
- }
656
-
657
- // ── Combining characters: append to previous cell, zero width ──
658
- if (isCombining(codePoint) && !isVS16(codePoint)) {
659
- const prev = getPrevCell()
660
- if (prev) {
661
- prev.cell.char += ch
662
- }
663
- return
664
- }
665
-
666
- // ── Emoji modifier (skin tone): append to previous cell, zero width ──
667
- if (isEmojiModifier(codePoint)) {
668
- const prev = getPrevCell()
669
- if (prev) {
670
- prev.cell.char += ch
671
- }
672
- return
673
- }
674
-
675
- // ── ZWJ (U+200D): append to previous cell, flag for next char ──
676
- if (isZWJ(codePoint)) {
677
- const prev = getPrevCell()
678
- if (prev) {
679
- prev.cell.char += ch
680
- afterZWJ = true
681
- }
682
- return
683
- }
684
-
685
- // ── After ZWJ: append this character to the previous cell ──
686
- if (afterZWJ) {
687
- afterZWJ = false
688
- const prev = getPrevCell()
689
- if (prev) {
690
- prev.cell.char += ch
691
- // The ZWJ sequence stays in the same wide cell
692
- return
693
- }
694
- }
695
-
696
- // ── Regional Indicators: pair into flag emoji ──
697
- if (isRegionalIndicator(codePoint)) {
698
- if (pendingRegionalIndicator !== null) {
699
- // Second RI: combine with first to form a flag, render as wide
700
- const flag = pendingRegionalIndicator + ch
701
- pendingRegionalIndicator = null
702
- // Write the combined flag as a wide character
703
- writeCharCore(flag, true)
704
- return
705
- } else {
706
- // First RI: store and wait for second
707
- pendingRegionalIndicator = ch
708
- return
709
- }
710
- }
711
-
712
- // Flush any pending regional indicator that wasn't paired
713
- if (pendingRegionalIndicator !== null) {
714
- const ri = pendingRegionalIndicator
715
- pendingRegionalIndicator = null
716
- writeCharCore(ri, true)
717
- }
718
-
719
- const wide = isWide(codePoint)
720
- writeCharCore(ch, wide)
721
- }
722
-
723
- function writeCharCore(ch: string, wide: boolean): void {
724
- // Guard: cannot write to a zero-dimension grid
725
- if (cols <= 0 || rows <= 0) return
726
-
727
- const charWidth = wide ? 2 : 1
728
- const wrapBoundary = leftRightMarginMode ? rightMargin + 1 : cols
729
- const wrapReturn = leftRightMarginMode ? leftMargin : 0
730
-
731
- // Handle autowrap at end of line (or right margin)
732
- if (curX + charWidth > wrapBoundary) {
733
- if (autoWrap) {
734
- // Mark this row as soft-wrapped (auto-wrap caused the line break)
735
- softWrapped[curY] = true
736
- curX = wrapReturn
737
- curY++
738
- if (curY > scrollBottom) {
739
- curY = scrollBottom
740
- scrollUp(scrollTop, scrollBottom)
741
- }
742
- } else {
743
- curX = wrapBoundary - charWidth
744
- }
745
- }
746
-
747
- // Insert mode: shift existing characters right before writing
748
- if (insertMode) {
749
- const row = grid[curY]!
750
- const insertEnd = leftRightMarginMode ? rightMargin + 1 : cols
751
- for (let i = 0; i < charWidth; i++) {
752
- // Shift cells right within margin, dropping the cell at the right edge
753
- row.splice(insertEnd - 1, 1)
754
- row.splice(curX, 0, EMPTY_CELL)
755
- }
756
- }
757
-
758
- // Copy-on-write: if cell is the shared EMPTY_CELL sentinel, create a fresh object
759
- const row = grid[curY]!
760
- let cell = row[curX]!
761
- if (cell === EMPTY_CELL) {
762
- cell = { ...EMPTY_CELL }
763
- row[curX] = cell
764
- }
765
- cell.char = ch
766
- cell.fg = attrs.fg ? { ...attrs.fg } : null
767
- cell.bg = attrs.bg ? { ...attrs.bg } : null
768
- cell.bold = attrs.bold
769
- cell.faint = attrs.faint
770
- cell.italic = attrs.italic
771
- cell.underline = attrs.underline
772
- cell.underlineColor = attrs.underlineColor ? { ...attrs.underlineColor } : null
773
- cell.overline = attrs.overline
774
- cell.strikethrough = attrs.strikethrough
775
- cell.inverse = attrs.inverse
776
- cell.hidden = attrs.hidden
777
- cell.blink = attrs.blink
778
- cell.wide = wide
779
- cell.url = attrs.url
780
-
781
- if (wide) {
782
- widenCell(row, curX, cell)
783
- }
784
-
785
- curX += charWidth
786
- lastChar = ch
787
- }
788
-
789
- // ── CSI handler ──
790
-
791
- function handleCSI(params: string, intermediates: string, finalByte: string): void {
792
- const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
793
-
794
- // CSI with intermediates
795
- if (intermediates === " ") {
796
- if (finalByte === "q") {
797
- // DECSCUSR - Set Cursor Shape
798
- const ps = parts[0] ?? 0
799
- switch (ps) {
800
- case 0:
801
- case 1:
802
- cursorShape = "block"
803
- cursorBlinking = true
804
- break
805
- case 2:
806
- cursorShape = "block"
807
- cursorBlinking = false
808
- break
809
- case 3:
810
- cursorShape = "underline"
811
- cursorBlinking = true
812
- break
813
- case 4:
814
- cursorShape = "underline"
815
- cursorBlinking = false
816
- break
817
- case 5:
818
- cursorShape = "bar"
819
- cursorBlinking = true
820
- break
821
- case 6:
822
- cursorShape = "bar"
823
- cursorBlinking = false
824
- break
825
- }
826
- }
827
- return
828
- }
829
-
830
- if (intermediates === "!") {
831
- if (finalByte === "p") {
832
- // DECSTR - Soft Terminal Reset
833
- softReset()
834
- }
835
- return
836
- }
837
-
838
- if (intermediates === "$") {
839
- // Mode reporting is handled in CSI private
840
- return
841
- }
842
-
843
- switch (finalByte) {
844
- case "A": // CUU - Cursor Up
845
- curY -= Math.max(parts[0] ?? 1, 1)
846
- clampCursor()
847
- break
848
- case "B": // CUD - Cursor Down
849
- curY += Math.max(parts[0] ?? 1, 1)
850
- clampCursor()
851
- break
852
- case "C": // CUF - Cursor Forward
853
- curX += Math.max(parts[0] ?? 1, 1)
854
- clampCursor()
855
- break
856
- case "D": // CUB - Cursor Back
857
- curX -= Math.max(parts[0] ?? 1, 1)
858
- clampCursor()
859
- break
860
- case "E": // CNL - Cursor Next Line
861
- curY += Math.max(parts[0] ?? 1, 1)
862
- curX = 0
863
- clampCursor()
864
- break
865
- case "F": // CPL - Cursor Previous Line
866
- curY -= Math.max(parts[0] ?? 1, 1)
867
- curX = 0
868
- clampCursor()
869
- break
870
- case "G": // CHA - Cursor Horizontal Absolute
871
- curX = (parts[0] ?? 1) - 1
872
- clampCursor()
873
- break
874
- case "H": // CUP - Cursor Position
875
- case "f": // HVP - same as CUP
876
- if (originMode) {
877
- // DECOM: positions are relative to scroll region
878
- curY = scrollTop + (parts[0] ?? 1) - 1
879
- curX = (parts[1] ?? 1) - 1
880
- // Clamp to scroll region bounds
881
- if (curY < scrollTop) curY = scrollTop
882
- if (curY > scrollBottom) curY = scrollBottom
883
- if (curX < 0) curX = 0
884
- if (curX >= cols) curX = cols - 1
885
- } else {
886
- curY = (parts[0] ?? 1) - 1
887
- curX = (parts[1] ?? 1) - 1
888
- clampCursor()
889
- }
890
- break
891
- case "J": // ED - Erase in Display
892
- handleEraseDisplay(parts[0] ?? 0)
893
- break
894
- case "K": // EL - Erase in Line
895
- handleEraseLine(parts[0] ?? 0)
896
- break
897
- case "L": // IL - Insert Lines
898
- handleInsertLines(Math.max(parts[0] ?? 1, 1))
899
- break
900
- case "M": // DL - Delete Lines
901
- handleDeleteLines(Math.max(parts[0] ?? 1, 1))
902
- break
903
- case "P": // DCH - Delete Characters
904
- handleDeleteChars(Math.max(parts[0] ?? 1, 1))
905
- break
906
- case "@": // ICH - Insert Characters
907
- handleInsertChars(Math.max(parts[0] ?? 1, 1))
908
- break
909
- case "X": // ECH - Erase Characters
910
- handleEraseChars(Math.max(parts[0] ?? 1, 1))
911
- break
912
- case "S": // SU - Scroll Up
913
- for (let i = 0; i < Math.max(parts[0] ?? 1, 1); i++) {
914
- scrollUp(scrollTop, scrollBottom)
915
- }
916
- break
917
- case "T": // SD - Scroll Down
918
- for (let i = 0; i < Math.max(parts[0] ?? 1, 1); i++) {
919
- scrollDown(scrollTop, scrollBottom)
920
- }
921
- break
922
- case "b": // REP - Repeat preceding character
923
- if (lastChar) {
924
- const count = Math.max(parts[0] ?? 1, 1)
925
- for (let i = 0; i < count; i++) {
926
- writeChar(lastChar)
927
- }
928
- }
929
- break
930
- case "d": // VPA - Line Position Absolute
931
- curY = (parts[0] ?? 1) - 1
932
- clampCursor()
933
- break
934
- case "m": // SGR - Select Graphic Rendition
935
- handleSGR(params)
936
- break
937
- case "r": // DECSTBM - Set Scrolling Region
938
- scrollTop = (parts[0] ?? 1) - 1
939
- scrollBottom = (parts[1] ?? rows) - 1
940
- if (scrollTop < 0) scrollTop = 0
941
- if (scrollBottom >= rows) scrollBottom = rows - 1
942
- if (scrollTop > scrollBottom) {
943
- scrollTop = 0
944
- scrollBottom = rows - 1
945
- }
946
- curX = 0
947
- curY = originMode ? scrollTop : 0
948
- break
949
- case "n": // DSR - Device Status Report
950
- if (onResponse) {
951
- if (parts[0] === 5) {
952
- // Status report - OK
953
- onResponse("\x1b[0n")
954
- } else if (parts[0] === 6) {
955
- // Cursor position report
956
- onResponse(`\x1b[${curY + 1};${curX + 1}R`)
957
- }
958
- }
959
- break
960
- case "c": // DA1 - Primary Device Attributes
961
- if (onResponse) {
962
- if (params === "" || params === "0") {
963
- // VT200 (62) + sixel (4). We include sixel because we parse and
964
- // preserve sixel data via getSixelImages() — applications checking
965
- // DA1 for sixel support before sending image data will work correctly.
966
- onResponse("\x1b[?62;4c")
967
- }
968
- }
969
- break
970
- case "h": // SM - Set Mode (non-private)
971
- for (const code of parts) {
972
- if (code === 4) insertMode = true // IRM - Insert/Replace Mode
973
- }
974
- break
975
- case "l": // RM - Reset Mode (non-private)
976
- for (const code of parts) {
977
- if (code === 4) insertMode = false
978
- }
979
- break
980
- case "s": // SCP - Save Cursor Position / DECSLRM when left/right margin mode active
981
- if (leftRightMarginMode && intermediates === "") {
982
- // DECSLRM - Set Left and Right Margins (1-based params)
983
- const pl = parts[0] ?? 0
984
- const pr = parts[1] ?? 0
985
- leftMargin = pl > 0 ? pl - 1 : 0
986
- rightMargin = pr > 0 ? pr - 1 : cols - 1
987
- if (leftMargin >= rightMargin) {
988
- leftMargin = 0
989
- rightMargin = cols - 1
990
- }
991
- if (leftMargin >= cols) leftMargin = 0
992
- if (rightMargin >= cols) rightMargin = cols - 1
993
- // Move cursor to home (top-left of margin area if origin mode, else absolute home)
994
- curX = originMode ? leftMargin : 0
995
- curY = originMode ? scrollTop : 0
996
- } else {
997
- savedCurX = curX
998
- savedCurY = curY
999
- }
1000
- break
1001
- case "u": // RCP - Restore Cursor Position
1002
- curX = savedCurX
1003
- curY = savedCurY
1004
- clampCursor()
1005
- break
1006
- default:
1007
- break
1008
- }
1009
- }
1010
-
1011
- function handleCSIGt(params: string, _intermediates: string, finalByte: string): void {
1012
- const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
1013
-
1014
- // CSI > sequences
1015
- if (finalByte === "c") {
1016
- // DA2 - Secondary Device Attributes
1017
- if (onResponse) {
1018
- if (params === "" || params === "0") {
1019
- onResponse("\x1b[>1;100;0c")
1020
- }
1021
- }
1022
- } else if (finalByte === "q") {
1023
- // XTVERSION
1024
- if (onResponse) {
1025
- if (params === "" || params === "0") {
1026
- onResponse("\x1bP>|vterm.js 0.1.0\x1b\\")
1027
- }
1028
- }
1029
- } else if (finalByte === "u") {
1030
- // CSI > flags u — Push keyboard mode (Kitty keyboard protocol)
1031
- kittyKeyboardStack.push(kittyKeyboardFlags)
1032
- kittyKeyboardFlags = parts[0] ?? 0
1033
- }
1034
- }
1035
-
1036
- function handleCSILt(_params: string, _intermediates: string, finalByte: string): void {
1037
- // CSI < sequences
1038
- if (finalByte === "u") {
1039
- // CSI < u — Pop keyboard mode (Kitty keyboard protocol)
1040
- kittyKeyboardFlags = kittyKeyboardStack.pop() ?? 0
1041
- }
1042
- }
1043
-
1044
- function handleCSIEq(params: string, _intermediates: string, finalByte: string): void {
1045
- // CSI = sequences
1046
- if (finalByte === "c") {
1047
- // DA3 - Tertiary Device Attributes
1048
- if (onResponse) {
1049
- if (params === "" || params === "0") {
1050
- onResponse("\x1bP!|00000000\x1b\\")
1051
- }
1052
- }
1053
- }
1054
- }
1055
-
1056
- function handleCSIPrivate(params: string, intermediates: string, finalByte: string): void {
1057
- const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
1058
-
1059
- // CSI ? u — Query keyboard mode (Kitty keyboard protocol)
1060
- if (finalByte === "u") {
1061
- if (onResponse) {
1062
- onResponse(`\x1b[?${kittyKeyboardFlags}u`)
1063
- }
1064
- return
1065
- }
1066
-
1067
- // DECRPM - Mode reporting: CSI ? Pd $ p
1068
- if (intermediates === "$" && finalByte === "p") {
1069
- if (onResponse) {
1070
- const mode = parts[0] ?? 0
1071
- let value = 0 // 0 = not recognized
1072
- switch (mode) {
1073
- case 1:
1074
- value = applicationCursor ? 1 : 2
1075
- break
1076
- case 6:
1077
- value = originMode ? 1 : 2
1078
- break
1079
- case 7:
1080
- value = autoWrap ? 1 : 2
1081
- break
1082
- case 25:
1083
- value = curVisible ? 1 : 2
1084
- break
1085
- case 47:
1086
- case 1047:
1087
- case 1049:
1088
- value = useAltScreen ? 1 : 2
1089
- break
1090
- case 66:
1091
- value = applicationKeypad ? 1 : 2
1092
- break
1093
- case 1000:
1094
- case 1002:
1095
- case 1003:
1096
- value = mouseTracking ? 1 : 2
1097
- break
1098
- case 1004:
1099
- value = focusTracking ? 1 : 2
1100
- break
1101
- case 1006:
1102
- value = sgrMouse ? 1 : 2
1103
- break
1104
- case 2004:
1105
- value = bracketedPaste ? 1 : 2
1106
- break
1107
- case 2026:
1108
- value = syncOutput ? 1 : 2
1109
- break
1110
- case 69:
1111
- value = leftRightMarginMode ? 1 : 2
1112
- break
1113
- case 2031:
1114
- value = colorSchemeReporting ? 1 : 2
1115
- break
1116
- case 5:
1117
- value = reverseVideo ? 1 : 2
1118
- break
1119
- case 4:
1120
- value = insertMode ? 1 : 2
1121
- break
1122
- }
1123
- onResponse(`\x1b[?${mode};${value}$y`)
1124
- }
1125
- return
1126
- }
1127
-
1128
- // Private DSR: CSI ? Ps n
1129
- if (finalByte === "n") {
1130
- if (onResponse) {
1131
- const ps = parts[0] ?? 0
1132
- if (ps === 6) {
1133
- // DECXCPR — Extended Cursor Position Report
1134
- onResponse(`\x1b[?${curY + 1};${curX + 1}n`)
1135
- } else if (ps === 997) {
1136
- // Color scheme report: 1 = dark, 2 = light
1137
- onResponse("\x1b[?997;1n")
1138
- }
1139
- }
1140
- return
1141
- }
1142
-
1143
- // DECSED / DECSEL — selective erase (CSI ? J / CSI ? K)
1144
- // We treat selective erase same as normal erase (no DECSCA protection tracking)
1145
- if (finalByte === "J") {
1146
- handleEraseDisplay(parts[0] ?? 0)
1147
- return
1148
- }
1149
- if (finalByte === "K") {
1150
- handleEraseLine(parts[0] ?? 0)
1151
- return
1152
- }
1153
-
1154
- const set = finalByte === "h"
1155
-
1156
- for (const code of parts) {
1157
- switch (code) {
1158
- case 1: // DECCKM - Application Cursor
1159
- applicationCursor = set
1160
- break
1161
- case 4: // IRM - Insert Mode (via DEC private)
1162
- insertMode = set
1163
- break
1164
- case 5: // DECSCNM - Reverse Video
1165
- reverseVideo = set
1166
- break
1167
- case 6: // DECOM - Origin Mode
1168
- originMode = set
1169
- break
1170
- case 7: // DECAWM - Autowrap Mode
1171
- autoWrap = set
1172
- break
1173
- case 25: // DECTCEM - Cursor Visible
1174
- curVisible = set
1175
- break
1176
- case 47: // Alternate screen buffer (old)
1177
- case 1047: // Alternate screen buffer
1178
- if (set && !useAltScreen) {
1179
- useAltScreen = true
1180
- grid = altGrid
1181
- softWrapped = altSoftWrapped
1182
- } else if (!set && useAltScreen) {
1183
- useAltScreen = false
1184
- grid = mainGrid
1185
- softWrapped = mainSoftWrapped
1186
- }
1187
- break
1188
- case 66: // DECNKM - Application Keypad
1189
- applicationKeypad = set
1190
- break
1191
- case 9: // X10 mouse
1192
- case 1000: // Mouse tracking (basic)
1193
- case 1002: // Mouse tracking (button events)
1194
- case 1003: // Mouse tracking (all events)
1195
- case 1015: // urxvt mouse encoding
1196
- case 1016: // SGR pixel mouse
1197
- mouseTracking = set
1198
- mouseTrackingMode = set ? code : 0
1199
- break
1200
- case 1004: // Focus tracking
1201
- focusTracking = set
1202
- break
1203
- case 1006: // SGR mouse mode
1204
- sgrMouse = set
1205
- break
1206
- case 1049: // Alternate screen buffer + save/restore cursor
1207
- if (set && !useAltScreen) {
1208
- savedCurX = curX
1209
- savedCurY = curY
1210
- useAltScreen = true
1211
- altGrid = makeGrid(cols, rows)
1212
- altSoftWrapped = new Array(rows).fill(false)
1213
- grid = altGrid
1214
- softWrapped = altSoftWrapped
1215
- curX = 0
1216
- curY = 0
1217
- } else if (!set && useAltScreen) {
1218
- useAltScreen = false
1219
- grid = mainGrid
1220
- softWrapped = mainSoftWrapped
1221
- curX = savedCurX
1222
- curY = savedCurY
1223
- clampCursor()
1224
- }
1225
- break
1226
- case 69: // DECLRMM - Left/Right Margin Mode
1227
- leftRightMarginMode = set
1228
- if (!set) {
1229
- // Reset margins when mode is disabled
1230
- leftMargin = 0
1231
- rightMargin = cols - 1
1232
- }
1233
- break
1234
- case 2004: // Bracketed paste
1235
- bracketedPaste = set
1236
- break
1237
- case 2026: // Synchronized output
1238
- syncOutput = set
1239
- break
1240
- case 2031: // Color scheme reporting
1241
- colorSchemeReporting = set
1242
- break
1243
- }
1244
- }
1245
- }
1246
-
1247
- // ── Erase operations ──
1248
-
1249
- function handleEraseDisplay(mode: number): void {
1250
- switch (mode) {
1251
- case 0: // Erase from cursor to end
1252
- eraseCells(curY, curX, curY, cols - 1)
1253
- for (let row = curY + 1; row < rows; row++) {
1254
- eraseCells(row, 0, row, cols - 1)
1255
- }
1256
- break
1257
- case 1: // Erase from start to cursor
1258
- for (let row = 0; row < curY; row++) {
1259
- eraseCells(row, 0, row, cols - 1)
1260
- }
1261
- eraseCells(curY, 0, curY, curX)
1262
- break
1263
- case 2: // Erase entire display
1264
- case 3: // Erase entire display + scrollback
1265
- for (let row = 0; row < rows; row++) {
1266
- eraseCells(row, 0, row, cols - 1)
1267
- }
1268
- if (mode === 3) {
1269
- scrollback.length = 0
1270
- }
1271
- break
1272
- }
1273
- }
1274
-
1275
- function handleEraseLine(mode: number): void {
1276
- const eraseRight = leftRightMarginMode ? rightMargin : cols - 1
1277
- const eraseLeft = leftRightMarginMode ? leftMargin : 0
1278
- switch (mode) {
1279
- case 0: // Erase from cursor to end of line (or right margin)
1280
- eraseCells(curY, curX, curY, eraseRight)
1281
- break
1282
- case 1: // Erase from start (or left margin) to cursor
1283
- eraseCells(curY, eraseLeft, curY, curX)
1284
- break
1285
- case 2: // Erase entire line (within margins if active)
1286
- eraseCells(curY, eraseLeft, curY, eraseRight)
1287
- break
1288
- }
1289
- }
1290
-
1291
- function eraseCells(row: number, startCol: number, _endRow: number, endCol: number): void {
1292
- const r = grid[row]
1293
- if (!r) return
1294
- for (let col = startCol; col <= endCol && col < cols; col++) {
1295
- const cell = emptyCell()
1296
- // Fill erased cells with the current background color
1297
- if (attrs.bg) {
1298
- cell.bg = { ...attrs.bg }
1299
- }
1300
- r[col] = cell
1301
- }
1302
- }
1303
-
1304
- function handleInsertLines(count: number): void {
1305
- if (curY < scrollTop || curY > scrollBottom) return
1306
- for (let i = 0; i < count; i++) {
1307
- scrollDown(curY, scrollBottom)
1308
- }
1309
- }
1310
-
1311
- function handleDeleteLines(count: number): void {
1312
- if (curY < scrollTop || curY > scrollBottom) return
1313
- for (let i = 0; i < count; i++) {
1314
- scrollUp(curY, scrollBottom)
1315
- }
1316
- }
1317
-
1318
- function handleDeleteChars(count: number): void {
1319
- const row = grid[curY]
1320
- if (!row) return
1321
- if (leftRightMarginMode && (leftMargin > 0 || rightMargin < cols - 1)) {
1322
- // Delete within margin bounds: shift left, insert blanks at right margin
1323
- for (let i = 0; i < count; i++) {
1324
- if (curX <= rightMargin) {
1325
- row.splice(curX, 1)
1326
- row.splice(rightMargin, 0, emptyCell())
1327
- }
1328
- }
1329
- } else {
1330
- for (let i = 0; i < count; i++) {
1331
- if (curX < cols) {
1332
- row.splice(curX, 1)
1333
- row.push(emptyCell())
1334
- }
1335
- }
1336
- }
1337
- }
1338
-
1339
- function handleInsertChars(count: number): void {
1340
- const row = grid[curY]
1341
- if (!row) return
1342
- if (leftRightMarginMode && (leftMargin > 0 || rightMargin < cols - 1)) {
1343
- // Insert within margin bounds: shift right, drop chars at right margin
1344
- for (let i = 0; i < count; i++) {
1345
- row.splice(rightMargin, 1)
1346
- row.splice(curX, 0, emptyCell())
1347
- }
1348
- } else {
1349
- for (let i = 0; i < count; i++) {
1350
- row.splice(curX, 0, emptyCell())
1351
- row.pop()
1352
- }
1353
- }
1354
- }
1355
-
1356
- function handleEraseChars(count: number): void {
1357
- const row = grid[curY]
1358
- if (!row) return
1359
- for (let i = 0; i < count && curX + i < cols; i++) {
1360
- row[curX + i] = emptyCell()
1361
- }
1362
- }
1363
-
1364
- // ── SGR (Select Graphic Rendition) ──
1365
-
1366
- function handleSGR(rawParams: string): void {
1367
- const segments = rawParams.split(";")
1368
- const params: number[] = []
1369
- const subParams = new Map<number, number[]>()
1370
- for (const seg of segments) {
1371
- if (seg.includes(":")) {
1372
- const subs = seg.split(":").map((s) => (s === "" ? 0 : parseInt(s, 10)))
1373
- subParams.set(params.length, subs)
1374
- params.push(subs[0]!)
1375
- } else {
1376
- params.push(seg === "" ? 0 : parseInt(seg, 10))
1377
- }
1378
- }
1379
-
1380
- if (params.length === 0 || (params.length === 1 && params[0] === 0)) {
1381
- attrs = resetAttrs()
1382
- return
1383
- }
1384
-
1385
- let i = 0
1386
- while (i < params.length) {
1387
- const code = params[i]!
1388
- switch (code) {
1389
- case 0:
1390
- attrs = resetAttrs()
1391
- break
1392
- case 1:
1393
- attrs.bold = true
1394
- break
1395
- case 2:
1396
- attrs.faint = true
1397
- break
1398
- case 3:
1399
- attrs.italic = true
1400
- break
1401
- case 4: {
1402
- // SGR 4 with optional sub-parameter: 4:0=none, 4:1=single, 4:3=curly, etc.
1403
- const subs = subParams.get(i)
1404
- if (subs && subs.length > 1) {
1405
- const sub = subs[1]!
1406
- switch (sub) {
1407
- case 0:
1408
- attrs.underline = "none"
1409
- break
1410
- case 1:
1411
- attrs.underline = "single"
1412
- break
1413
- case 2:
1414
- attrs.underline = "double"
1415
- break
1416
- case 3:
1417
- attrs.underline = "curly"
1418
- break
1419
- case 4:
1420
- attrs.underline = "dotted"
1421
- break
1422
- case 5:
1423
- attrs.underline = "dashed"
1424
- break
1425
- default:
1426
- attrs.underline = "single"
1427
- break
1428
- }
1429
- } else {
1430
- attrs.underline = "single"
1431
- }
1432
- break
1433
- }
1434
- case 5: // Slow blink
1435
- attrs.blink = true
1436
- break
1437
- case 6: // Rapid blink (treat same as blink)
1438
- attrs.blink = true
1439
- break
1440
- case 7:
1441
- attrs.inverse = true
1442
- break
1443
- case 8: // Hidden/conceal
1444
- attrs.hidden = true
1445
- break
1446
- case 9:
1447
- attrs.strikethrough = true
1448
- break
1449
- case 21: // Double underline
1450
- attrs.underline = "double"
1451
- break
1452
- case 22: // Normal intensity (neither bold nor faint)
1453
- attrs.bold = false
1454
- attrs.faint = false
1455
- break
1456
- case 23:
1457
- attrs.italic = false
1458
- break
1459
- case 24:
1460
- attrs.underline = "none"
1461
- break
1462
- case 25: // Blink off
1463
- attrs.blink = false
1464
- break
1465
- case 27:
1466
- attrs.inverse = false
1467
- break
1468
- case 28: // Reveal (turn off hidden/conceal)
1469
- attrs.hidden = false
1470
- break
1471
- case 29:
1472
- attrs.strikethrough = false
1473
- break
1474
- // Foreground colors 30-37
1475
- case 30:
1476
- case 31:
1477
- case 32:
1478
- case 33:
1479
- case 34:
1480
- case 35:
1481
- case 36:
1482
- case 37:
1483
- attrs.fg = { ...PALETTE_256[code - 30]! }
1484
- break
1485
- case 38: {
1486
- // Extended foreground: 38;5;N (256) or 38;2;R;G;B (truecolor)
1487
- // Also handle colon form: 38:5:N or 38:2:R:G:B
1488
- const subs = subParams.get(i)
1489
- if (subs && subs.length >= 3) {
1490
- const result = parseExtendedColorFromSubs(subs)
1491
- if (result) attrs.fg = result
1492
- } else {
1493
- const result = parseExtendedColor(params, i)
1494
- if (result) {
1495
- attrs.fg = result.color
1496
- i = result.nextIndex - 1
1497
- }
1498
- }
1499
- break
1500
- }
1501
- case 39: // Default foreground
1502
- attrs.fg = null
1503
- break
1504
- // Background colors 40-47
1505
- case 40:
1506
- case 41:
1507
- case 42:
1508
- case 43:
1509
- case 44:
1510
- case 45:
1511
- case 46:
1512
- case 47:
1513
- attrs.bg = { ...PALETTE_256[code - 40]! }
1514
- break
1515
- case 48: {
1516
- // Extended background: 48;5;N (256) or 48;2;R;G;B (truecolor)
1517
- const subs = subParams.get(i)
1518
- if (subs && subs.length >= 3) {
1519
- const result = parseExtendedColorFromSubs(subs)
1520
- if (result) attrs.bg = result
1521
- } else {
1522
- const result = parseExtendedColor(params, i)
1523
- if (result) {
1524
- attrs.bg = result.color
1525
- i = result.nextIndex - 1
1526
- }
1527
- }
1528
- break
1529
- }
1530
- case 49: // Default background
1531
- attrs.bg = null
1532
- break
1533
- case 53: // Overline
1534
- attrs.overline = true
1535
- break
1536
- case 55: // Overline off
1537
- attrs.overline = false
1538
- break
1539
- case 58: {
1540
- // Underline color: 58;5;N (256) or 58;2;R;G;B (truecolor)
1541
- // Also handle colon form: 58:5:N or 58:2:R:G:B
1542
- const subs = subParams.get(i)
1543
- if (subs && subs.length >= 3) {
1544
- const result = parseExtendedColorFromSubs(subs)
1545
- if (result) attrs.underlineColor = result
1546
- } else {
1547
- const result = parseExtendedColor(params, i)
1548
- if (result) {
1549
- attrs.underlineColor = result.color
1550
- i = result.nextIndex - 1
1551
- }
1552
- }
1553
- break
1554
- }
1555
- case 59: // Default underline color
1556
- attrs.underlineColor = null
1557
- break
1558
- // Bright foreground 90-97
1559
- case 90:
1560
- case 91:
1561
- case 92:
1562
- case 93:
1563
- case 94:
1564
- case 95:
1565
- case 96:
1566
- case 97:
1567
- attrs.fg = { ...PALETTE_256[code - 90 + 8]! }
1568
- break
1569
- // Bright background 100-107
1570
- case 100:
1571
- case 101:
1572
- case 102:
1573
- case 103:
1574
- case 104:
1575
- case 105:
1576
- case 106:
1577
- case 107:
1578
- attrs.bg = { ...PALETTE_256[code - 100 + 8]! }
1579
- break
1580
- }
1581
- i++
1582
- }
1583
- }
1584
-
1585
- function parseExtendedColor(params: number[], startIndex: number): { color: CellColor; nextIndex: number } | null {
1586
- if (startIndex + 1 >= params.length) return null
1587
-
1588
- const type = params[startIndex + 1]
1589
- if (type === 5 && startIndex + 2 < params.length) {
1590
- const idx = params[startIndex + 2]!
1591
- const color = PALETTE_256[idx] ?? { r: 0, g: 0, b: 0 }
1592
- return { color: { ...color }, nextIndex: startIndex + 3 }
1593
- } else if (type === 2 && startIndex + 4 < params.length) {
1594
- return {
1595
- color: {
1596
- r: params[startIndex + 2]!,
1597
- g: params[startIndex + 3]!,
1598
- b: params[startIndex + 4]!,
1599
- },
1600
- nextIndex: startIndex + 5,
1601
- }
1602
- }
1603
- return null
1604
- }
1605
-
1606
- /** Parse extended color from colon sub-parameters (e.g., 38:2:R:G:B or 38:5:N) */
1607
- function parseExtendedColorFromSubs(subs: number[]): CellColor | null {
1608
- if (subs.length < 3) return null
1609
- const type = subs[1]
1610
- if (type === 5 && subs.length >= 3) {
1611
- const idx = subs[2]!
1612
- const color = PALETTE_256[idx] ?? { r: 0, g: 0, b: 0 }
1613
- return { ...color }
1614
- } else if (type === 2) {
1615
- // Can be 38:2:R:G:B or 38:2:colorspace:R:G:B
1616
- if (subs.length >= 5) {
1617
- // 38:2:R:G:B (no colorspace) or 38:2:cs:R:G:B
1618
- // If subs.length >= 6, assume colorspace variant
1619
- if (subs.length >= 6) {
1620
- return { r: subs[3]!, g: subs[4]!, b: subs[5]! }
1621
- }
1622
- return { r: subs[2]!, g: subs[3]!, b: subs[4]! }
1623
- } else if (subs.length >= 4) {
1624
- return { r: subs[2]!, g: subs[3]!, b: 0 }
1625
- }
1626
- }
1627
- return null
1628
- }
1629
-
1630
- // ── OSC handler ──
1631
-
1632
- function handleOSC(oscString: string): void {
1633
- const semicolonIdx = oscString.indexOf(";")
1634
- if (semicolonIdx === -1) return
1635
-
1636
- const code = parseInt(oscString.substring(0, semicolonIdx), 10)
1637
- const value = oscString.substring(semicolonIdx + 1)
1638
-
1639
- switch (code) {
1640
- case 0: // Set icon name and window title
1641
- case 2: // Set window title
1642
- title = value
1643
- break
1644
- case 133: {
1645
- // Semantic prompt markers (FinalTerm / shell integration)
1646
- // Value format: "X" or "X;params" where X is A/B/C/D
1647
- const marker = value.charAt(0)
1648
- switch (marker) {
1649
- case "A": // Start of prompt
1650
- semanticZones.push({ type: "prompt", startRow: curY, startCol: curX })
1651
- break
1652
- case "B": // End of prompt / start of command
1653
- semanticZones.push({ type: "command", startRow: curY, startCol: curX })
1654
- break
1655
- case "C": // End of command / start of output
1656
- semanticZones.push({ type: "output", startRow: curY, startCol: curX })
1657
- break
1658
- case "D": // End of output (exit code in params, ignored for storage)
1659
- break
1660
- }
1661
- break
1662
- }
1663
- case 1: // Set icon name (ignore)
1664
- break
1665
- case 7: // Current working directory: OSC 7 ; file://host/path ST
1666
- cwd = value
1667
- break
1668
- case 8: {
1669
- // Hyperlink: OSC 8 ; params ; url ST
1670
- // Format: 8;params;url or 8;;url or 8;; (close)
1671
- const secondSemicolon = value.indexOf(";")
1672
- if (secondSemicolon !== -1) {
1673
- const url = value.substring(secondSemicolon + 1)
1674
- attrs.url = url || null
1675
- }
1676
- break
1677
- }
1678
- case 10: // Foreground color query
1679
- if (value === "?" && onResponse) {
1680
- // Default foreground (white-ish)
1681
- onResponse("\x1b]10;rgb:ffff/ffff/ffff\x1b\\")
1682
- }
1683
- break
1684
- case 11: // Background color query
1685
- if (value === "?" && onResponse) {
1686
- // Default background (black)
1687
- onResponse("\x1b]11;rgb:0000/0000/0000\x1b\\")
1688
- }
1689
- break
1690
- case 52: {
1691
- // Clipboard: OSC 52 ; selection ; base64-data ST
1692
- const clipSemi = value.indexOf(";")
1693
- if (clipSemi !== -1) {
1694
- const data = value.substring(clipSemi + 1)
1695
- if (data === "?") {
1696
- // Query clipboard
1697
- if (onResponse) {
1698
- const encoded = btoa(clipboard)
1699
- onResponse(`\x1b]52;c;${encoded}\x1b\\`)
1700
- }
1701
- } else {
1702
- // Set clipboard
1703
- try {
1704
- clipboard = atob(data)
1705
- } catch {
1706
- clipboard = ""
1707
- }
1708
- if (onResponse) {
1709
- onResponse(`\x1b]52;c;${data}\x1b\\`)
1710
- }
1711
- }
1712
- }
1713
- break
1714
- }
1715
- case 9: // Notifications: OSC 9 ; message ST (iTerm2/ConEmu convention)
1716
- notifications.push(value)
1717
- break
1718
- case 633: {
1719
- // VS Code shell integration (OSC 633 is a superset of OSC 133)
1720
- // Maps to the same semantic zone model as FinalTerm markers.
1721
- const marker = value.charAt(0)
1722
- switch (marker) {
1723
- case "A":
1724
- semanticZones.push({ type: "prompt", startRow: curY, startCol: curX })
1725
- break
1726
- case "B":
1727
- semanticZones.push({ type: "command", startRow: curY, startCol: curX })
1728
- break
1729
- case "C":
1730
- semanticZones.push({ type: "output", startRow: curY, startCol: curX })
1731
- break
1732
- case "D":
1733
- break
1734
- }
1735
- break
1736
- }
1737
- case 66: {
1738
- // OSC 66 — Text Sizing
1739
- if (value === "?" && onResponse) {
1740
- // Query: respond with current text scale
1741
- onResponse(`\x1b]66;s=${textScale}\x1b\\`)
1742
- } else {
1743
- // Parse key=value pairs (e.g., "s=2")
1744
- const kvPairs = value.split(";")
1745
- for (const pair of kvPairs) {
1746
- const [k, v] = pair.split("=")
1747
- if (k === "s" && v !== undefined) {
1748
- const n = parseInt(v, 10)
1749
- if (!isNaN(n) && n > 0) {
1750
- textScale = n
1751
- }
1752
- }
1753
- }
1754
- }
1755
- break
1756
- }
1757
- case 5522: {
1758
- // OSC 5522 — Advanced Clipboard (Kitty clipboard protocol)
1759
- if (value === "?" && onResponse) {
1760
- // Query: respond with stored clipboard data
1761
- const encoded = btoa(advancedClipboard)
1762
- onResponse(`\x1b]5522;${encoded}\x1b\\`)
1763
- } else {
1764
- // Store clipboard data (base64-encoded)
1765
- try {
1766
- advancedClipboard = atob(value)
1767
- } catch {
1768
- advancedClipboard = ""
1769
- }
1770
- }
1771
- break
1772
- }
1773
- case 1337: {
1774
- // iTerm2 proprietary sequences
1775
- if (value === "ReportCellSize" && onResponse) {
1776
- // Report default 8x16 pixel cell size (standard monospace ratio)
1777
- onResponse("\x1b]1337;ReportCellSize=16;8\x1b\\")
1778
- } else if (value === "RequestCapabilities" && onResponse) {
1779
- // Report empty capabilities (protocol supported, no iTerm2-specific features)
1780
- onResponse("\x1b]1337;Capabilities=\x1b\\")
1781
- }
1782
- // Inline images (File=...) are silently consumed — no pixel framebuffer
1783
- break
1784
- }
1785
- }
1786
- }
1787
-
1788
- // ── DCS handler ──
1789
-
1790
- function handleDCS(data: string): void {
1791
- // Sixel graphics: DCS [Ps;Ps;Ps] q [sixel-data]
1792
- const match = data.match(/^(\d*(?:;\d*)*)q(.*)$/s)
1793
- if (match) {
1794
- hasSixel = true
1795
- sixelImages.push({
1796
- data: match[2]!,
1797
- row: dcsStartRow,
1798
- col: dcsStartCol,
1799
- })
1800
- return
1801
- }
1802
-
1803
- // DECRQSS: DCS $ q Pt ST → response DCS Ps $ r Pt ST
1804
- if (data.startsWith("$q") && onResponse) {
1805
- const pt = data.substring(2)
1806
- if (pt === '"p') {
1807
- // DECSCL - Conformance level: VT200 mode, 8-bit controls
1808
- onResponse('\x1bP1$r62;1"p\x1b\\')
1809
- } else {
1810
- // Not recognized
1811
- onResponse("\x1bP0$r\x1b\\")
1812
- }
1813
- return
1814
- }
1815
-
1816
- // XTGETTCAP: DCS + q hex ST → response DCS 1 + r hex = hexvalue ST
1817
- if (data.startsWith("+q") && onResponse) {
1818
- const hexName = data.substring(2)
1819
- if (hexName === "544e") {
1820
- // "TN" = terminal name → "vterm"
1821
- const hexValue = Array.from(new TextEncoder().encode("vterm"))
1822
- .map((b) => b.toString(16).padStart(2, "0"))
1823
- .join("")
1824
- onResponse(`\x1bP1+r544e=${hexValue}\x1b\\`)
1825
- } else {
1826
- // Unknown capability
1827
- onResponse("\x1bP0+r\x1b\\")
1828
- }
1829
- return
1830
- }
1831
-
1832
- // Other DCS sequences are consumed and ignored
1833
- }
1834
-
1835
- // ── APC handler ──
1836
-
1837
- function handleAPC(data: string): void {
1838
- if (!data.startsWith("G")) return
1839
-
1840
- hasKittyGraphics = true
1841
-
1842
- // Parse key=value pairs
1843
- const semicolonIdx = data.indexOf(";")
1844
- const kvPart = semicolonIdx >= 0 ? data.substring(1, semicolonIdx) : data.substring(1)
1845
-
1846
- const params: Record<string, string> = {}
1847
- for (const pair of kvPart.split(",")) {
1848
- const eqIdx = pair.indexOf("=")
1849
- if (eqIdx >= 0) {
1850
- params[pair.substring(0, eqIdx)] = pair.substring(eqIdx + 1)
1851
- }
1852
- }
1853
-
1854
- // Handle query action
1855
- if (params.a === "q" && onResponse) {
1856
- // Respond: OK
1857
- onResponse(`\x1b_Gi=${params.i ?? "0"};OK\x1b\\`)
1858
- }
1859
- }
1860
-
1861
- // ── Soft reset (DECSTR) ──
1862
-
1863
- function softReset(): void {
1864
- // Reset modes to defaults
1865
- insertMode = false
1866
- originMode = false
1867
- autoWrap = true
1868
- curVisible = true
1869
- kittyKeyboardFlags = 0
1870
- kittyKeyboardStack = []
1871
- cursorShape = "block"
1872
- cursorBlinking = true
1873
- applicationCursor = false
1874
- applicationKeypad = false
1875
- reverseVideo = false
1876
-
1877
- // Reset scroll region
1878
- scrollTop = 0
1879
- scrollBottom = rows - 1
1880
-
1881
- // Reset left/right margins
1882
- leftRightMarginMode = false
1883
- leftMargin = 0
1884
- rightMargin = cols - 1
1885
-
1886
- // Reset attributes
1887
- attrs = resetAttrs()
1888
-
1889
- // Reset character set
1890
- charsetG0 = false
1891
-
1892
- // Reset cursor to home
1893
- curX = 0
1894
- curY = 0
1895
- }
1896
-
1897
- // ── Full reset ──
1898
-
1899
- function fullReset(): void {
1900
- mainGrid = makeGrid(cols, rows)
1901
- altGrid = makeGrid(cols, rows)
1902
- grid = mainGrid
1903
- scrollback = []
1904
- curX = 0
1905
- curY = 0
1906
- curVisible = true
1907
- cursorShape = "block"
1908
- cursorBlinking = true
1909
- savedCurX = 0
1910
- savedCurY = 0
1911
- savedState = { curX: 0, curY: 0, attrs: resetAttrs(), originMode: false, autoWrap: true, charsetG0: false }
1912
- attrs = resetAttrs()
1913
- title = ""
1914
- useAltScreen = false
1915
- bracketedPaste = false
1916
- applicationCursor = false
1917
- applicationKeypad = false
1918
- autoWrap = true
1919
- mouseTracking = false
1920
- mouseTrackingMode = 0
1921
- sgrMouse = false
1922
- focusTracking = false
1923
- originMode = false
1924
- insertMode = false
1925
- reverseVideo = false
1926
- syncOutput = false
1927
- kittyKeyboardFlags = 0
1928
- kittyKeyboardStack = []
1929
- hasKittyGraphics = false
1930
- hasSixel = false
1931
- sixelImages = []
1932
- scrollTop = 0
1933
- scrollBottom = rows - 1
1934
- leftRightMarginMode = false
1935
- leftMargin = 0
1936
- rightMargin = cols - 1
1937
- colorSchemeReporting = false
1938
- textScale = 1
1939
- advancedClipboard = ""
1940
- viewportOffset = 0
1941
- charsetG0 = false
1942
- clipboard = ""
1943
- cwd = ""
1944
- notifications = []
1945
- lastChar = ""
1946
- pendingRegionalIndicator = null
1947
- afterZWJ = false
1948
- parserState = "ground"
1949
- escBuf = ""
1950
- oscBuf = ""
1951
- apcBuf = ""
1952
- semanticZones = []
1953
- mainSoftWrapped = new Array(rows).fill(false)
1954
- altSoftWrapped = new Array(rows).fill(false)
1955
- softWrapped = mainSoftWrapped
1956
- }
1957
-
1958
- // ── Main parser ──
1959
-
1960
- function process(data: Uint8Array): void {
1961
- const text = decoder.decode(data, { stream: true })
1962
-
1963
- for (let i = 0; i < text.length; i++) {
1964
- const ch = text[i]!
1965
- const code = text.charCodeAt(i)
1966
-
1967
- switch (parserState) {
1968
- case "ground":
1969
- if (code === 0x1b) {
1970
- parserState = "escape"
1971
- escBuf = ""
1972
- } else if (code === 0x07) {
1973
- // BEL — ignore
1974
- } else if (code === 0x08) {
1975
- // BS - Backspace
1976
- if (curX > 0) curX--
1977
- } else if (code === 0x09) {
1978
- // TAB
1979
- curX = Math.min((Math.floor(curX / 8) + 1) * 8, cols - 1)
1980
- } else if (code === 0x0a || code === 0x0b || code === 0x0c) {
1981
- // LF, VT, FF — linefeed (hard break — clear any soft-wrap flag)
1982
- softWrapped[curY] = false
1983
- curY++
1984
- if (curY > scrollBottom) {
1985
- curY = scrollBottom
1986
- scrollUp(scrollTop, scrollBottom)
1987
- }
1988
- } else if (code === 0x0d) {
1989
- // CR - Carriage Return
1990
- curX = 0
1991
- } else if (code >= 0x20) {
1992
- // Handle surrogate pairs for characters > U+FFFF
1993
- let char = ch
1994
- if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
1995
- const nextCode = text.charCodeAt(i + 1)
1996
- if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
1997
- char = ch + text[i + 1]!
1998
- i++
1999
- }
2000
- }
2001
- writeChar(char)
2002
- }
2003
- break
2004
-
2005
- case "escape":
2006
- if (ch === "[") {
2007
- parserState = "csi"
2008
- escBuf = ""
2009
- } else if (ch === "]") {
2010
- parserState = "osc"
2011
- oscBuf = ""
2012
- } else if (ch === "P") {
2013
- parserState = "dcs"
2014
- escBuf = ""
2015
- dcsBuf = ""
2016
- dcsStartRow = curY
2017
- dcsStartCol = curX
2018
- } else if (ch === "c") {
2019
- // RIS - Reset to Initial State
2020
- fullReset()
2021
- parserState = "ground"
2022
- } else if (ch === "D") {
2023
- // IND - Index (move cursor down, scroll if needed)
2024
- curY++
2025
- if (curY > scrollBottom) {
2026
- curY = scrollBottom
2027
- scrollUp(scrollTop, scrollBottom)
2028
- }
2029
- parserState = "ground"
2030
- } else if (ch === "M") {
2031
- // RI - Reverse Index (move cursor up, scroll if needed)
2032
- curY--
2033
- if (curY < scrollTop) {
2034
- curY = scrollTop
2035
- scrollDown(scrollTop, scrollBottom)
2036
- }
2037
- parserState = "ground"
2038
- } else if (ch === "7") {
2039
- // DECSC - Save Cursor + attributes + modes
2040
- savedState = {
2041
- curX,
2042
- curY,
2043
- attrs: {
2044
- ...attrs,
2045
- fg: attrs.fg ? { ...attrs.fg } : null,
2046
- bg: attrs.bg ? { ...attrs.bg } : null,
2047
- underlineColor: attrs.underlineColor ? { ...attrs.underlineColor } : null,
2048
- },
2049
- originMode,
2050
- autoWrap,
2051
- charsetG0,
2052
- }
2053
- parserState = "ground"
2054
- } else if (ch === "8") {
2055
- // DECRC - Restore Cursor + attributes + modes
2056
- curX = savedState.curX
2057
- curY = savedState.curY
2058
- attrs = {
2059
- ...savedState.attrs,
2060
- fg: savedState.attrs.fg ? { ...savedState.attrs.fg } : null,
2061
- bg: savedState.attrs.bg ? { ...savedState.attrs.bg } : null,
2062
- underlineColor: savedState.attrs.underlineColor ? { ...savedState.attrs.underlineColor } : null,
2063
- }
2064
- originMode = savedState.originMode
2065
- autoWrap = savedState.autoWrap
2066
- charsetG0 = savedState.charsetG0
2067
- clampCursor()
2068
- parserState = "ground"
2069
- } else if (ch === "E") {
2070
- // NEL - Next Line
2071
- curX = 0
2072
- curY++
2073
- if (curY > scrollBottom) {
2074
- curY = scrollBottom
2075
- scrollUp(scrollTop, scrollBottom)
2076
- }
2077
- parserState = "ground"
2078
- } else if (ch === "(") {
2079
- // Designate G0 character set
2080
- parserState = "escape_charset"
2081
- } else if (ch === ")") {
2082
- // Designate G1 character set (ignored, just consume next byte)
2083
- parserState = "escape_charset"
2084
- } else if (ch === "=") {
2085
- // DECKPAM - Application Keypad Mode
2086
- applicationKeypad = true
2087
- parserState = "ground"
2088
- } else if (ch === ">") {
2089
- // DECKPNM - Normal Keypad Mode
2090
- applicationKeypad = false
2091
- parserState = "ground"
2092
- } else if (ch === "_") {
2093
- // APC - Application Program Command
2094
- parserState = "apc"
2095
- apcBuf = ""
2096
- } else {
2097
- // Unknown escape — return to ground
2098
- parserState = "ground"
2099
- }
2100
- break
2101
-
2102
- case "escape_charset":
2103
- // Character set designation: ESC ( 0 = DEC Special Graphics, ESC ( B = ASCII
2104
- if (ch === "0") {
2105
- charsetG0 = true
2106
- } else {
2107
- charsetG0 = false // B = ASCII, or any other
2108
- }
2109
- parserState = "ground"
2110
- break
2111
-
2112
- case "csi": {
2113
- if (code >= 0x40 && code <= 0x7e) {
2114
- // Final byte — dispatch CSI
2115
- // Extract intermediates (characters 0x20-0x2F between params and final)
2116
- let paramPart = escBuf
2117
- let intermediatePart = ""
2118
-
2119
- // Check for intermediates at the end of escBuf
2120
- // Intermediates are 0x20-0x2F: space ! " # $ % & ' ( ) * + , - . /
2121
- let j = paramPart.length - 1
2122
- while (j >= 0) {
2123
- const c = paramPart.charCodeAt(j)
2124
- if (c >= 0x20 && c <= 0x2f) {
2125
- j--
2126
- } else {
2127
- break
2128
- }
2129
- }
2130
- if (j < paramPart.length - 1) {
2131
- intermediatePart = paramPart.substring(j + 1)
2132
- paramPart = paramPart.substring(0, j + 1)
2133
- }
2134
-
2135
- if (paramPart.startsWith("?")) {
2136
- handleCSIPrivate(paramPart.substring(1), intermediatePart, ch)
2137
- } else if (paramPart.startsWith(">")) {
2138
- handleCSIGt(paramPart.substring(1), intermediatePart, ch)
2139
- } else if (paramPart.startsWith("<")) {
2140
- handleCSILt(paramPart.substring(1), intermediatePart, ch)
2141
- } else if (paramPart.startsWith("=")) {
2142
- handleCSIEq(paramPart.substring(1), intermediatePart, ch)
2143
- } else {
2144
- handleCSI(paramPart, intermediatePart, ch)
2145
- }
2146
- parserState = "ground"
2147
- } else if (escBuf.length >= 256) {
2148
- parserState = "ground"
2149
- } else {
2150
- escBuf += ch
2151
- }
2152
- break
2153
- }
2154
-
2155
- case "osc":
2156
- if (code === 0x07) {
2157
- // BEL terminates OSC
2158
- handleOSC(oscBuf)
2159
- parserState = "ground"
2160
- } else if (code === 0x1b) {
2161
- // ESC might be start of ST (\x1b\\)
2162
- parserState = "osc_st"
2163
- } else if (oscBuf.length >= 4096) {
2164
- parserState = "ground"
2165
- } else {
2166
- oscBuf += ch
2167
- }
2168
- break
2169
-
2170
- case "osc_st":
2171
- if (ch === "\\") {
2172
- // ST (String Terminator) — end of OSC
2173
- handleOSC(oscBuf)
2174
- }
2175
- parserState = "ground"
2176
- break
2177
-
2178
- case "dcs":
2179
- // Accumulate DCS data until ST (ESC \) or BEL
2180
- if (code === 0x1b) {
2181
- parserState = "dcs_st"
2182
- } else if (code === 0x07) {
2183
- // BEL terminates DCS
2184
- handleDCS(dcsBuf)
2185
- parserState = "ground"
2186
- } else {
2187
- dcsBuf += ch
2188
- }
2189
- break
2190
-
2191
- case "dcs_st":
2192
- // Expecting backslash to complete ST
2193
- if (ch === "\\") {
2194
- // ST (String Terminator) — end of DCS
2195
- handleDCS(dcsBuf)
2196
- }
2197
- parserState = "ground"
2198
- break
2199
-
2200
- case "dcs_passthrough":
2201
- // Consume until ST
2202
- if (code === 0x1b) {
2203
- parserState = "dcs_st"
2204
- }
2205
- break
2206
-
2207
- case "apc":
2208
- if (code === 0x1b) {
2209
- parserState = "apc_st"
2210
- } else if (code === 0x07) {
2211
- // BEL terminates APC
2212
- handleAPC(apcBuf)
2213
- parserState = "ground"
2214
- } else {
2215
- apcBuf += ch
2216
- }
2217
- break
2218
-
2219
- case "apc_st":
2220
- if (ch === "\\") {
2221
- // ST (String Terminator) — end of APC
2222
- handleAPC(apcBuf)
2223
- }
2224
- parserState = "ground"
2225
- break
2226
- }
2227
- }
2228
- }
2229
-
2230
- // ── Resize ──
2231
-
2232
- /**
2233
- * Reconstruct logical lines from a grid, joining rows that were soft-wrapped.
2234
- * Returns an array of logical lines, each being an array of ScreenCells (may be longer than cols).
2235
- */
2236
- function getLogicalLines(srcGrid: ScreenCell[][], srcSoftWrapped: boolean[], srcRows: number): ScreenCell[][] {
2237
- const logical: ScreenCell[][] = []
2238
- let currentLine: ScreenCell[] = []
2239
-
2240
- for (let r = 0; r < srcRows; r++) {
2241
- const row = srcGrid[r]
2242
- if (!row) continue
2243
- // Append this row's cells to the current logical line
2244
- for (let c = 0; c < row.length; c++) {
2245
- currentLine.push(row[c]!)
2246
- }
2247
- if (srcSoftWrapped[r]) {
2248
- // This row was soft-wrapped — continue accumulating into same logical line
2249
- continue
2250
- }
2251
- // Hard break (or last row): finalize this logical line
2252
- logical.push(currentLine)
2253
- currentLine = []
2254
- }
2255
- // If there's a dangling line (shouldn't happen, but be safe)
2256
- if (currentLine.length > 0) {
2257
- logical.push(currentLine)
2258
- }
2259
- return logical
2260
- }
2261
-
2262
- /**
2263
- * Re-wrap logical lines to a new column width, producing grid rows and soft-wrap flags.
2264
- */
2265
- function rewrapLines(logicalLines: ScreenCell[][], newCols: number): { rows: ScreenCell[][]; wrapped: boolean[] } {
2266
- const outRows: ScreenCell[][] = []
2267
- const outWrapped: boolean[] = []
2268
-
2269
- for (const line of logicalLines) {
2270
- // Trim trailing empty cells from logical line
2271
- let lineLen = line.length
2272
- while (lineLen > 0) {
2273
- const cell = line[lineLen - 1]!
2274
- if (cell === EMPTY_CELL || (cell.char === "" && !cell.wide)) {
2275
- lineLen--
2276
- } else {
2277
- break
2278
- }
2279
- }
2280
-
2281
- if (lineLen === 0) {
2282
- // Empty logical line — produce one empty row
2283
- outRows.push(makeRow(newCols))
2284
- outWrapped.push(false)
2285
- continue
2286
- }
2287
-
2288
- // Wrap the logical line content into rows of newCols width
2289
- let pos = 0
2290
- while (pos < lineLen) {
2291
- const row = makeRow(newCols)
2292
- let col = 0
2293
- while (col < newCols && pos < lineLen) {
2294
- const cell = line[pos]!
2295
- if (cell.wide && col + 2 > newCols) {
2296
- // Wide char doesn't fit — leave rest of row empty, wrap to next
2297
- break
2298
- }
2299
- row[col] = cell === EMPTY_CELL ? EMPTY_CELL : { ...cell }
2300
- col++
2301
- pos++
2302
- // If cell was wide, the next cell in the logical line is the spacer
2303
- // which we already advanced past via pos++
2304
- }
2305
- const moreContent = pos < lineLen
2306
- outRows.push(row)
2307
- outWrapped.push(moreContent) // soft-wrapped if there's more content to come
2308
- }
2309
- }
2310
-
2311
- return { rows: outRows, wrapped: outWrapped }
2312
- }
2313
-
2314
- /**
2315
- * Trim trailing empty rows from reflowed result, so they don't push content off the top
2316
- * when we take the last newRows rows.
2317
- */
2318
- function trimTrailingEmptyRows(result: { rows: ScreenCell[][]; wrapped: boolean[] }): void {
2319
- while (result.rows.length > 1) {
2320
- const lastRow = result.rows[result.rows.length - 1]!
2321
- const isEmpty = lastRow.every((cell) => cell === EMPTY_CELL || (cell.char === "" && !cell.wide))
2322
- if (isEmpty && !result.wrapped[result.rows.length - 2]) {
2323
- // The row before wasn't soft-wrapped and this row is empty — trim it
2324
- result.rows.pop()
2325
- result.wrapped.pop()
2326
- } else {
2327
- break
2328
- }
2329
- }
2330
- }
2331
-
2332
- function resize(newCols: number, newRows: number): void {
2333
- // Reflow main grid
2334
- const mainLogical = getLogicalLines(mainGrid, mainSoftWrapped, rows)
2335
- const mainResult = rewrapLines(mainLogical, newCols)
2336
- trimTrailingEmptyRows(mainResult)
2337
-
2338
- // Reflow alt grid (usually not reflowed, but do it for consistency)
2339
- const altLogical = getLogicalLines(altGrid, altSoftWrapped, rows)
2340
- const altResult = rewrapLines(altLogical, newCols)
2341
- trimTrailingEmptyRows(altResult)
2342
-
2343
- // Build new grids: if reflowed content fits, place at top; if it overflows, take the last newRows
2344
- const newMain = makeGrid(newCols, newRows)
2345
- const newMainWrapped: boolean[] = new Array(newRows).fill(false)
2346
- const mainStartRow = Math.max(0, mainResult.rows.length - newRows)
2347
- for (let r = 0; r < newRows && mainStartRow + r < mainResult.rows.length; r++) {
2348
- newMain[r] = mainResult.rows[mainStartRow + r]!
2349
- newMainWrapped[r] = mainResult.wrapped[mainStartRow + r]!
2350
- }
2351
-
2352
- // Build new alt grid
2353
- const newAlt = makeGrid(newCols, newRows)
2354
- const newAltWrapped: boolean[] = new Array(newRows).fill(false)
2355
- const altStartRow = Math.max(0, altResult.rows.length - newRows)
2356
- for (let r = 0; r < newRows && altStartRow + r < altResult.rows.length; r++) {
2357
- newAlt[r] = altResult.rows[altStartRow + r]!
2358
- newAltWrapped[r] = altResult.wrapped[altStartRow + r]!
2359
- }
2360
-
2361
- mainGrid = newMain
2362
- altGrid = newAlt
2363
- mainSoftWrapped = newMainWrapped
2364
- altSoftWrapped = newAltWrapped
2365
- grid = useAltScreen ? altGrid : mainGrid
2366
- softWrapped = useAltScreen ? altSoftWrapped : mainSoftWrapped
2367
- cols = newCols
2368
- rows = newRows
2369
- scrollTop = 0
2370
- scrollBottom = rows - 1
2371
- leftMargin = 0
2372
- rightMargin = cols - 1
2373
- clampCursor()
2374
- }
2375
-
2376
- // ── Accessors ──
2377
-
2378
- function getCell(row: number, col: number): ScreenCell {
2379
- const r = grid[row]
2380
- if (!r || col >= cols) return emptyCell()
2381
- return { ...r[col]! }
2382
- }
2383
-
2384
- function getLine(row: number): ScreenCell[] {
2385
- const r = grid[row]
2386
- if (!r) return makeRow(cols)
2387
- return r.map((cell) => ({ ...cell }))
2388
- }
2389
-
2390
- function getText(): string {
2391
- const lines: string[] = []
2392
- for (let r = 0; r < rows; r++) {
2393
- lines.push(rowToString(grid[r]!))
2394
- }
2395
- return lines.join("\n")
2396
- }
2397
-
2398
- function rowToString(row: ScreenCell[]): string {
2399
- let line = ""
2400
- for (let i = 0; i < row.length; i++) {
2401
- const cell = row[i]!
2402
- if (cell.wide) {
2403
- line += cell.char
2404
- } else if (cell.char === "") {
2405
- if (i > 0 && row[i - 1]?.wide) {
2406
- continue
2407
- }
2408
- line += " "
2409
- } else {
2410
- line += cell.char
2411
- }
2412
- }
2413
- return line.replace(/\s+$/, "")
2414
- }
2415
-
2416
- function getTextRange(startRow: number, startCol: number, endRow: number, endCol: number): string {
2417
- const parts: string[] = []
2418
-
2419
- for (let row = startRow; row <= endRow; row++) {
2420
- const r = grid[row]
2421
- if (!r) continue
2422
-
2423
- const colStart = row === startRow ? startCol : 0
2424
- const colEnd = row === endRow ? endCol : cols
2425
-
2426
- let line = ""
2427
- for (let col = colStart; col < colEnd; col++) {
2428
- const cell = r[col]
2429
- if (!cell) continue
2430
- if (cell.char === "" && col > 0 && r[col - 1]?.wide) continue
2431
- line += cell.char || " "
2432
- }
2433
- parts.push(line.replace(/\s+$/, ""))
2434
- }
2435
-
2436
- return parts.join("\n")
2437
- }
2438
-
2439
- function getMode(mode: string): boolean {
2440
- switch (mode) {
2441
- case "altScreen":
2442
- return useAltScreen
2443
- case "cursorVisible":
2444
- return curVisible
2445
- case "bracketedPaste":
2446
- return bracketedPaste
2447
- case "applicationCursor":
2448
- return applicationCursor
2449
- case "applicationKeypad":
2450
- return applicationKeypad
2451
- case "autoWrap":
2452
- return autoWrap
2453
- case "mouseTracking":
2454
- return mouseTracking
2455
- case "focusTracking":
2456
- return focusTracking
2457
- case "originMode":
2458
- return originMode
2459
- case "insertMode":
2460
- return insertMode
2461
- case "reverseVideo":
2462
- return reverseVideo
2463
- case "syncOutput":
2464
- return syncOutput
2465
- case "sgrMouse":
2466
- return sgrMouse
2467
- case "pixelMouse":
2468
- return mouseTrackingMode === 1016
2469
- case "leftRightMargin":
2470
- return leftRightMarginMode
2471
- case "colorSchemeReporting":
2472
- return colorSchemeReporting
2473
- case "kittyKeyboard":
2474
- return kittyKeyboardFlags > 0
2475
- case "kittyGraphics":
2476
- return hasKittyGraphics
2477
- case "sixel":
2478
- return hasSixel
2479
- default:
2480
- return false
2481
- }
2482
- }
2483
-
2484
- // Suppress unused variable warnings
2485
- void [mouseTrackingMode, textScale, advancedClipboard]
2486
-
2487
- return {
2488
- get cols() {
2489
- return cols
2490
- },
2491
- get rows() {
2492
- return rows
2493
- },
2494
- process,
2495
- resize,
2496
- reset: fullReset,
2497
- getCell,
2498
- getLine,
2499
- getText,
2500
- getTextRange,
2501
- getCursorPosition: () => ({ x: curX, y: curY }),
2502
- getCursorVisible: () => curVisible,
2503
- getCursorShape: () => cursorShape,
2504
- getCursorBlinking: () => cursorBlinking,
2505
- getTitle: () => title,
2506
- getMode,
2507
- getClipboard: () => clipboard,
2508
- getCwd: () => cwd,
2509
- getNotifications: () => [...notifications],
2510
- getScrollbackLength: () => scrollback.length,
2511
- getViewportOffset: () => viewportOffset,
2512
- scrollViewport: (delta: number) => {
2513
- viewportOffset = Math.max(0, Math.min(scrollback.length, viewportOffset + delta))
2514
- },
2515
- getSemanticZones: () => semanticZones.map((z) => ({ ...z })),
2516
- getSixelImages: () => sixelImages.map((img) => ({ ...img })),
2517
- }
2518
- }