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 +44 -44
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/screen.ts +712 -46
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
|
|
104
|
-
|
|
105
|
-
| `cols`
|
|
106
|
-
| `rows`
|
|
107
|
-
| `scrollbackLimit` | `number`
|
|
108
|
-
| `onResponse`
|
|
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
|
|
113
|
-
|
|
114
|
-
| `process(data: Uint8Array)`
|
|
115
|
-
| `getText()`
|
|
116
|
-
| `getTextRange(sr, sc, er, ec)` | Get text in a range
|
|
117
|
-
| `getLine(row)`
|
|
118
|
-
| `getCell(row, col)`
|
|
119
|
-
| `getCursorPosition()`
|
|
120
|
-
| `getCursorVisible()`
|
|
121
|
-
| `getCursorShape()`
|
|
122
|
-
| `getCursorBlinking()`
|
|
123
|
-
| `getMode(mode)`
|
|
124
|
-
| `getTitle()`
|
|
125
|
-
| `getScrollbackLength()`
|
|
126
|
-
| `getViewportOffset()`
|
|
127
|
-
| `scrollViewport(delta)`
|
|
128
|
-
| `resize(cols, rows)`
|
|
129
|
-
| `reset()`
|
|
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
|
|
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
|
|
149
|
+
url: string | null // OSC 8 hyperlink
|
|
150
150
|
}
|
|
151
151
|
```
|
|
152
152
|
|
|
153
153
|
## vs vt100.js
|
|
154
154
|
|
|
155
|
-
| Feature
|
|
156
|
-
|
|
157
|
-
| SGR basics (bold, italic, underline, colors) | Yes
|
|
158
|
-
| Underline styles (curly, dotted, dashed)
|
|
159
|
-
| Underline color
|
|
160
|
-
| Blink, overline
|
|
161
|
-
| Cursor shapes (DECSCUSR)
|
|
162
|
-
| OSC 8 hyperlinks
|
|
163
|
-
| DA1/DA2/DA3 responses
|
|
164
|
-
| DSR/DECRPM responses
|
|
165
|
-
| Mouse tracking
|
|
166
|
-
| Focus tracking
|
|
167
|
-
| Synchronized output
|
|
168
|
-
| DEC Special Graphics
|
|
169
|
-
| REP (repeat character)
|
|
170
|
-
| DECSTR (soft reset)
|
|
171
|
-
| DCS sequences
|
|
172
|
-
| Package size
|
|
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
package/src/index.ts
CHANGED
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 (
|
|
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
|
|
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"
|
|
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
|
|
448
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
1541
|
-
|
|
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
|
}
|