vterm.js 0.1.2 → 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/README.md CHANGED
@@ -100,40 +100,40 @@ console.log(cell.underlineColor) // { r: 0, g: 150, b: 255 }
100
100
 
101
101
  ### `createVtermScreen(options)`
102
102
 
103
- | Option | Type | Default | Description |
104
- |--------|------|---------|-------------|
105
- | `cols` | `number` | `80` | Terminal width |
106
- | `rows` | `number` | `24` | Terminal height |
107
- | `scrollbackLimit` | `number` | `1000` | Max scrollback lines |
108
- | `onResponse` | `(data: string) => void` | — | Callback for DA1/DA2/DSR responses |
103
+ | Option | Type | Default | Description |
104
+ | ----------------- | ------------------------ | ------- | ---------------------------------- |
105
+ | `cols` | `number` | `80` | Terminal width |
106
+ | `rows` | `number` | `24` | Terminal height |
107
+ | `scrollbackLimit` | `number` | `1000` | Max scrollback lines |
108
+ | `onResponse` | `(data: string) => void` | — | Callback for DA1/DA2/DSR responses |
109
109
 
110
110
  ### Screen methods
111
111
 
112
- | Method | Description |
113
- |--------|-------------|
114
- | `process(data: Uint8Array)` | Feed raw terminal data |
115
- | `getText()` | Get all text (scrollback + screen) |
116
- | `getTextRange(sr, sc, er, ec)` | Get text in a range |
117
- | `getLine(row)` | Get cells for a row |
118
- | `getCell(row, col)` | Get a single cell |
119
- | `getCursorPosition()` | Get cursor `{ x, y }` |
120
- | `getCursorVisible()` | Check cursor visibility |
121
- | `getCursorShape()` | Get cursor shape: `"block"`, `"underline"`, or `"bar"` |
122
- | `getCursorBlinking()` | Check if cursor is blinking |
123
- | `getMode(mode)` | Check terminal mode |
124
- | `getTitle()` | Get window title |
125
- | `getScrollbackLength()` | Number of scrollback lines |
126
- | `getViewportOffset()` | Current viewport scroll offset |
127
- | `scrollViewport(delta)` | Scroll viewport |
128
- | `resize(cols, rows)` | Resize terminal |
129
- | `reset()` | Reset to initial state |
112
+ | Method | Description |
113
+ | ------------------------------ | ------------------------------------------------------ |
114
+ | `process(data: Uint8Array)` | Feed raw terminal data |
115
+ | `getText()` | Get all text (scrollback + screen) |
116
+ | `getTextRange(sr, sc, er, ec)` | Get text in a range |
117
+ | `getLine(row)` | Get cells for a row |
118
+ | `getCell(row, col)` | Get a single cell |
119
+ | `getCursorPosition()` | Get cursor `{ x, y }` |
120
+ | `getCursorVisible()` | Check cursor visibility |
121
+ | `getCursorShape()` | Get cursor shape: `"block"`, `"underline"`, or `"bar"` |
122
+ | `getCursorBlinking()` | Check if cursor is blinking |
123
+ | `getMode(mode)` | Check terminal mode |
124
+ | `getTitle()` | Get window title |
125
+ | `getScrollbackLength()` | Number of scrollback lines |
126
+ | `getViewportOffset()` | Current viewport scroll offset |
127
+ | `scrollViewport(delta)` | Scroll viewport |
128
+ | `resize(cols, rows)` | Resize terminal |
129
+ | `reset()` | Reset to initial state |
130
130
 
131
131
  ### Cell properties
132
132
 
133
133
  ```typescript
134
134
  interface ScreenCell {
135
135
  char: string
136
- fg: CellColor | null // { r, g, b }
136
+ fg: CellColor | null // { r, g, b }
137
137
  bg: CellColor | null
138
138
  bold: boolean
139
139
  faint: boolean
@@ -146,30 +146,30 @@ interface ScreenCell {
146
146
  hidden: boolean
147
147
  blink: boolean
148
148
  wide: boolean
149
- url: string | null // OSC 8 hyperlink
149
+ url: string | null // OSC 8 hyperlink
150
150
  }
151
151
  ```
