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