vterm.js 0.1.2 → 0.3.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.3.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
- * - DCS sequences (consumed and ignored, XTVERSION response)
23
+ * - DCS sequences (XTVERSION, DECRQSS, XTGETTCAP, Sixel)
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
@@ -87,10 +101,16 @@ export interface Screen {
87
101
 
88
102
  getTitle(): string
89
103
  getMode(mode: string): boolean
104
+ getClipboard(): string
105
+ getCwd(): string
106
+ getNotifications(): string[]
90
107
 
91
108
  getScrollbackLength(): number
92
109
  getViewportOffset(): number
93
110
  scrollViewport(delta: number): void
111
+
112
+ getSemanticZones(): SemanticZone[]
113
+ getSixelImages(): SixelImage[]
94
114
  }
95
115
 
96
116
  // ── Implementation ─────────────────────────────────────────────────────
@@ -183,11 +203,27 @@ const DEC_SPECIAL_GRAPHICS: Record<string, string> = {
183
203
  "}": "\u00a3", // £
184
204
  }
185
205
 
186
- // ── Unicode width (simplified — CJK detection) ─────────────────────────
206
+ // ── Unicode width & character classification ────────────────────────────
187
207
 
188
208
  function isWide(codePoint: number): boolean {
189
209
  return (
190
210
  (codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
211
+ (codePoint >= 0x231a && codePoint <= 0x231b) || // Watch, Hourglass
212
+ (codePoint >= 0x2614 && codePoint <= 0x2615) || // Umbrella, Hot Beverage
213
+ (codePoint >= 0x2648 && codePoint <= 0x2653) || // Zodiac signs
214
+ codePoint === 0x267f || // Wheelchair
215
+ codePoint === 0x2693 || // Anchor
216
+ codePoint === 0x26a1 || // High Voltage
217
+ codePoint === 0x26ce || // Ophiuchus
218
+ codePoint === 0x26d4 || // No Entry
219
+ codePoint === 0x2705 || // Check Mark
220
+ codePoint === 0x2728 || // Sparkles
221
+ codePoint === 0x274c || // Cross Mark
222
+ codePoint === 0x274e || // Cross Mark variant
223
+ (codePoint >= 0x2753 && codePoint <= 0x2755) || // Question marks
224
+ (codePoint >= 0x2795 && codePoint <= 0x2797) || // Plus, Minus, Division
225
+ codePoint === 0x27b0 || // Curly Loop
226
+ codePoint === 0x27bf || // Double Curly Loop
191
227
  (codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK Radicals
192
228
  (codePoint >= 0x3041 && codePoint <= 0x33bf) || // Hiragana, Katakana, Bopomofo, etc.
193
229
  (codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK Unified Extension A
@@ -199,6 +235,14 @@ function isWide(codePoint: number): boolean {
199
235
  (codePoint >= 0xfe30 && codePoint <= 0xfe6b) || // CJK Compatibility Forms
200
236
  (codePoint >= 0xff01 && codePoint <= 0xff60) || // Fullwidth Forms
201
237
  (codePoint >= 0xffe0 && codePoint <= 0xffe6) || // Fullwidth Signs
238
+ codePoint === 0x1f004 || // Mahjong Tile
239
+ codePoint === 0x1f0cf || // Playing Card
240
+ (codePoint >= 0x1f170 && codePoint <= 0x1f171) || // A/B buttons
241
+ (codePoint >= 0x1f17e && codePoint <= 0x1f17f) || // O/P buttons
242
+ codePoint === 0x1f18e || // AB button
243
+ (codePoint >= 0x1f191 && codePoint <= 0x1f19a) || // Squared symbols
244
+ (codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff) || // Regional Indicators
245
+ (codePoint >= 0x1f200 && codePoint <= 0x1f202) || // Enclosed CJK
202
246
  (codePoint >= 0x1f300 && codePoint <= 0x1f9ff) || // Misc Symbols/Emoticons
203
247
  (codePoint >= 0x1fa00 && codePoint <= 0x1faff) || // Extended Symbols & Pictographs
204
248
  (codePoint >= 0x20000 && codePoint <= 0x2fffd) || // CJK Extension B-F
@@ -206,6 +250,54 @@ function isWide(codePoint: number): boolean {
206
250
  )
207
251
  }
208
252
 
253
+ function isCombining(cp: number): boolean {
254
+ return (
255
+ (cp >= 0x0300 && cp <= 0x036f) || // Combining Diacritical Marks
256
+ (cp >= 0x0483 && cp <= 0x0489) || // Cyrillic combining
257
+ (cp >= 0x0591 && cp <= 0x05bd) || // Hebrew
258
+ cp === 0x05bf || // Hebrew
259
+ (cp >= 0x05c1 && cp <= 0x05c2) || // Hebrew
260
+ (cp >= 0x05c4 && cp <= 0x05c5) || // Hebrew
261
+ cp === 0x05c7 || // Hebrew
262
+ (cp >= 0x0610 && cp <= 0x061a) || // Arabic
263
+ (cp >= 0x064b && cp <= 0x065f) || // Arabic
264
+ cp === 0x0670 || // Arabic
265
+ (cp >= 0x06d6 && cp <= 0x06dc) || // Arabic
266
+ (cp >= 0x06df && cp <= 0x06e4) || // Arabic
267
+ (cp >= 0x06e7 && cp <= 0x06e8) || // Arabic
268
+ (cp >= 0x06ea && cp <= 0x06ed) || // Arabic
269
+ (cp >= 0x0730 && cp <= 0x074a) || // Syriac
270
+ (cp >= 0x0900 && cp <= 0x0903) || // Devanagari
271
+ (cp >= 0x093a && cp <= 0x094f) || // Devanagari
272
+ (cp >= 0x0951 && cp <= 0x0957) || // Devanagari
273
+ (cp >= 0x0962 && cp <= 0x0963) || // Devanagari
274
+ cp === 0x0e31 || // Thai
275
+ (cp >= 0x0e34 && cp <= 0x0e3a) || // Thai
276
+ (cp >= 0x0e47 && cp <= 0x0e4e) || // Thai
277
+ (cp >= 0x1ab0 && cp <= 0x1aff) || // Combining Diacritical Marks Extended
278
+ (cp >= 0x1dc0 && cp <= 0x1dff) || // Combining Diacritical Marks Supplement
279
+ (cp >= 0x20d0 && cp <= 0x20ff) || // Combining Diacritical Marks for Symbols
280
+ (cp >= 0xfe00 && cp <= 0xfe0f) || // Variation Selectors
281
+ (cp >= 0xe0100 && cp <= 0xe01ef) // Variation Selectors Supplement
282
+ )
283
+ }
284
+
285
+ function isRegionalIndicator(cp: number): boolean {
286
+ return cp >= 0x1f1e6 && cp <= 0x1f1ff
287
+ }
288
+
289
+ function isZWJ(cp: number): boolean {
290
+ return cp === 0x200d
291
+ }
292
+
293
+ function isVS16(cp: number): boolean {
294
+ return cp === 0xfe0f
295
+ }
296
+
297
+ function isEmojiModifier(cp: number): boolean {
298
+ return cp >= 0x1f3fb && cp <= 0x1f3ff // Skin tone modifiers
299
+ }
300
+
209
301
  // ── Internal attrs interface ───────────────────────────────────────────
210
302
 
211
303
  interface Attrs {
@@ -286,6 +378,23 @@ export function createScreen(options: ScreenOptions = {}): Screen {
286
378
  let reverseVideo = false
287
379
  let syncOutput = false
288
380
 
381
+ // Kitty keyboard protocol
382
+ // Headless design: we implement the full push/pop/query state machine so that
383
+ // applications can negotiate keyboard encoding modes. The flags are stored and
384
+ // queryable (CSI ? u responds correctly), and a host can read them to decide
385
+ // how to encode keypresses. The actual key encoding lives in the host layer
386
+ // (e.g. termless's encodeKeyToAnsi), not here — separation of concerns.
387
+ let kittyKeyboardFlags = 0
388
+ let kittyKeyboardStack: number[] = []
389
+
390
+ // Kitty graphics protocol
391
+ // Headless design: we parse the APC G protocol and respond to queries so that
392
+ // applications relying on the query→response handshake work correctly. We don't
393
+ // store image data because there's no pixel framebuffer — unlike sixel where we
394
+ // preserve the raw data for consumers, kitty graphics payloads are chunked and
395
+ // stateful, making storage without rendering impractical.
396
+ let hasKittyGraphics = false
397
+
289
398
  // Scroll region (inclusive, 0-based)
290
399
  let scrollTop = 0
291
400
  let scrollBottom = rows - 1
@@ -299,9 +408,35 @@ export function createScreen(options: ScreenOptions = {}): Screen {
299
408
  // Clipboard (OSC 52)
300
409
  let clipboard = ""
301
410
 
411
+ // Current working directory (OSC 7)
412
+ let cwd = ""
413
+
414
+ // Notifications (OSC 9)
415
+ let notifications: string[] = []
416
+
417
+ // Semantic prompt zones (OSC 133 / OSC 633)
418
+ let semanticZones: SemanticZone[] = []
419
+
420
+ // Sixel graphics (DCS q)
421
+ // Headless design: we parse sixel data and store it for consumers (e.g. a GUI
422
+ // renderer could use getSixelImages() to display them). We don't decode pixels
423
+ // because there's no framebuffer to render into — but preserving the data means
424
+ // a host application gets full fidelity without re-parsing the stream.
425
+ let hasSixel = false
426
+ let sixelImages: SixelImage[] = []
427
+
428
+ // Soft-wrap tracking: true if the line break at end of this row was caused by auto-wrap
429
+ let mainSoftWrapped: boolean[] = new Array(rows).fill(false)
430
+ let altSoftWrapped: boolean[] = new Array(rows).fill(false)
431
+ let softWrapped = mainSoftWrapped
432
+
302
433
  // Last printed character for REP
303
434
  let lastChar = ""
304
435
 
436
+ // Unicode sequence state
437
+ let pendingRegionalIndicator: string | null = null // First RI waiting for pair
438
+ let afterZWJ = false // Next character should join with previous cell
439
+
305
440
  // Parser state
306
441
  let parserState:
307
442
  | "ground"
@@ -312,9 +447,15 @@ export function createScreen(options: ScreenOptions = {}): Screen {
312
447
  | "dcs"
313
448
  | "dcs_passthrough"
314
449
  | "osc_st"
315
- | "dcs_st" = "ground"
450
+ | "dcs_st"
451
+ | "apc"
452
+ | "apc_st" = "ground"
316
453
  let escBuf = ""
317
454
  let oscBuf = ""
455
+ let dcsBuf = ""
456
+ let dcsStartRow = 0
457
+ let dcsStartCol = 0
458
+ let apcBuf = ""
318
459
 
319
460
  // Decoder for incoming bytes
320
461
  const decoder = new TextDecoder()
@@ -374,19 +515,71 @@ export function createScreen(options: ScreenOptions = {}): Screen {
374
515
  }
375
516
  for (let i = top; i < bottom; i++) {
376
517
  grid[i] = grid[i + 1]!
518
+ softWrapped[i] = softWrapped[i + 1]!
377
519
  }
378
520
  grid[bottom] = makeRow(cols)
521
+ softWrapped[bottom] = false
379
522
  }
380
523
 
381
524
  function scrollDown(top: number, bottom: number): void {
382
525
  for (let i = bottom; i > top; i--) {
383
526
  grid[i] = grid[i - 1]!
527
+ softWrapped[i] = softWrapped[i - 1]!
384
528
  }
385
529
  grid[top] = makeRow(cols)
530
+ softWrapped[top] = false
386
531
  }
387
532
 
388
533
  // ── Character writing ──
389
534
 
535
+ /** Find the previous non-spacer cell (the cell before curX, skipping wide-char spacers) */
536
+ function getPrevCell(): { cell: ScreenCell; col: number } | null {
537
+ if (curX === 0 && curY === 0) return null
538
+ let prevCol = curX - 1
539
+ let prevRow = curY
540
+ if (prevCol < 0) {
541
+ prevRow--
542
+ if (prevRow < 0) return null
543
+ prevCol = cols - 1
544
+ }
545
+ const row = grid[prevRow]!
546
+ let cell = row[prevCol]!
547
+ // If we landed on a spacer (empty char after a wide character), go back one more
548
+ if (cell !== EMPTY_CELL && cell.char === "" && prevCol > 0) {
549
+ prevCol--
550
+ cell = row[prevCol]!
551
+ }
552
+ if (cell === EMPTY_CELL) return null
553
+ return { cell, col: prevCol }
554
+ }
555
+
556
+ /** Widen a cell to 2 columns, adding a spacer cell after it */
557
+ function widenCell(row: ScreenCell[], col: number, cell: ScreenCell): void {
558
+ cell.wide = true
559
+ if (col + 1 < cols) {
560
+ let spacer = row[col + 1]!
561
+ if (spacer === EMPTY_CELL) {
562
+ spacer = { ...EMPTY_CELL }
563
+ row[col + 1] = spacer
564
+ }
565
+ spacer.char = ""
566
+ spacer.fg = null
567
+ spacer.bg = null
568
+ spacer.bold = false
569
+ spacer.faint = false
570
+ spacer.italic = false
571
+ spacer.underline = "none"
572
+ spacer.underlineColor = null
573
+ spacer.overline = false
574
+ spacer.strikethrough = false
575
+ spacer.inverse = false
576
+ spacer.hidden = false
577
+ spacer.blink = false
578
+ spacer.wide = false
579
+ spacer.url = null
580
+ }
581
+ }
582
+
390
583
  function writeChar(ch: string): void {
391
584
  // Apply DEC Special Graphics character mapping
392
585
  if (charsetG0 && ch.length === 1) {
@@ -395,12 +588,95 @@ export function createScreen(options: ScreenOptions = {}): Screen {
395
588
  }
396
589
 
397
590
  const codePoint = ch.codePointAt(0) ?? 0
591
+
592
+ // ── VS-16 (U+FE0F): widen previous character to emoji presentation ──
593
+ if (isVS16(codePoint)) {
594
+ const prev = getPrevCell()
595
+ if (prev && !prev.cell.wide) {
596
+ prev.cell.char += ch
597
+ const row = grid[curY === 0 && curX === 0 ? 0 : curY]!
598
+ widenCell(row, prev.col, prev.cell)
599
+ // Advance cursor past the spacer
600
+ curX = prev.col + 2
601
+ if (curX >= cols) curX = cols - 1
602
+ }
603
+ return
604
+ }
605
+
606
+ // ── Combining characters: append to previous cell, zero width ──
607
+ if (isCombining(codePoint) && !isVS16(codePoint)) {
608
+ const prev = getPrevCell()
609
+ if (prev) {
610
+ prev.cell.char += ch
611
+ }
612
+ return
613
+ }
614
+
615
+ // ── Emoji modifier (skin tone): append to previous cell, zero width ──
616
+ if (isEmojiModifier(codePoint)) {
617
+ const prev = getPrevCell()
618
+ if (prev) {
619
+ prev.cell.char += ch
620
+ }
621
+ return
622
+ }
623
+
624
+ // ── ZWJ (U+200D): append to previous cell, flag for next char ──
625
+ if (isZWJ(codePoint)) {
626
+ const prev = getPrevCell()
627
+ if (prev) {
628
+ prev.cell.char += ch
629
+ afterZWJ = true
630
+ }
631
+ return
632
+ }
633
+
634
+ // ── After ZWJ: append this character to the previous cell ──
635
+ if (afterZWJ) {
636
+ afterZWJ = false
637
+ const prev = getPrevCell()
638
+ if (prev) {
639
+ prev.cell.char += ch
640
+ // The ZWJ sequence stays in the same wide cell
641
+ return
642
+ }
643
+ }
644
+
645
+ // ── Regional Indicators: pair into flag emoji ──
646
+ if (isRegionalIndicator(codePoint)) {
647
+ if (pendingRegionalIndicator !== null) {
648
+ // Second RI: combine with first to form a flag, render as wide
649
+ const flag = pendingRegionalIndicator + ch
650
+ pendingRegionalIndicator = null
651
+ // Write the combined flag as a wide character
652
+ writeCharCore(flag, true)
653
+ return
654
+ } else {
655
+ // First RI: store and wait for second
656
+ pendingRegionalIndicator = ch
657
+ return
658
+ }
659
+ }
660
+
661
+ // Flush any pending regional indicator that wasn't paired
662
+ if (pendingRegionalIndicator !== null) {
663
+ const ri = pendingRegionalIndicator
664
+ pendingRegionalIndicator = null
665
+ writeCharCore(ri, true)
666
+ }
667
+
398
668
  const wide = isWide(codePoint)
669
+ writeCharCore(ch, wide)
670
+ }
671
+
672
+ function writeCharCore(ch: string, wide: boolean): void {
399
673
  const charWidth = wide ? 2 : 1
400
674
 
401
675
  // Handle autowrap at end of line
402
676
  if (curX + charWidth > cols) {
403
677
  if (autoWrap) {
678
+ // Mark this row as soft-wrapped (auto-wrap caused the line break)
679
+ softWrapped[curY] = true
404
680
  curX = 0
405
681
  curY++
406
682
  if (curY > scrollBottom) {
@@ -444,27 +720,8 @@ export function createScreen(options: ScreenOptions = {}): Screen {
444
720
  cell.wide = wide
445
721
  cell.url = attrs.url
446
722
 
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
723
+ if (wide) {
724
+ widenCell(row, curX, cell)
468
725
  }
469
726
 
470
727
  curX += charWidth
@@ -558,9 +815,20 @@ export function createScreen(options: ScreenOptions = {}): Screen {
558
815
  break
559
816
  case "H": // CUP - Cursor Position
560
817
  case "f": // HVP - same as CUP
561
- curY = (parts[0] ?? 1) - 1
562
- curX = (parts[1] ?? 1) - 1
563
- clampCursor()
818
+ if (originMode) {
819
+ // DECOM: positions are relative to scroll region
820
+ curY = scrollTop + (parts[0] ?? 1) - 1
821
+ curX = (parts[1] ?? 1) - 1
822
+ // Clamp to scroll region bounds
823
+ if (curY < scrollTop) curY = scrollTop
824
+ if (curY > scrollBottom) curY = scrollBottom
825
+ if (curX < 0) curX = 0
826
+ if (curX >= cols) curX = cols - 1
827
+ } else {
828
+ curY = (parts[0] ?? 1) - 1
829
+ curX = (parts[1] ?? 1) - 1
830
+ clampCursor()
831
+ }
564
832
  break
565
833
  case "J": // ED - Erase in Display
566
834
  handleEraseDisplay(parts[0] ?? 0)
@@ -634,10 +902,23 @@ export function createScreen(options: ScreenOptions = {}): Screen {
634
902
  case "c": // DA1 - Primary Device Attributes
635
903
  if (onResponse) {
636
904
  if (params === "" || params === "0") {
905
+ // VT200 (62) + sixel (4). We include sixel because we parse and
906
+ // preserve sixel data via getSixelImages() — applications checking
907
+ // DA1 for sixel support before sending image data will work correctly.
637
908
  onResponse("\x1b[?62;4c")
638
909
  }
639
910
  }
640
911
  break
912
+ case "h": // SM - Set Mode (non-private)
913
+ for (const code of parts) {
914
+ if (code === 4) insertMode = true // IRM - Insert/Replace Mode
915
+ }
916
+ break
917
+ case "l": // RM - Reset Mode (non-private)
918
+ for (const code of parts) {
919
+ if (code === 4) insertMode = false
920
+ }
921
+ break
641
922
  case "s": // SCP - Save Cursor Position
642
923
  savedCurX = curX
643
924
  savedCurY = curY
@@ -653,6 +934,8 @@ export function createScreen(options: ScreenOptions = {}): Screen {
653
934
  }
654
935
 
655
936
  function handleCSIGt(params: string, _intermediates: string, finalByte: string): void {
937
+ const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
938
+
656
939
  // CSI > sequences
657
940
  if (finalByte === "c") {
658
941
  // DA2 - Secondary Device Attributes
@@ -668,12 +951,44 @@ export function createScreen(options: ScreenOptions = {}): Screen {
668
951
  onResponse("\x1bP>|vterm.js 0.1.0\x1b\\")
669
952
  }
670
953
  }
954
+ } else if (finalByte === "u") {
955
+ // CSI > flags u — Push keyboard mode (Kitty keyboard protocol)
956
+ kittyKeyboardStack.push(kittyKeyboardFlags)
957
+ kittyKeyboardFlags = parts[0] ?? 0
958
+ }
959
+ }
960
+
961
+ function handleCSILt(_params: string, _intermediates: string, finalByte: string): void {
962
+ // CSI < sequences
963
+ if (finalByte === "u") {
964
+ // CSI < u — Pop keyboard mode (Kitty keyboard protocol)
965
+ kittyKeyboardFlags = kittyKeyboardStack.pop() ?? 0
966
+ }
967
+ }
968
+
969
+ function handleCSIEq(params: string, _intermediates: string, finalByte: string): void {
970
+ // CSI = sequences
971
+ if (finalByte === "c") {
972
+ // DA3 - Tertiary Device Attributes
973
+ if (onResponse) {
974
+ if (params === "" || params === "0") {
975
+ onResponse("\x1bP!|00000000\x1b\\")
976
+ }
977
+ }
671
978
  }
672
979
  }
673
980
 
674
981
  function handleCSIPrivate(params: string, intermediates: string, finalByte: string): void {
675
982
  const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
676
983
 
984
+ // CSI ? u — Query keyboard mode (Kitty keyboard protocol)
985
+ if (finalByte === "u") {
986
+ if (onResponse) {
987
+ onResponse(`\x1b[?${kittyKeyboardFlags}u`)
988
+ }
989
+ return
990
+ }
991
+
677
992
  // DECRPM - Mode reporting: CSI ? Pd $ p
678
993
  if (intermediates === "$" && finalByte === "p") {
679
994
  if (onResponse) {
@@ -729,6 +1044,17 @@ export function createScreen(options: ScreenOptions = {}): Screen {
729
1044
  return
730
1045
  }
731
1046
 
1047
+ // DECSED / DECSEL — selective erase (CSI ? J / CSI ? K)
1048
+ // We treat selective erase same as normal erase (no DECSCA protection tracking)
1049
+ if (finalByte === "J") {
1050
+ handleEraseDisplay(parts[0] ?? 0)
1051
+ return
1052
+ }
1053
+ if (finalByte === "K") {
1054
+ handleEraseLine(parts[0] ?? 0)
1055
+ return
1056
+ }
1057
+
732
1058
  const set = finalByte === "h"
733
1059
 
734
1060
  for (const code of parts) {
@@ -756,14 +1082,20 @@ export function createScreen(options: ScreenOptions = {}): Screen {
756
1082
  if (set && !useAltScreen) {
757
1083
  useAltScreen = true
758
1084
  grid = altGrid
1085
+ softWrapped = altSoftWrapped
759
1086
  } else if (!set && useAltScreen) {
760
1087
  useAltScreen = false
761
1088
  grid = mainGrid
1089
+ softWrapped = mainSoftWrapped
762
1090
  }
763
1091
  break
764
1092
  case 66: // DECNKM - Application Keypad
765
1093
  applicationKeypad = set
766
1094
  break
1095
+ case 9: // X10 mouse tracking
1096
+ mouseTracking = set
1097
+ mouseTrackingMode = set ? 9 : 0
1098
+ break
767
1099
  case 1000: // Mouse tracking (basic)
768
1100
  mouseTracking = set
769
1101
  mouseTrackingMode = set ? 1000 : 0
@@ -776,6 +1108,14 @@ export function createScreen(options: ScreenOptions = {}): Screen {
776
1108
  mouseTracking = set
777
1109
  mouseTrackingMode = set ? 1003 : 0
778
1110
  break
1111
+ case 1015: // urxvt mouse encoding
1112
+ mouseTracking = set
1113
+ mouseTrackingMode = set ? 1015 : 0
1114
+ break
1115
+ case 1016: // SGR pixel mouse
1116
+ mouseTracking = set
1117
+ mouseTrackingMode = set ? 1016 : 0
1118
+ break
779
1119
  case 1004: // Focus tracking
780
1120
  focusTracking = set
781
1121
  break
@@ -788,12 +1128,15 @@ export function createScreen(options: ScreenOptions = {}): Screen {
788
1128
  savedCurY = curY
789
1129
  useAltScreen = true
790
1130
  altGrid = makeGrid(cols, rows)
1131
+ altSoftWrapped = new Array(rows).fill(false)
791
1132
  grid = altGrid
1133
+ softWrapped = altSoftWrapped
792
1134
  curX = 0
793
1135
  curY = 0
794
1136
  } else if (!set && useAltScreen) {
795
1137
  useAltScreen = false
796
1138
  grid = mainGrid
1139
+ softWrapped = mainSoftWrapped
797
1140
  curX = savedCurX
798
1141
  curY = savedCurY
799
1142
  clampCursor()
@@ -855,7 +1198,12 @@ export function createScreen(options: ScreenOptions = {}): Screen {
855
1198
  const r = grid[row]
856
1199
  if (!r) return
857
1200
  for (let col = startCol; col <= endCol && col < cols; col++) {
858
- r[col] = emptyCell()
1201
+ const cell = emptyCell()
1202
+ // Fill erased cells with the current background color
1203
+ if (attrs.bg) {
1204
+ cell.bg = { ...attrs.bg }
1205
+ }
1206
+ r[col] = cell
859
1207
  }
860
1208
  }
861
1209
 
@@ -1181,8 +1529,30 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1181
1529
  case 2: // Set window title
1182
1530
  title = value
1183
1531
  break
1532
+ case 133: {
1533
+ // Semantic prompt markers (FinalTerm / shell integration)
1534
+ // Value format: "X" or "X;params" where X is A/B/C/D
1535
+ const marker = value.charAt(0)
1536
+ switch (marker) {
1537
+ case "A": // Start of prompt
1538
+ semanticZones.push({ type: "prompt", startRow: curY, startCol: curX })
1539
+ break
1540
+ case "B": // End of prompt / start of command
1541
+ semanticZones.push({ type: "command", startRow: curY, startCol: curX })
1542
+ break
1543
+ case "C": // End of command / start of output
1544
+ semanticZones.push({ type: "output", startRow: curY, startCol: curX })
1545
+ break
1546
+ case "D": // End of output (exit code in params, ignored for storage)
1547
+ break
1548
+ }
1549
+ break
1550
+ }
1184
1551
  case 1: // Set icon name (ignore)
1185
1552
  break
1553
+ case 7: // Current working directory: OSC 7 ; file://host/path ST
1554
+ cwd = value
1555
+ break
1186
1556
  case 8: {
1187
1557
  // Hyperlink: OSC 8 ; params ; url ST
1188
1558
  // Format: 8;params;url or 8;;url or 8;; (close)
@@ -1230,6 +1600,113 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1230
1600
  }
1231
1601
  break
1232
1602
  }
1603
+ case 9: // Notifications: OSC 9 ; message ST (iTerm2/ConEmu convention)
1604
+ notifications.push(value)
1605
+ break
1606
+ case 633: {
1607
+ // VS Code shell integration (OSC 633 is a superset of OSC 133)
1608
+ // Maps to the same semantic zone model as FinalTerm markers.
1609
+ const marker = value.charAt(0)
1610
+ switch (marker) {
1611
+ case "A":
1612
+ semanticZones.push({ type: "prompt", startRow: curY, startCol: curX })
1613
+ break
1614
+ case "B":
1615
+ semanticZones.push({ type: "command", startRow: curY, startCol: curX })
1616
+ break
1617
+ case "C":
1618
+ semanticZones.push({ type: "output", startRow: curY, startCol: curX })
1619
+ break
1620
+ case "D":
1621
+ break
1622
+ }
1623
+ break
1624
+ }
1625
+ case 1337: {
1626
+ // iTerm2 proprietary sequences
1627
+ if (value === "ReportCellSize" && onResponse) {
1628
+ // Report default 8x16 pixel cell size (standard monospace ratio)
1629
+ onResponse("\x1b]1337;ReportCellSize=16;8\x1b\\")
1630
+ } else if (value === "RequestCapabilities" && onResponse) {
1631
+ // Report empty capabilities (protocol supported, no iTerm2-specific features)
1632
+ onResponse("\x1b]1337;Capabilities=\x1b\\")
1633
+ }
1634
+ // Inline images (File=...) are silently consumed — no pixel framebuffer
1635
+ break
1636
+ }
1637
+ }
1638
+ }
1639
+
1640
+ // ── DCS handler ──
1641
+
1642
+ function handleDCS(data: string): void {
1643
+ // Sixel graphics: DCS [Ps;Ps;Ps] q [sixel-data]
1644
+ const match = data.match(/^(\d*(?:;\d*)*)q(.*)$/s)
1645
+ if (match) {
1646
+ hasSixel = true
1647
+ sixelImages.push({
1648
+ data: match[2]!,
1649
+ row: dcsStartRow,
1650
+ col: dcsStartCol,
1651
+ })
1652
+ return
1653
+ }
1654
+
1655
+ // DECRQSS: DCS $ q Pt ST → response DCS Ps $ r Pt ST
1656
+ if (data.startsWith("$q") && onResponse) {
1657
+ const pt = data.substring(2)
1658
+ if (pt === '"p') {
1659
+ // DECSCL - Conformance level: VT200 mode, 8-bit controls
1660
+ onResponse('\x1bP1$r62;1"p\x1b\\')
1661
+ } else {
1662
+ // Not recognized
1663
+ onResponse("\x1bP0$r\x1b\\")
1664
+ }
1665
+ return
1666
+ }
1667
+
1668
+ // XTGETTCAP: DCS + q hex ST → response DCS 1 + r hex = hexvalue ST
1669
+ if (data.startsWith("+q") && onResponse) {
1670
+ const hexName = data.substring(2)
1671
+ if (hexName === "544e") {
1672
+ // "TN" = terminal name → "vterm"
1673
+ const hexValue = Array.from(new TextEncoder().encode("vterm"))
1674
+ .map((b) => b.toString(16).padStart(2, "0"))
1675
+ .join("")
1676
+ onResponse(`\x1bP1+r544e=${hexValue}\x1b\\`)
1677
+ } else {
1678
+ // Unknown capability
1679
+ onResponse("\x1bP0+r\x1b\\")
1680
+ }
1681
+ return
1682
+ }
1683
+
1684
+ // Other DCS sequences are consumed and ignored
1685
+ }
1686
+
1687
+ // ── APC handler ──
1688
+
1689
+ function handleAPC(data: string): void {
1690
+ if (!data.startsWith("G")) return
1691
+
1692
+ hasKittyGraphics = true
1693
+
1694
+ // Parse key=value pairs
1695
+ const semicolonIdx = data.indexOf(";")
1696
+ const kvPart = semicolonIdx >= 0 ? data.substring(1, semicolonIdx) : data.substring(1)
1697
+
1698
+ const params: Record<string, string> = {}
1699
+ for (const pair of kvPart.split(",")) {
1700
+ const eqIdx = pair.indexOf("=")
1701
+ if (eqIdx >= 0) {
1702
+ params[pair.substring(0, eqIdx)] = pair.substring(eqIdx + 1)
1703
+ }
1704
+ }
1705
+
1706
+ // Handle query action
1707
+ if (params.a === "q" && onResponse) {
1708
+ // Respond: OK
1709
+ onResponse(`\x1b_Gi=${params.i ?? "0"};OK\x1b\\`)
1233
1710
  }
1234
1711
  }
1235
1712
 
@@ -1241,6 +1718,8 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1241
1718
  originMode = false
1242
1719
  autoWrap = true
1243
1720
  curVisible = true
1721
+ kittyKeyboardFlags = 0
1722
+ kittyKeyboardStack = []
1244
1723
  cursorShape = "block"
1245
1724
  cursorBlinking = true
1246
1725
  applicationCursor = false
@@ -1292,15 +1771,29 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1292
1771
  insertMode = false
1293
1772
  reverseVideo = false
1294
1773
  syncOutput = false
1774
+ kittyKeyboardFlags = 0
1775
+ kittyKeyboardStack = []
1776
+ hasKittyGraphics = false
1777
+ hasSixel = false
1778
+ sixelImages = []
1295
1779
  scrollTop = 0
1296
1780
  scrollBottom = rows - 1
1297
1781
  viewportOffset = 0
1298
1782
  charsetG0 = false
1299
1783
  clipboard = ""
1784
+ cwd = ""
1785
+ notifications = []
1300
1786
  lastChar = ""
1787
+ pendingRegionalIndicator = null
1788
+ afterZWJ = false
1301
1789
  parserState = "ground"
1302
1790
  escBuf = ""
1303
1791
  oscBuf = ""
1792
+ apcBuf = ""
1793
+ semanticZones = []
1794
+ mainSoftWrapped = new Array(rows).fill(false)
1795
+ altSoftWrapped = new Array(rows).fill(false)
1796
+ softWrapped = mainSoftWrapped
1304
1797
  }
1305
1798
 
1306
1799
  // ── Main parser ──
@@ -1326,7 +1819,8 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1326
1819
  // TAB
1327
1820
  curX = Math.min((Math.floor(curX / 8) + 1) * 8, cols - 1)
1328
1821
  } else if (code === 0x0a || code === 0x0b || code === 0x0c) {
1329
- // LF, VT, FF — linefeed
1822
+ // LF, VT, FF — linefeed (hard break — clear any soft-wrap flag)
1823
+ softWrapped[curY] = false
1330
1824
  curY++
1331
1825
  if (curY > scrollBottom) {
1332
1826
  curY = scrollBottom
@@ -1359,6 +1853,9 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1359
1853
  } else if (ch === "P") {
1360
1854
  parserState = "dcs"
1361
1855
  escBuf = ""
1856
+ dcsBuf = ""
1857
+ dcsStartRow = curY
1858
+ dcsStartCol = curX
1362
1859
  } else if (ch === "c") {
1363
1860
  // RIS - Reset to Initial State
1364
1861
  fullReset()
@@ -1425,6 +1922,18 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1425
1922
  } else if (ch === ")") {
1426
1923
  // Designate G1 character set (ignored, just consume next byte)
1427
1924
  parserState = "escape_charset"
1925
+ } else if (ch === "=") {
1926
+ // DECKPAM - Application Keypad Mode
1927
+ applicationKeypad = true
1928
+ parserState = "ground"
1929
+ } else if (ch === ">") {
1930
+ // DECKPNM - Normal Keypad Mode
1931
+ applicationKeypad = false
1932
+ parserState = "ground"
1933
+ } else if (ch === "_") {
1934
+ // APC - Application Program Command
1935
+ parserState = "apc"
1936
+ apcBuf = ""
1428
1937
  } else {
1429
1938
  // Unknown escape — return to ground
1430
1939
  parserState = "ground"
@@ -1468,6 +1977,10 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1468
1977
  handleCSIPrivate(paramPart.substring(1), intermediatePart, ch)
1469
1978
  } else if (paramPart.startsWith(">")) {
1470
1979
  handleCSIGt(paramPart.substring(1), intermediatePart, ch)
1980
+ } else if (paramPart.startsWith("<")) {
1981
+ handleCSILt(paramPart.substring(1), intermediatePart, ch)
1982
+ } else if (paramPart.startsWith("=")) {
1983
+ handleCSIEq(paramPart.substring(1), intermediatePart, ch)
1471
1984
  } else {
1472
1985
  handleCSI(paramPart, intermediatePart, ch)
1473
1986
  }
@@ -1504,19 +2017,23 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1504
2017
  break
1505
2018
 
1506
2019
  case "dcs":
1507
- // Consume until ST
2020
+ // Accumulate DCS data until ST (ESC \) or BEL
1508
2021
  if (code === 0x1b) {
1509
2022
  parserState = "dcs_st"
1510
2023
  } else if (code === 0x07) {
1511
- // Some implementations use BEL to terminate DCS
2024
+ // BEL terminates DCS
2025
+ handleDCS(dcsBuf)
1512
2026
  parserState = "ground"
2027
+ } else {
2028
+ dcsBuf += ch
1513
2029
  }
1514
2030
  break
1515
2031
 
1516
2032
  case "dcs_st":
1517
2033
  // Expecting backslash to complete ST
1518
2034
  if (ch === "\\") {
1519
- // DCS complete
2035
+ // ST (String Terminator) — end of DCS
2036
+ handleDCS(dcsBuf)
1520
2037
  }
1521
2038
  parserState = "ground"
1522
2039
  break
@@ -1527,22 +2044,167 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1527
2044
  parserState = "dcs_st"
1528
2045
  }
1529
2046
  break
2047
+
2048
+ case "apc":
2049
+ if (code === 0x1b) {
2050
+ parserState = "apc_st"
2051
+ } else if (code === 0x07) {
2052
+ // BEL terminates APC
2053
+ handleAPC(apcBuf)
2054
+ parserState = "ground"
2055
+ } else {
2056
+ apcBuf += ch
2057
+ }
2058
+ break
2059
+
2060
+ case "apc_st":
2061
+ if (ch === "\\") {
2062
+ // ST (String Terminator) — end of APC
2063
+ handleAPC(apcBuf)
2064
+ }
2065
+ parserState = "ground"
2066
+ break
1530
2067
  }
1531
2068
  }
1532
2069
  }
1533
2070
 
1534
2071
  // ── Resize ──
1535
2072
 
2073
+ /**
2074
+ * Reconstruct logical lines from a grid, joining rows that were soft-wrapped.
2075
+ * Returns an array of logical lines, each being an array of ScreenCells (may be longer than cols).
2076
+ */
2077
+ function getLogicalLines(srcGrid: ScreenCell[][], srcSoftWrapped: boolean[], srcRows: number): ScreenCell[][] {
2078
+ const logical: ScreenCell[][] = []
2079
+ let currentLine: ScreenCell[] = []
2080
+
2081
+ for (let r = 0; r < srcRows; r++) {
2082
+ const row = srcGrid[r]
2083
+ if (!row) continue
2084
+ // Append this row's cells to the current logical line
2085
+ for (let c = 0; c < row.length; c++) {
2086
+ currentLine.push(row[c]!)
2087
+ }
2088
+ if (srcSoftWrapped[r]) {
2089
+ // This row was soft-wrapped — continue accumulating into same logical line
2090
+ continue
2091
+ }
2092
+ // Hard break (or last row): finalize this logical line
2093
+ logical.push(currentLine)
2094
+ currentLine = []
2095
+ }
2096
+ // If there's a dangling line (shouldn't happen, but be safe)
2097
+ if (currentLine.length > 0) {
2098
+ logical.push(currentLine)
2099
+ }
2100
+ return logical
2101
+ }
2102
+
2103
+ /**
2104
+ * Re-wrap logical lines to a new column width, producing grid rows and soft-wrap flags.
2105
+ */
2106
+ function rewrapLines(logicalLines: ScreenCell[][], newCols: number): { rows: ScreenCell[][]; wrapped: boolean[] } {
2107
+ const outRows: ScreenCell[][] = []
2108
+ const outWrapped: boolean[] = []
2109
+
2110
+ for (const line of logicalLines) {
2111
+ // Trim trailing empty cells from logical line
2112
+ let lineLen = line.length
2113
+ while (lineLen > 0) {
2114
+ const cell = line[lineLen - 1]!
2115
+ if (cell === EMPTY_CELL || (cell.char === "" && !cell.wide)) {
2116
+ lineLen--
2117
+ } else {
2118
+ break
2119
+ }
2120
+ }
2121
+
2122
+ if (lineLen === 0) {
2123
+ // Empty logical line — produce one empty row
2124
+ outRows.push(makeRow(newCols))
2125
+ outWrapped.push(false)
2126
+ continue
2127
+ }
2128
+
2129
+ // Wrap the logical line content into rows of newCols width
2130
+ let pos = 0
2131
+ while (pos < lineLen) {
2132
+ const row = makeRow(newCols)
2133
+ let col = 0
2134
+ while (col < newCols && pos < lineLen) {
2135
+ const cell = line[pos]!
2136
+ if (cell.wide && col + 2 > newCols) {
2137
+ // Wide char doesn't fit — leave rest of row empty, wrap to next
2138
+ break
2139
+ }
2140
+ row[col] = cell === EMPTY_CELL ? EMPTY_CELL : { ...cell }
2141
+ col++
2142
+ pos++
2143
+ // If cell was wide, the next cell in the logical line is the spacer
2144
+ // which we already advanced past via pos++
2145
+ }
2146
+ const moreContent = pos < lineLen
2147
+ outRows.push(row)
2148
+ outWrapped.push(moreContent) // soft-wrapped if there's more content to come
2149
+ }
2150
+ }
2151
+
2152
+ return { rows: outRows, wrapped: outWrapped }
2153
+ }
2154
+
2155
+ /**
2156
+ * Trim trailing empty rows from reflowed result, so they don't push content off the top
2157
+ * when we take the last newRows rows.
2158
+ */
2159
+ function trimTrailingEmptyRows(result: { rows: ScreenCell[][]; wrapped: boolean[] }): void {
2160
+ while (result.rows.length > 1) {
2161
+ const lastRow = result.rows[result.rows.length - 1]!
2162
+ const isEmpty = lastRow.every((cell) => cell === EMPTY_CELL || (cell.char === "" && !cell.wide))
2163
+ if (isEmpty && !result.wrapped[result.rows.length - 2]) {
2164
+ // The row before wasn't soft-wrapped and this row is empty — trim it
2165
+ result.rows.pop()
2166
+ result.wrapped.pop()
2167
+ } else {
2168
+ break
2169
+ }
2170
+ }
2171
+ }
2172
+
1536
2173
  function resize(newCols: number, newRows: number): void {
2174
+ // Reflow main grid
2175
+ const mainLogical = getLogicalLines(mainGrid, mainSoftWrapped, rows)
2176
+ const mainResult = rewrapLines(mainLogical, newCols)
2177
+ trimTrailingEmptyRows(mainResult)
2178
+
2179
+ // Reflow alt grid (usually not reflowed, but do it for consistency)
2180
+ const altLogical = getLogicalLines(altGrid, altSoftWrapped, rows)
2181
+ const altResult = rewrapLines(altLogical, newCols)
2182
+ trimTrailingEmptyRows(altResult)
2183
+
2184
+ // Build new grids: if reflowed content fits, place at top; if it overflows, take the last newRows
1537
2185
  const newMain = makeGrid(newCols, newRows)
1538
- const newAlt = makeGrid(newCols, newRows)
2186
+ const newMainWrapped: boolean[] = new Array(newRows).fill(false)
2187
+ const mainStartRow = Math.max(0, mainResult.rows.length - newRows)
2188
+ for (let r = 0; r < newRows && mainStartRow + r < mainResult.rows.length; r++) {
2189
+ newMain[r] = mainResult.rows[mainStartRow + r]!
2190
+ newMainWrapped[r] = mainResult.wrapped[mainStartRow + r]!
2191
+ }
1539
2192
 
1540
- copyGrid(mainGrid, newMain, Math.min(cols, newCols), Math.min(rows, newRows))
1541
- copyGrid(altGrid, newAlt, Math.min(cols, newCols), Math.min(rows, newRows))
2193
+ // Build new alt grid
2194
+ const newAlt = makeGrid(newCols, newRows)
2195
+ const newAltWrapped: boolean[] = new Array(newRows).fill(false)
2196
+ const altStartRow = Math.max(0, altResult.rows.length - newRows)
2197
+ for (let r = 0; r < newRows && altStartRow + r < altResult.rows.length; r++) {
2198
+ newAlt[r] = altResult.rows[altStartRow + r]!
2199
+ newAltWrapped[r] = altResult.wrapped[altStartRow + r]!
2200
+ }
1542
2201
 
1543
2202
  mainGrid = newMain
1544
2203
  altGrid = newAlt
2204
+ mainSoftWrapped = newMainWrapped
2205
+ altSoftWrapped = newAltWrapped
1545
2206
  grid = useAltScreen ? altGrid : mainGrid
2207
+ softWrapped = useAltScreen ? altSoftWrapped : mainSoftWrapped
1546
2208
  cols = newCols
1547
2209
  rows = newRows
1548
2210
  scrollTop = 0
@@ -1550,17 +2212,6 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1550
2212
  clampCursor()
1551
2213
  }
1552
2214
 
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
2215
  // ── Accessors ──
1565
2216
 
1566
2217
  function getCell(row: number, col: number): ScreenCell {
@@ -1652,6 +2303,16 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1652
2303
  return syncOutput
1653
2304
  case "sgrMouse":
1654
2305
  return sgrMouse
2306
+ case "pixelMouse":
2307
+ return mouseTrackingMode === 1016
2308
+ case "leftRightMargin":
2309
+ return false // Not implemented yet
2310
+ case "kittyKeyboard":
2311
+ return kittyKeyboardFlags > 0
2312
+ case "kittyGraphics":
2313
+ return hasKittyGraphics
2314
+ case "sixel":
2315
+ return hasSixel
1655
2316
  default:
1656
2317
  return false
1657
2318
  }
@@ -1680,10 +2341,15 @@ export function createScreen(options: ScreenOptions = {}): Screen {
1680
2341
  getCursorBlinking: () => cursorBlinking,
1681
2342
  getTitle: () => title,
1682
2343
  getMode,
2344
+ getClipboard: () => clipboard,
2345
+ getCwd: () => cwd,
2346
+ getNotifications: () => [...notifications],
1683
2347
  getScrollbackLength: () => scrollback.length,
1684
2348
  getViewportOffset: () => viewportOffset,
1685
2349
  scrollViewport: (delta: number) => {
1686
2350
  viewportOffset = Math.max(0, Math.min(scrollback.length, viewportOffset + delta))
1687
2351
  },
2352
+ getSemanticZones: () => semanticZones.map((z) => ({ ...z })),
2353
+ getSixelImages: () => sixelImages.map((img) => ({ ...img })),
1688
2354
  }
1689
2355
  }