152
152
 
153
153
  ## vs vt100.js
154
154
 
155
- | Feature | vt100.js | vterm.js |
156
- |---------|----------|---------|
157
- | SGR basics (bold, italic, underline, colors) | Yes | Yes |
158
- | Underline styles (curly, dotted, dashed) | Yes | Yes |
159
- | Underline color | No | Yes |
160
- | Blink, overline | No | Yes |
161
- | Cursor shapes (DECSCUSR) | No | Yes |
162
- | OSC 8 hyperlinks | No | Yes |
163
- | DA1/DA2/DA3 responses | No | Yes |
164
- | DSR/DECRPM responses | No | Yes |
165
- | Mouse tracking | No | Yes |
166
- | Focus tracking | No | Yes |
167
- | Synchronized output | No | Yes |
168
- | DEC Special Graphics | No | Yes |
169
- | REP (repeat character) | No | Yes |
170
- | DECSTR (soft reset) | No | Yes |
171
- | DCS sequences | No | Yes |
172
- | Package size | Smaller | Larger |
155
+ | Feature | vt100.js | vterm.js |
156
+ | -------------------------------------------- | -------- | -------- |
157
+ | SGR basics (bold, italic, underline, colors) | Yes | Yes |
158
+ | Underline styles (curly, dotted, dashed) | Yes | Yes |
159
+ | Underline color | No | Yes |
160
+ | Blink, overline | No | Yes |
161
+ | Cursor shapes (DECSCUSR) | No | Yes |
162
+ | OSC 8 hyperlinks | No | Yes |
163
+ | DA1/DA2/DA3 responses | No | Yes |
164
+ | DSR/DECRPM responses | No | Yes |
165
+ | Mouse tracking | No | Yes |
166
+ | Focus tracking | No | Yes |
167
+ | Synchronized output | No | Yes |
168
+ | DEC Special Graphics | No | Yes |
169
+ | REP (repeat character) | No | Yes |
170
+ | DECSTR (soft reset) | No | Yes |
171
+ | DCS sequences | No | Yes |
172
+ | Package size | Smaller | Larger |
173
173
 
174
174
  **Use vt100.js** when you want fast and simple — it covers ~90% of real-world terminal usage.
175
175
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vterm.js",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Modern terminal emulator — full VT/ECMA-48/xterm standards coverage. Pure TypeScript, zero dependencies.",
5
5
  "keywords": [
6
6
  "ansi",
package/src/index.ts CHANGED
@@ -5,4 +5,6 @@ export {
5
5
  type ScreenCell,
6
6
  type CellColor,
7
7
  type UnderlineStyle,
8
+ type SemanticZone,
9
+ type SixelImage,
8
10
  } from "./screen.ts"
package/src/screen.ts CHANGED
@@ -16,10 +16,12 @@
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
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)
@@ -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,6 +105,9 @@ 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 ─────────────────────────────────────────────────────
@@ -183,11 +200,27 @@ const DEC_SPECIAL_GRAPHICS: Record<string, string> = {
183
200
  "}": "\u00a3", // £
184
201
  }
185
202
 
186
- // ── Unicode width (simplified — CJK detection) ─────────────────────────
203
+ // ── Unicode width & character classification ────────────────────────────
187
204
 
188
205
  function isWide(codePoint: number): boolean {
189
206
  return (
190
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
191
224
  (codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK Radicals
192
225
  (codePoint >= 0x3041 && codePoint <= 0x33bf) || // Hiragana, Katakana, Bopomofo, etc.
193
226
  (codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK Unified Extension A
@@ -199,6 +232,14 @@ function isWide(codePoint: number): boolean {
199
232
  (codePoint >= 0xfe30 && codePoint <= 0xfe6b) || // CJK Compatibility Forms
200
233
  (codePoint >= 0xff01 && codePoint <= 0xff60) || // Fullwidth Forms
201
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
202
243
  (codePoint >= 0x1f300 && codePoint <= 0x1f9ff) || // Misc Symbols/Emoticons
203
244
  (codePoint >= 0x1fa00 && codePoint <= 0x1faff) || // Extended Symbols & Pictographs
204
245
  (codePoint >= 0x20000 && codePoint <= 0x2fffd) || // CJK Extension B-F
@@ -206,6 +247,54 @@ function isWide(codePoint: number): boolean {
206
247
  )
207
248
  }
208
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
+
209
298
  // ── Internal attrs interface ───────────────────────────────────────────
210
299
 
211
300
  interface Attrs {
@@ -286,6 +375,13 @@ export function createScreen(options: ScreenOptions = {}): Screen {
286
375
  let reverseVideo = false
287
376
  let syncOutput = false
288
377
 
378
+ // Kitty keyboard protocol
379
+ let kittyKeyboardFlags = 0
380
+ let kittyKeyboardStack: number[] = []
381
+
382
+ // Kitty graphics protocol
383
+ let hasKittyGraphics = false
384
+
289
385
  // Scroll region (inclusive, 0-based)
290
386
  let scrollTop = 0
291
387
  let scrollBottom = rows - 1
@@ -299,9 +395,25 @@ export function createScreen(options: ScreenOptions = {}): Screen {
299
395
  // Clipboard (OSC 52)
300
396
  let clipboard = ""
301
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
+
302
410
  // Last printed character for REP
303
411
  let lastChar = ""
304
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
+
305
417
  // Parser state
306
418
  let parserState:
307
419
  | "ground"
@@ -312,9 +424,15 @@ export function createScreen(options: ScreenOptions = {}): Screen {
312
424
  | "dcs"
313
425
  | "dcs_passthrough"
314
426
  | "osc_st"
315
- | "dcs_st" = "ground"
427
+ | "dcs_st"
428
+ | "apc"
429
+ | "apc_st" = "ground"
316
430
  let escBuf = ""
317
431
  let oscBuf = ""
432
+ let dcsBuf = ""
433
+ let dcsStartRow = 0
434
+ let dcsStartCol = 0
435
+ let apcBuf = ""
318
436
 
319
437
  // Decoder for incoming bytes
320
438
  const decoder = new TextDecoder()
@@ -374,19 +492,71 @@ export function createScreen(options: ScreenOptions = {}): Screen {
374
492
  }
375
493
  for (let i = top; i < bottom; i++) {
376
494
  grid[i] = grid[i + 1]!
495
+ softWrapped[i] = softWrapped[i + 1]!
377
496
  }
378
497
  grid[bottom] = makeRow(cols)
498
+ softWrapped[bottom] = false
379
499
  }
380
500
 
381
501
  function scrollDown(top: number, bottom: number): void {
382
502
  for (let i = bottom; i > top; i--) {
383
503
  grid[i] = grid[i - 1]!
504
+ softWrapped[i] = softWrapped[i - 1]!
384
505
  }
385
506
  grid[top] = makeRow(cols)
507
+ softWrapped[top] = false
386
508
  }
387
509
 
388
510
  // ── Character writing ──
389
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
+
390
560
  function writeChar(ch: string): void {
391
561
  // Apply DEC Special Graphics character mapping
392
562
  if (charsetG0 && ch.length === 1) {
@@ -395,12 +565,95 @@ export function createScreen(options: ScreenOptions = {}): Screen {
395
565
  }
396
566
 
397
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
+
398
645
  const wide = isWide(codePoint)
646
+ writeCharCore(ch, wide)
647
+ }
648
+
649
+ function writeCharCore(ch: string, wide: boolean): void {
399
650
  const charWidth = wide ? 2 : 1
400
651
 
401
652
  // Handle autowrap at end of line
402
653
  if (curX + charWidth > cols) {
403
654
  if (autoWrap) {
655
+ // Mark this row as soft-wrapped (auto-wrap caused the line break)
656
+ softWrapped[curY] = true
404
657
  curX = 0
405
658
  curY++
406
659
  if (curY > scrollBottom) {
@@ -444,27 +697,8 @@ export function createScreen(options: ScreenOptions = {}): Screen {
444
697
  cell.wide = wide
445
698
  cell.url = attrs.url
446
699
 
447
- if (wide && curX + 1 < cols) {
448
- let spacer = row[curX + 1]!
449
- if (spacer === EMPTY_CELL) {
450
- spacer = { ...EMPTY_CELL }
451
- row[curX + 1] = spacer
452
- }
453
- spacer.char = ""
454
- spacer.fg = null
455
- spacer.bg = null
456
- spacer.bold = false
457
- spacer.faint = false
458
- spacer.italic = false
459
- spacer.underline = "none"
460
- spacer.underlineColor = null
461
- spacer.overline = false
462
- spacer.strikethrough = false
463
- spacer.inverse = false
464
- spacer.hidden = false
465
- spacer.blink = false
466
- spacer.wide = false
467
- spacer.url = null
700
+ if (wide) {
701
+ widenCell(row, curX, cell)
468
702
  }
469
703
 
470
704
  curX += charWidth
@@ -638,6 +872,16 @@ export function createScreen(options: ScreenOptions = {}): Screen {
638
872
  }
639
873
  }
640
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
641
885
  case "s": // SCP - Save Cursor Position
642
886
  savedCurX = curX
643
887
  savedCurY = curY
@@ -653,6 +897,8 @@ export function createScreen(options: ScreenOptions = {}): Screen {
653
897
  }
654
898
 
655
899
  function handleCSIGt(params: string, _intermediates: string, finalByte: string): void {
900
+ const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
901
+
656
902
  // CSI > sequences
657
903
  if (finalByte === "c") {
658
904
  // DA2 - Secondary Device Attributes
@@ -668,12 +914,32 @@ export function createScreen(options: ScreenOptions = {}): Screen {
668
914
  onResponse("\x1bP>|vterm.js 0.1.0\x1b\\")
669
915
  }
670
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
671
929
  }
672
930
  }
673
931
 
674
932
  function handleCSIPrivate(params: string, intermediates: string, finalByte: string): void {
675
933
  const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
676
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
+
677
943
  // DECRPM - Mode reporting: CSI ? Pd $ p
678
944
  if (intermediates === "$" && finalByte === "p") {
679
945
  if (onResponse) {
@@ -756,9 +1022,11 @@ export function createScreen(options: ScreenOptions = {}): Screen {
756
1022
  if (set && !useAltScreen) {
757
1023
  useAltScreen = true
758
1024
  grid = altGrid
1025
+ softWrapped = altSoftWrapped
759
1026
  } else if (!set && useAltScreen) {
760
1027
  useAltScreen = false
761
1028
  grid = mainGrid
1029
+ softWrapped = mainSoftWrapped
762
1030
  }
763
1031
  break
764
1032
  case 66: // DECNKM - Application Keypad
@@ -788,12 +1056,15 @@ export function createScreen(options: ScreenOptions = {}): Screen {
788
1056
  savedCurY = curY
789
1057
  useAltScreen = true
790
1058
  altGrid = makeGrid(cols, rows)
1059
+ altSoftWrapped = new Array(rows).fill(false)
791
1060
  grid = altGrid
1061
+ softWrapped = altSoftWrapped
792
1062
  curX = 0
793
1063
  curY = 0
794
1064
  } else if (!set && useAltScreen) {
795
1065
  useAltScreen = false
796
1066
  grid = mainGrid
1067
+ softWrapped = mainSoftWrapped
797
1068
  curX = savedCurX
798
1069
  curY = savedCurY
799
1070
  clampCursor()
@@ -1181,6 +1452,25 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1181
1452
  case 2: // Set window title
1182
1453
  title = value
1183
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
+ }
1184
1474
  case 1: // Set icon name (ignore)
1185
1475
  break
1186
1476
  case 8: {
@@ -1233,6 +1523,50 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1233
1523
  }
1234
1524
  }
1235
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
+
1236
1570
  // ── Soft reset (DECSTR) ──
1237
1571
 
1238
1572
  function softReset(): void {
@@ -1241,6 +1575,8 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1241
1575
  originMode = false
1242
1576
  autoWrap = true
1243
1577
  curVisible = true
1578
+ kittyKeyboardFlags = 0
1579
+ kittyKeyboardStack = []
1244
1580
  cursorShape = "block"
1245
1581
  cursorBlinking = true
1246
1582
  applicationCursor = false
@@ -1292,15 +1628,27 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1292
1628
  insertMode = false
1293
1629
  reverseVideo = false
1294
1630
  syncOutput = false
1631
+ kittyKeyboardFlags = 0
1632
+ kittyKeyboardStack = []
1633
+ hasKittyGraphics = false
1634
+ hasSixel = false
1635
+ sixelImages = []
1295
1636
  scrollTop = 0
1296
1637
  scrollBottom = rows - 1
1297
1638
  viewportOffset = 0
1298
1639
  charsetG0 = false
1299
1640
  clipboard = ""
1300
1641
  lastChar = ""
1642
+ pendingRegionalIndicator = null
1643
+ afterZWJ = false
1301
1644
  parserState = "ground"
1302
1645
  escBuf = ""
1303
1646
  oscBuf = ""
1647
+ apcBuf = ""
1648
+ semanticZones = []
1649
+ mainSoftWrapped = new Array(rows).fill(false)
1650
+ altSoftWrapped = new Array(rows).fill(false)
1651
+ softWrapped = mainSoftWrapped
1304
1652
  }
1305
1653
 
1306
1654
  // ── Main parser ──
@@ -1326,7 +1674,8 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1326
1674
  // TAB
1327
1675
  curX = Math.min((Math.floor(curX / 8) + 1) * 8, cols - 1)
1328
1676
  } else if (code === 0x0a || code === 0x0b || code === 0x0c) {
1329
- // LF, VT, FF — linefeed
1677
+ // LF, VT, FF — linefeed (hard break — clear any soft-wrap flag)
1678
+ softWrapped[curY] = false
1330
1679
  curY++
1331
1680
  if (curY > scrollBottom) {
1332
1681
  curY = scrollBottom
@@ -1359,6 +1708,9 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1359
1708
  } else if (ch === "P") {
1360
1709
  parserState = "dcs"
1361
1710
  escBuf = ""
1711
+ dcsBuf = ""
1712
+ dcsStartRow = curY
1713
+ dcsStartCol = curX
1362
1714
  } else if (ch === "c") {
1363
1715
  // RIS - Reset to Initial State
1364
1716
  fullReset()
@@ -1425,6 +1777,10 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1425
1777
  } else if (ch === ")") {
1426
1778
  // Designate G1 character set (ignored, just consume next byte)
1427
1779
  parserState = "escape_charset"
1780
+ } else if (ch === "_") {
1781
+ // APC - Application Program Command
1782
+ parserState = "apc"
1783
+ apcBuf = ""
1428
1784
  } else {
1429
1785
  // Unknown escape — return to ground
1430
1786
  parserState = "ground"
@@ -1468,6 +1824,8 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1468
1824
  handleCSIPrivate(paramPart.substring(1), intermediatePart, ch)
1469
1825
  } else if (paramPart.startsWith(">")) {
1470
1826
  handleCSIGt(paramPart.substring(1), intermediatePart, ch)
1827
+ } else if (paramPart.startsWith("<")) {
1828
+ handleCSILt(paramPart.substring(1), intermediatePart, ch)
1471
1829
  } else {
1472
1830
  handleCSI(paramPart, intermediatePart, ch)
1473
1831
  }
@@ -1504,19 +1862,23 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1504
1862
  break
1505
1863
 
1506
1864
  case "dcs":
1507
- // Consume until ST
1865
+ // Accumulate DCS data until ST (ESC \) or BEL
1508
1866
  if (code === 0x1b) {
1509
1867
  parserState = "dcs_st"
1510
1868
  } else if (code === 0x07) {
1511
- // Some implementations use BEL to terminate DCS
1869
+ // BEL terminates DCS
1870
+ handleDCS(dcsBuf)
1512
1871
  parserState = "ground"
1872
+ } else {
1873
+ dcsBuf += ch
1513
1874
  }
1514
1875
  break
1515
1876
 
1516
1877
  case "dcs_st":
1517
1878
  // Expecting backslash to complete ST
1518
1879
  if (ch === "\\") {
1519
- // DCS complete
1880
+ // ST (String Terminator) — end of DCS
1881
+ handleDCS(dcsBuf)
1520
1882
  }
1521
1883
  parserState = "ground"
1522
1884
  break
@@ -1527,22 +1889,167 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1527
1889
  parserState = "dcs_st"
1528
1890
  }
1529
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
1530
1912
  }
1531
1913
  }
1532
1914
  }
1533
1915
 
1534
1916
  // ── Resize ──
1535
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
+
1536
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
1537
2030
  const newMain = makeGrid(newCols, newRows)
1538
- const newAlt = 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
+ }
1539
2037
 
1540
- copyGrid(mainGrid, newMain, Math.min(cols, newCols), Math.min(rows, newRows))
1541
- copyGrid(altGrid, newAlt, Math.min(cols, newCols), Math.min(rows, newRows))
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
+ }
1542
2046
 
1543
2047
  mainGrid = newMain
1544
2048
  altGrid = newAlt
2049
+ mainSoftWrapped = newMainWrapped
2050
+ altSoftWrapped = newAltWrapped
1545
2051
  grid = useAltScreen ? altGrid : mainGrid
2052
+ softWrapped = useAltScreen ? altSoftWrapped : mainSoftWrapped
1546
2053
  cols = newCols
1547
2054
  rows = newRows
1548
2055
  scrollTop = 0
@@ -1550,17 +2057,6 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1550
2057
  clampCursor()
1551
2058
  }
1552
2059
 
1553
- function copyGrid(src: ScreenCell[][], dst: ScreenCell[][], copyCols: number, copyRows: number): void {
1554
- for (let row = 0; row < copyRows; row++) {
1555
- for (let col = 0; col < copyCols; col++) {
1556
- const srcCell = src[row]?.[col]
1557
- if (srcCell) {
1558
- dst[row]![col] = { ...srcCell }
1559
- }
1560
- }
1561
- }
1562
- }
1563
-
1564
2060
  // ── Accessors ──
1565
2061
 
1566
2062
  function getCell(row: number, col: number): ScreenCell {
@@ -1652,6 +2148,12 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1652
2148
  return syncOutput
1653
2149
  case "sgrMouse":
1654
2150
  return sgrMouse
2151
+ case "kittyKeyboard":
2152
+ return kittyKeyboardFlags > 0
2153
+ case "kittyGraphics":
2154
+ return hasKittyGraphics
2155
+ case "sixel":
2156
+ return hasSixel
1655
2157
  default:
1656
2158
  return false
1657
2159
  }
@@ -1685,5 +2187,7 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1685
2187
  scrollViewport: (delta: number) => {
1686
2188
  viewportOffset = Math.max(0, Math.min(scrollback.length, viewportOffset + delta))
1687
2189
  },
2190
+ getSemanticZones: () => semanticZones.map((z) => ({ ...z })),
2191
+ getSixelImages: () => sixelImages.map((img) => ({ ...img })),
1688
2192
  }
1689
2193
  }