@hncode/hncode 0.5.2 → 0.5.5
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/package.json +38 -38
- package/src/agent.js +172 -19
- package/src/cache.js +175 -130
- package/src/clipboard.js +69 -2
- package/src/config.js +10 -3
- package/src/index.js +29 -5
- package/src/keybindings.js +25 -3
- package/src/llm.js +54 -6
- package/src/mcp.js +419 -384
- package/src/plugin.js +95 -10
- package/src/remote.js +307 -289
- package/src/session-hub.js +37 -0
- package/src/settings-schema.js +2 -0
- package/src/shell-safety.js +610 -562
- package/src/swarm-progress.js +1 -1
- package/src/team/mailbox.js +216 -139
- package/src/team/permission.js +121 -0
- package/src/team/protocol.js +167 -0
- package/src/team/runner.js +127 -12
- package/src/team/spawn.js +63 -5
- package/src/team/store.js +31 -4
- package/src/team/tasks.js +15 -2
- package/src/team/team.js +51 -16
- package/src/team-panel.js +418 -20
- package/src/term-caps.js +457 -410
- package/src/term.js +142 -26
- package/src/tool-result.js +50 -2
- package/src/tools/agent.js +464 -436
- package/src/tools/bash.js +53 -14
- package/src/tools/diagnostics.js +13 -0
- package/src/tools/edit.js +727 -577
- package/src/tools/fetch-url.js +118 -5
- package/src/tools/glob.js +5 -3
- package/src/tools/grep.js +19 -5
- package/src/tools/ignore.js +17 -3
- package/src/tools/index.js +17 -3
- package/src/tools/read.js +13 -2
- package/src/tools/shell.js +145 -134
- package/src/tools/tasks.js +214 -141
- package/src/tools/team.js +13 -0
- package/src/tools/utils.js +12 -3
- package/src/tools/web-search.js +85 -34
- package/src/tui.js +2089 -714
- package/src/update.js +38 -3
- package/src/web.js +47 -1
package/src/term-caps.js
CHANGED
|
@@ -1,411 +1,458 @@
|
|
|
1
|
-
// Terminal capability probing — ASK the terminal instead of guessing.
|
|
2
|
-
//
|
|
3
|
-
// WHY THIS EXISTS
|
|
4
|
-
// ---------------
|
|
5
|
-
// Everything this app does that is terminal-specific used to be either unconditional
|
|
6
|
-
// or env-var-gated. Three of those were guesses with real consequences:
|
|
7
|
-
//
|
|
8
|
-
// * `ESC[?2026h/l` (synchronized output) was wrapped around EVERY diff write. A
|
|
9
|
-
// terminal that does not implement it is supposed to ignore the private mode,
|
|
10
|
-
// but "supposed to" is doing a lot of work for two sequences per frame.
|
|
11
|
-
// * the Kitty keyboard protocol was turned ON by default with only
|
|
12
|
-
// `HNCODE_NO_KITTY_KEYS=1` to undo it, and the code's own comment admitted why
|
|
13
|
-
// that is a gamble: a terminal that does not implement `>1u` "reacts badly
|
|
14
|
-
// (dropped/mangled input)". That is a wrong-guess-causes-a-broken-keyboard bug.
|
|
15
|
-
// * the theme was forced to `dark`, whose normal text is WHITE, while the frame
|
|
16
|
-
// pads its rows with PLAIN SPACES — no background is ever painted. On a terminal
|
|
17
|
-
// with a light background that is white text on white: the transcript is
|
|
18
|
-
// invisible. Nothing in the process could tell, because nothing asked.
|
|
19
|
-
//
|
|
20
|
-
// So this module asks. The protocol is the standard one, and it is worth spelling out
|
|
21
|
-
// because it is why this needs no timeout-based guessing:
|
|
22
|
-
//
|
|
23
|
-
// Terminals process input sequentially and answer queries IN ORDER. So the batch
|
|
24
|
-
// ends with a Device Attributes request (`ESC[c`) — the one query every terminal
|
|
25
|
-
// answers — and its reply doubles as the end-of-batch sentinel: everything asked
|
|
26
|
-
// before it has been answered by the time it arrives. No per-query timeout, no
|
|
27
|
-
// waiting for absent replies.
|
|
28
|
-
//
|
|
29
|
-
// A response arrives on the SAME stream as keystrokes, so whoever reads stdin while
|
|
30
|
-
// the probe runs must route it here instead of into the key parser. `probeTerminal`
|
|
31
|
-
// owns stdin for that window and hands back anything it could not consume, so a key
|
|
32
|
-
// pressed during the probe is still delivered.
|
|
33
|
-
//
|
|
34
|
-
// The whole thing is best-effort: an unanswered query leaves its field `null`, which
|
|
35
|
-
// every consumer reads as "no information", never as "not supported".
|
|
36
|
-
|
|
37
|
-
/** How long to wait for the answers. Real terminals reply in ~1ms; this only bites
|
|
38
|
-
* on a terminal that answers nothing at all, where it is the price of not guessing. */
|
|
39
|
-
export const PROBE_TIMEOUT_MS = 250;
|
|
40
|
-
|
|
41
|
-
/** The batch, in the order it is written. DA1 is LAST — it is the sentinel.
|
|
42
|
-
* OSC 11 `?` — report the background colour
|
|
43
|
-
* CSI ? u — report the Kitty KEYBOARD protocol flags
|
|
44
|
-
* CSI ? 2026$p — report whether synchronized output is known
|
|
45
|
-
* ESC _G ... ESC\ — query the Kitty GRAPHICS protocol: does it exist, and does it
|
|
46
|
-
* accept the transmission format we would send
|
|
47
|
-
* CSI 16 t — the pixel size of ONE CELL (XTWINOPS 16)
|
|
48
|
-
* CSI 14 t — the pixel size of the TEXT AREA (XTWINOPS 14), a cross-check
|
|
49
|
-
* for the cell size on a terminal that answers only that
|
|
50
|
-
* CSI c — Device Attributes: the sentinel, and the VT level
|
|
51
|
-
*
|
|
52
|
-
* Both query forms were wrong before, and both mattered only for images.
|
|
53
|
-
*
|
|
54
|
-
* The graphics query is its own APC, not a `CSI ? u` variant — an earlier guess collided
|
|
55
|
-
* with the keyboard probe, and since an unanswered query is inferred as "not supported"
|
|
56
|
-
* once the sentinel arrives, that switched images OFF on the terminals that have them.
|
|
57
|
-
* It carries a REAL payload: one transparent RGBA pixel, RFC 1950 zlib, direct data
|
|
58
|
-
* (f=32, o=z) — the same path the renderer uploads on. A probe that asks about formats we
|
|
59
|
-
* would not send reports on a format we would not send. (dsh-TUI's terminal-querier.ts
|
|
60
|
-
* sends exactly this, with the same base64.)
|
|
61
|
-
*
|
|
62
|
-
* The CELL size is XTWINOPS **16**, not 14: 14 is the text area. The reply to both is
|
|
63
|
-
* `CSI 6 ; w ; h t` — opcode 6 for the cell — so the parser matches on the REPORT
|
|
64
|
-
* opcode, and 14/18 were what an earlier version matched on and therefore never read.
|
|
65
|
-
*/
|
|
66
|
-
export const PROBE_QUERY = '\x1b]11;?\x07' + '\x1b[?u' + '\x1b[?2026$p'
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
* the
|
|
230
|
-
*/
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
*
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
found.
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
return;
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
1
|
+
// Terminal capability probing — ASK the terminal instead of guessing.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS
|
|
4
|
+
// ---------------
|
|
5
|
+
// Everything this app does that is terminal-specific used to be either unconditional
|
|
6
|
+
// or env-var-gated. Three of those were guesses with real consequences:
|
|
7
|
+
//
|
|
8
|
+
// * `ESC[?2026h/l` (synchronized output) was wrapped around EVERY diff write. A
|
|
9
|
+
// terminal that does not implement it is supposed to ignore the private mode,
|
|
10
|
+
// but "supposed to" is doing a lot of work for two sequences per frame.
|
|
11
|
+
// * the Kitty keyboard protocol was turned ON by default with only
|
|
12
|
+
// `HNCODE_NO_KITTY_KEYS=1` to undo it, and the code's own comment admitted why
|
|
13
|
+
// that is a gamble: a terminal that does not implement `>1u` "reacts badly
|
|
14
|
+
// (dropped/mangled input)". That is a wrong-guess-causes-a-broken-keyboard bug.
|
|
15
|
+
// * the theme was forced to `dark`, whose normal text is WHITE, while the frame
|
|
16
|
+
// pads its rows with PLAIN SPACES — no background is ever painted. On a terminal
|
|
17
|
+
// with a light background that is white text on white: the transcript is
|
|
18
|
+
// invisible. Nothing in the process could tell, because nothing asked.
|
|
19
|
+
//
|
|
20
|
+
// So this module asks. The protocol is the standard one, and it is worth spelling out
|
|
21
|
+
// because it is why this needs no timeout-based guessing:
|
|
22
|
+
//
|
|
23
|
+
// Terminals process input sequentially and answer queries IN ORDER. So the batch
|
|
24
|
+
// ends with a Device Attributes request (`ESC[c`) — the one query every terminal
|
|
25
|
+
// answers — and its reply doubles as the end-of-batch sentinel: everything asked
|
|
26
|
+
// before it has been answered by the time it arrives. No per-query timeout, no
|
|
27
|
+
// waiting for absent replies.
|
|
28
|
+
//
|
|
29
|
+
// A response arrives on the SAME stream as keystrokes, so whoever reads stdin while
|
|
30
|
+
// the probe runs must route it here instead of into the key parser. `probeTerminal`
|
|
31
|
+
// owns stdin for that window and hands back anything it could not consume, so a key
|
|
32
|
+
// pressed during the probe is still delivered.
|
|
33
|
+
//
|
|
34
|
+
// The whole thing is best-effort: an unanswered query leaves its field `null`, which
|
|
35
|
+
// every consumer reads as "no information", never as "not supported".
|
|
36
|
+
|
|
37
|
+
/** How long to wait for the answers. Real terminals reply in ~1ms; this only bites
|
|
38
|
+
* on a terminal that answers nothing at all, where it is the price of not guessing. */
|
|
39
|
+
export const PROBE_TIMEOUT_MS = 250;
|
|
40
|
+
|
|
41
|
+
/** The batch, in the order it is written. DA1 is LAST — it is the sentinel.
|
|
42
|
+
* OSC 11 `?` — report the background colour
|
|
43
|
+
* CSI ? u — report the Kitty KEYBOARD protocol flags
|
|
44
|
+
* CSI ? 2026$p — report whether synchronized output is known
|
|
45
|
+
* ESC _G ... ESC\ — query the Kitty GRAPHICS protocol: does it exist, and does it
|
|
46
|
+
* accept the transmission format we would send
|
|
47
|
+
* CSI 16 t — the pixel size of ONE CELL (XTWINOPS 16)
|
|
48
|
+
* CSI 14 t — the pixel size of the TEXT AREA (XTWINOPS 14), a cross-check
|
|
49
|
+
* for the cell size on a terminal that answers only that
|
|
50
|
+
* CSI c — Device Attributes: the sentinel, and the VT level
|
|
51
|
+
*
|
|
52
|
+
* Both query forms were wrong before, and both mattered only for images.
|
|
53
|
+
*
|
|
54
|
+
* The graphics query is its own APC, not a `CSI ? u` variant — an earlier guess collided
|
|
55
|
+
* with the keyboard probe, and since an unanswered query is inferred as "not supported"
|
|
56
|
+
* once the sentinel arrives, that switched images OFF on the terminals that have them.
|
|
57
|
+
* It carries a REAL payload: one transparent RGBA pixel, RFC 1950 zlib, direct data
|
|
58
|
+
* (f=32, o=z) — the same path the renderer uploads on. A probe that asks about formats we
|
|
59
|
+
* would not send reports on a format we would not send. (dsh-TUI's terminal-querier.ts
|
|
60
|
+
* sends exactly this, with the same base64.)
|
|
61
|
+
*
|
|
62
|
+
* The CELL size is XTWINOPS **16**, not 14: 14 is the text area. The reply to both is
|
|
63
|
+
* `CSI 6 ; w ; h t` — opcode 6 for the cell — so the parser matches on the REPORT
|
|
64
|
+
* opcode, and 14/18 were what an earlier version matched on and therefore never read.
|
|
65
|
+
*/
|
|
66
|
+
export const PROBE_QUERY = '\x1b]11;?\x07' + '\x1b[?u' + '\x1b[?2026$p'
|
|
67
|
+
// The three mouse-reporting modes, asked with DECRQM so a terminal that does not implement one can
|
|
68
|
+
// SAY SO rather than being sent it blind. This is the fix for a UI whose keyboard works and whose
|
|
69
|
+
// mouse does nothing: the mode was accepted and no event ever arrived.
|
|
70
|
+
//
|
|
71
|
+
// 1000 click and release
|
|
72
|
+
// 1002 button-motion (drag)
|
|
73
|
+
// 1003 any-motion (hover) — the least widely supported, and the one worth dropping alone
|
|
74
|
+
+ '\x1b[?1000$p' + '\x1b[?1002$p' + '\x1b[?1003$p'
|
|
75
|
+
+ '\x1b_Gi=31,s=1,v=1,a=q,t=d,f=32,o=z;eAFjYGBgAAAABAAB\x1b\\'
|
|
76
|
+
+ '\x1b[16t' + '\x1b[14t' + '\x1b[c';
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The mouse-reporting modes hncode asks for, by name.
|
|
80
|
+
*
|
|
81
|
+
* Kept as a map rather than three magic numbers at the call site so the query, the parser and the
|
|
82
|
+
* enable step all name the same mode.
|
|
83
|
+
*/
|
|
84
|
+
export const MOUSE_MODES = Object.freeze({ click: 1000, drag: 1002, hover: 1003 });
|
|
85
|
+
|
|
86
|
+
/** Synchronized output (BEGIN/END SYNCHRONIZED UPDATE). */
|
|
87
|
+
export const SYNC_OUTPUT_MODE = 2026;
|
|
88
|
+
|
|
89
|
+
/** DECRPM `status` values for a mode. 0 means "not recognised", which is the one that
|
|
90
|
+
* says the terminal has never heard of the mode. */
|
|
91
|
+
const DECRPM_NOT_RECOGNISED = 0;
|
|
92
|
+
const DECRPM_SET = 1;
|
|
93
|
+
const DECRPM_RESET = 2;
|
|
94
|
+
|
|
95
|
+
/** VT level per the FIRST Device Attributes parameter. A small table on purpose:
|
|
96
|
+
* the parameter identifies the level, not the program — `xterm`, `kitty`, `vte` and
|
|
97
|
+
* Windows Terminal all report a VT level in this range, and telling THOSE apart is
|
|
98
|
+
* what the environment is for (see terminalFamily). */
|
|
99
|
+
const VT_LEVELS = {
|
|
100
|
+
1: 'vt100', 2: 'vt100-avo', 6: 'vt102', 12: 'vt125', 15: 'vt131',
|
|
101
|
+
16: 'vt132', 17: 'vt1xx', 18: 'vt330', 19: 'vt340',
|
|
102
|
+
41: 'vt420', 61: 'vt510', 62: 'vt220', 63: 'vt320', 64: 'vt420', 65: 'vt525',
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Meaning of the extension parameters that follow the VT level, for the ones that
|
|
106
|
+
* change what this app may attempt. */
|
|
107
|
+
const DA1_EXTENSIONS = {
|
|
108
|
+
4: 'sixel',
|
|
109
|
+
6: 'selective-erase',
|
|
110
|
+
21: 'horizontal-scroll',
|
|
111
|
+
22: 'ansi-color',
|
|
112
|
+
28: 'rectangular-edit',
|
|
113
|
+
52: 'clipboard',
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
function emptyCaps() {
|
|
117
|
+
return {
|
|
118
|
+
probed: false,
|
|
119
|
+
bg: null, // { r, g, b } of the terminal background, or null
|
|
120
|
+
kitty: null, // true/false: does it speak the Kitty keyboard protocol
|
|
121
|
+
kittyGraphics: null, // true/false: does it speak the Kitty GRAPHICS protocol
|
|
122
|
+
sync: null, // true/false: synchronized output usable
|
|
123
|
+
// Mouse reporting, per mode: true = the terminal recognises it, false = it said it does not
|
|
124
|
+
// (DECRPM status 0), null = it did not answer. The last is treated as YES by the caller, because
|
|
125
|
+
// silence is not a refusal — a terminal that ignores DECRQM entirely must keep working.
|
|
126
|
+
mouseClick: null, // ?1000, click and release
|
|
127
|
+
mouseDrag: null, // ?1002, button-motion
|
|
128
|
+
mouseHover: null, // ?1003, any-motion
|
|
129
|
+
level: null, // 'vt220', …
|
|
130
|
+
extensions: [], // ['sixel', 'ansi-color', …]
|
|
131
|
+
textArea: null, // { w, h } in PIXELS: the terminal's text area, or null
|
|
132
|
+
cell: null, // { w, h } in pixels: one cell, or null
|
|
133
|
+
da1: null, // the raw DA1 parameter list, for diagnostics
|
|
134
|
+
family: null, // a human-readable name, from the environment
|
|
135
|
+
cols: null, // columns, when the terminal reported them
|
|
136
|
+
rows: null, // rows, when the terminal reported them
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let caps = emptyCaps();
|
|
141
|
+
|
|
142
|
+
/** Everything learned so far. Read by the renderer and the theme picker. */
|
|
143
|
+
export function terminalCaps() { return caps; }
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
/** Merge in probe results. Exported so tests (and a future re-probe on resize) can
|
|
147
|
+
* install a set without running the protocol. */
|
|
148
|
+
export function setTerminalCaps(next) {
|
|
149
|
+
caps = Object.assign(emptyCaps(), caps, next, { probed: true });
|
|
150
|
+
return caps;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Whether a `ESC[?2026h … ESC[?2026l` wrapper should be emitted. Unknown (no probe,
|
|
154
|
+
* no reply) keeps the OLD unconditional behaviour — this must not silently change
|
|
155
|
+
* what an already-good terminal receives. */
|
|
156
|
+
export function syncOutputUsable() {
|
|
157
|
+
return caps.sync !== false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Whether `ESC[>1u` may be sent. Unknown keeps the old default (on), so a terminal
|
|
161
|
+
* that answers nothing is treated exactly as before; only a terminal that ANSWERED
|
|
162
|
+
* and did not report Kitty support turns it off. */
|
|
163
|
+
export function kittyKeysUsable() {
|
|
164
|
+
return caps.kitty !== false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Parsing
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
// `ESC ] 11 ; rgb :RRRR/GGGG/BBBB` then BEL or ST. Components are 1-4 hex digits.
|
|
172
|
+
// The `#rrggbb` spelling is also accepted: it is not xterm's, but several terminals
|
|
173
|
+
// answer OSC 11 that way and there is no reason to throw the reading away.
|
|
174
|
+
const OSC11_RE = /\x1b\]11;(?:rgb:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})|#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2}))(?:\x07|\x1b\\)/g;
|
|
175
|
+
// Kitty keyboard flags reply.
|
|
176
|
+
const KITTY_RE = /\x1b\[\?(\d+)u/g;
|
|
177
|
+
// DECRPM: mode number, then `;`, then the status, then `$y`.
|
|
178
|
+
const DECRPM_RE = /\x1b\[\?(\d+);(\d+)\$y/g;
|
|
179
|
+
// Device Attributes reply: `ESC [ ? <params> c`.
|
|
180
|
+
const DA1_RE = /\x1b\[\?([0-9;]*)c/g;
|
|
181
|
+
// Kitty GRAPHICS query reply. The value is a bitmap of what the terminal accepts;
|
|
182
|
+
// per the spec, querying action=1 asks which features are supported.
|
|
183
|
+
const KITTY_GRAPHICS_RE = /\x1b\[_G([\s\S]*?)\x1b\\/g;
|
|
184
|
+
// XTWINOPS replies are `CSI <opcode> ; <value> ... t`, and the opcode in the
|
|
185
|
+
// REPLY is not the one in the QUERY. Reporting the cell size is opcode 6 and the
|
|
186
|
+
// text area is 4; 14 and 18 are those same operations’ query forms, so a parser
|
|
187
|
+
// written for the query numbers matched nothing at all: the cell size stayed unknown, so
|
|
188
|
+
// every image refused to draw ("image could not be displayed") on a terminal that had
|
|
189
|
+
// just reported one — and the unmatched bytes fell through to the key parser.
|
|
190
|
+
const XT_CELL_RE = /\x1b\[6;(\d+);(\d+)t/g;
|
|
191
|
+
const XT_AREA_RE = /\x1b\[4;(\d+);(\d+)t/g;
|
|
192
|
+
|
|
193
|
+
/** One hex component of an OSC 11 reply, scaled to 0-255. xterm sends four digits
|
|
194
|
+
* (`ffff`), others send two (`ff`), so the scale comes from the digit count. */
|
|
195
|
+
function scaleHex(h) {
|
|
196
|
+
const n = parseInt(h, 16);
|
|
197
|
+
const max = Math.pow(16, h.length) - 1;
|
|
198
|
+
return max > 0 ? Math.round((n / max) * 255) : 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Is this background colour light? Used to pick a readable theme: the frame paints no
|
|
202
|
+
* background of its own, so the terminal's shows through every padded row. */
|
|
203
|
+
export function isLightBg(bg) {
|
|
204
|
+
if (!bg) return false;
|
|
205
|
+
// Rec. 601 luma, which matches how a colour reads far better than a plain average.
|
|
206
|
+
return (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) > 127;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Human-readable terminal name. The ENVIRONMENT is what actually identifies the
|
|
210
|
+
* program (`TERM_PROGRAM`, `WT_SESSION`, `TERM`); the DA1 level only says which VT
|
|
211
|
+
* it emulates, which several of them report identically. */
|
|
212
|
+
export function terminalFamily(env = process.env) {
|
|
213
|
+
if (env.WT_SESSION) return 'Windows Terminal';
|
|
214
|
+
if (env.TERM_PROGRAM) {
|
|
215
|
+
return /vscode/i.test(String(env.TERM_PROGRAM)) ? 'VS Code' : String(env.TERM_PROGRAM);
|
|
216
|
+
}
|
|
217
|
+
const term = String(env.TERM || '');
|
|
218
|
+
if (!term) return null;
|
|
219
|
+
if (/kitty/i.test(term)) return 'kitty';
|
|
220
|
+
if (/wezterm/i.test(term)) return 'WezTerm';
|
|
221
|
+
if (/ghostty/i.test(term)) return 'Ghostty';
|
|
222
|
+
if (/^screen/i.test(term)) return 'screen';
|
|
223
|
+
if (/^tmux/i.test(term)) return 'tmux';
|
|
224
|
+
if (/^xterm/i.test(term)) return 'xterm';
|
|
225
|
+
return term;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Prefixes of the replies we are waiting for. Used to trim a dangling partial reply
|
|
229
|
+
* from the bytes handed back to the key parser, so half an escape sequence does not
|
|
230
|
+
* become garbage input. */
|
|
231
|
+
const REPLY_PREFIXES = ['\x1b]11;', '\x1b[?', '\x1b[_G'];
|
|
232
|
+
|
|
233
|
+
/** Longest suffix of `buf` that could still grow into one of the replies. */
|
|
234
|
+
function danglingReplyLen(buf) {
|
|
235
|
+
let best = 0;
|
|
236
|
+
for (const p of REPLY_PREFIXES) {
|
|
237
|
+
const max = Math.min(p.length, buf.length);
|
|
238
|
+
for (let len = max; len >= 2; len--) {
|
|
239
|
+
if (buf.endsWith(p.slice(0, len))) { if (len > best) best = len; break; }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return best;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Remove any complete probe reply from `s`.
|
|
247
|
+
*
|
|
248
|
+
* For ONE narrow case, after the probe has given up: a slow terminal answers anyway,
|
|
249
|
+
* and those bytes arrive on the keystroke stream. Without this they reach the key
|
|
250
|
+
* parser and are typed into the composer as garbage. A terminal that answered the DA1
|
|
251
|
+
* sentinel has nothing left outstanding, so its caller does not need this at all — see
|
|
252
|
+
* the `complete` flag `probeTerminal` returns.
|
|
253
|
+
*/
|
|
254
|
+
export function stripProbeReplies(s) {
|
|
255
|
+
const buf = String(s == null ? '' : s);
|
|
256
|
+
// Almost every chunk is ordinary typing; only touch the buffer when it could hold a
|
|
257
|
+
// reply at all.
|
|
258
|
+
if (buf.indexOf('\x1b') < 0) return buf;
|
|
259
|
+
const spans = [];
|
|
260
|
+
const noop = () => {};
|
|
261
|
+
takeMatches(OSC11_RE, buf, spans, noop);
|
|
262
|
+
takeMatches(KITTY_RE, buf, spans, noop);
|
|
263
|
+
takeMatches(KITTY_GRAPHICS_RE, buf, spans, noop);
|
|
264
|
+
takeMatches(XT_CELL_RE, buf, spans, noop);
|
|
265
|
+
takeMatches(XT_AREA_RE, buf, spans, noop);
|
|
266
|
+
takeMatches(DECRPM_RE, buf, spans, noop);
|
|
267
|
+
takeMatches(DA1_RE, buf, spans, noop);
|
|
268
|
+
return removeSpans(buf, spans);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Collect the matches of `re` in `buf`, recording their spans so the caller can
|
|
272
|
+
* compute what is LEFT after removing them. */
|
|
273
|
+
function takeMatches(re, buf, spans, onMatch) {
|
|
274
|
+
re.lastIndex = 0;
|
|
275
|
+
let m;
|
|
276
|
+
while ((m = re.exec(buf)) !== null) {
|
|
277
|
+
spans.push([m.index, m.index + m[0].length]);
|
|
278
|
+
onMatch(m);
|
|
279
|
+
// A zero-length match cannot happen with these patterns, but guard the loop.
|
|
280
|
+
if (m[0].length === 0) re.lastIndex++;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Everything not covered by a span. */
|
|
285
|
+
function removeSpans(buf, spans) {
|
|
286
|
+
if (!spans.length) return buf;
|
|
287
|
+
spans.sort((a, b) => a[0] - b[0]);
|
|
288
|
+
let out = '';
|
|
289
|
+
let at = 0;
|
|
290
|
+
for (const [s, e] of spans) {
|
|
291
|
+
if (s > at) out += buf.slice(at, s);
|
|
292
|
+
at = Math.max(at, e);
|
|
293
|
+
}
|
|
294
|
+
return out + buf.slice(at);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Parse whatever has arrived. Pure, so it is directly testable.
|
|
299
|
+
*
|
|
300
|
+
* @returns {{ found: object, rest: string, complete: boolean }}
|
|
301
|
+
* `found` carries only the fields a reply actually provided; `rest` is the buffer
|
|
302
|
+
* with every reply removed (the caller keeps the tail it could still use); and
|
|
303
|
+
* `complete` is true once the DA1 sentinel has been seen.
|
|
304
|
+
*/
|
|
305
|
+
export function parseProbeReplies(buf) {
|
|
306
|
+
const spans = [];
|
|
307
|
+
const found = {};
|
|
308
|
+
|
|
309
|
+
takeMatches(OSC11_RE, buf, spans, (m) => {
|
|
310
|
+
found.bg = m[4] != null
|
|
311
|
+
? { r: parseInt(m[4], 16), g: parseInt(m[5], 16), b: parseInt(m[6], 16) }
|
|
312
|
+
: { r: scaleHex(m[1]), g: scaleHex(m[2]), b: scaleHex(m[3]) };
|
|
313
|
+
});
|
|
314
|
+
takeMatches(KITTY_RE, buf, spans, () => {
|
|
315
|
+
// Any flags value, including 0, means the terminal implements the protocol: 0 is
|
|
316
|
+
// "supported, nothing enabled yet", which is exactly the state we ask from.
|
|
317
|
+
found.kitty = true;
|
|
318
|
+
});
|
|
319
|
+
takeMatches(DECRPM_RE, buf, spans, (m) => {
|
|
320
|
+
const mode = Number(m[1]);
|
|
321
|
+
const status = Number(m[2]);
|
|
322
|
+
if (mode === SYNC_OUTPUT_MODE) {
|
|
323
|
+
// 0 is "not recognised"; SET and RESET both mean the terminal knows the mode,
|
|
324
|
+
// and either state is something the wrapper can work with.
|
|
325
|
+
found.sync = status === DECRPM_SET || status === DECRPM_RESET;
|
|
326
|
+
}
|
|
327
|
+
// A mouse mode: `true` only when the terminal RECOGNISES it. Status 0 means it has never heard of
|
|
328
|
+
// the mode, and asking anyway is what leaves the application waiting for reports that never come —
|
|
329
|
+
// a UI where the keyboard works and the mouse does nothing.
|
|
330
|
+
//
|
|
331
|
+
// A mode the terminal did not answer for stays `undefined`, which the caller reads as UNKNOWN
|
|
332
|
+
// rather than as "no": silence is not a refusal, and a terminal that ignores DECRQM altogether has
|
|
333
|
+
// to keep the previous behaviour.
|
|
334
|
+
const known = status !== DECRPM_NOT_RECOGNISED;
|
|
335
|
+
if (mode === MOUSE_MODES.click) found.mouseClick = known;
|
|
336
|
+
if (mode === MOUSE_MODES.drag) found.mouseDrag = known;
|
|
337
|
+
if (mode === MOUSE_MODES.hover) found.mouseHover = known;
|
|
338
|
+
});
|
|
339
|
+
// Kitty GRAPHICS: a supported query answers `OK`; a terminal without the protocol says
|
|
340
|
+
// nothing at all. Silence is resolved in probeTerminal (a sentinel arriving means every
|
|
341
|
+
// query was answered, so silence means "no"), not here.
|
|
342
|
+
takeMatches(KITTY_GRAPHICS_RE, buf, spans, (m) => {
|
|
343
|
+
if (!/\bOK\b/.test(m[1])) return;
|
|
344
|
+
found.kittyGraphics = true;
|
|
345
|
+
});
|
|
346
|
+
// XTWINOPS: the CELL size (reported as opcode 6) and the TEXT AREA (opcode 4). Both
|
|
347
|
+
// are needed to place an image in cells rather than guessing — and a guess here is wrong
|
|
348
|
+
// on every display whose font is not square, which is most of them.
|
|
349
|
+
takeMatches(XT_CELL_RE, buf, spans, (m) => {
|
|
350
|
+
const w = Number(m[1]);
|
|
351
|
+
const h = Number(m[2]);
|
|
352
|
+
// A terminal that answers 0x0 has nothing to say — treat it as unknown rather than
|
|
353
|
+
// as a cell of zero width, which would divide by zero downstream.
|
|
354
|
+
if (w > 0 && h > 0) found.cell = { w, h };
|
|
355
|
+
});
|
|
356
|
+
takeMatches(XT_AREA_RE, buf, spans, (m) => {
|
|
357
|
+
const w = Number(m[1]);
|
|
358
|
+
const h = Number(m[2]);
|
|
359
|
+
if (w > 0 && h > 0) found.textArea = { w, h };
|
|
360
|
+
});
|
|
361
|
+
let complete = false;
|
|
362
|
+
takeMatches(DA1_RE, buf, spans, (m) => {
|
|
363
|
+
const params = m[1].split(';').map((s) => Number(s)).filter((n) => Number.isFinite(n));
|
|
364
|
+
found.da1 = params;
|
|
365
|
+
found.level = VT_LEVELS[params[0]] || (params.length ? `vt?${params[0]}` : null);
|
|
366
|
+
found.extensions = params.slice(1).map((p) => DA1_EXTENSIONS[p]).filter(Boolean);
|
|
367
|
+
// The sentinel is the LAST DA1 in the batch, and we only write one.
|
|
368
|
+
complete = true;
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const rest = removeSpans(buf, spans);
|
|
372
|
+
return { found, rest, complete };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// The probe
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Ask the terminal. Owns `stdin` for the duration.
|
|
381
|
+
*
|
|
382
|
+
* @param {object} opts
|
|
383
|
+
* @param {import('node:stream').Readable} opts.stdin Raw-mode stdin.
|
|
384
|
+
* @param {{write: Function}} opts.stdout Where the queries go.
|
|
385
|
+
* @param {number} [opts.timeoutMs]
|
|
386
|
+
* @returns {Promise<{ caps: object, rest: string }>} `rest` is everything that was
|
|
387
|
+
* NOT a reply — a keystroke typed while the probe was waiting must not be eaten.
|
|
388
|
+
*/
|
|
389
|
+
export function probeTerminal({ stdin, stdout, timeoutMs = PROBE_TIMEOUT_MS } = {}) {
|
|
390
|
+
return new Promise((resolve) => {
|
|
391
|
+
// A probe REPLACES what is known: a second run must not inherit the first one's
|
|
392
|
+
// answers, or a terminal that changed would keep stale flags.
|
|
393
|
+
caps = emptyCaps();
|
|
394
|
+
// Declared before the closures that use them: `finish` can run from the catch
|
|
395
|
+
// below, i.e. before the listener is attached.
|
|
396
|
+
const found = {};
|
|
397
|
+
let buf = '';
|
|
398
|
+
let settled = false;
|
|
399
|
+
let timer = null;
|
|
400
|
+
// Whether the DA1 sentinel ever arrived: everything asked before it is answered by
|
|
401
|
+
// then, so if it did, nothing is outstanding.
|
|
402
|
+
let sawSentinel = false;
|
|
403
|
+
|
|
404
|
+
const finish = () => {
|
|
405
|
+
if (settled) return;
|
|
406
|
+
settled = true;
|
|
407
|
+
if (timer) clearTimeout(timer);
|
|
408
|
+
try { stdin.removeListener('data', onData); } catch { /* stream may be gone */ }
|
|
409
|
+
// Anything still buffered that could be the head of a reply is a reply we never
|
|
410
|
+
// finished receiving: drop just that tail, and hand the rest back as real input.
|
|
411
|
+
const tail = danglingReplyLen(buf);
|
|
412
|
+
const rest = tail ? buf.slice(0, buf.length - tail) : buf;
|
|
413
|
+
// The environment is the only thing that actually identifies the PROGRAM; DA1
|
|
414
|
+
// only reports which VT it emulates, which several of them do identically.
|
|
415
|
+
if (!found.family) found.family = terminalFamily();
|
|
416
|
+
// A terminal that answered the DA1 SENTINEL answered everything asked before it,
|
|
417
|
+
// in order — so a query it left unanswered is one it does not implement. For
|
|
418
|
+
// Kitty that inference is load-bearing: the reply is mandatory when the protocol
|
|
419
|
+
// is supported, so silence means NO, and sending `>1u` anyway is exactly what
|
|
420
|
+
// mangles input. Only inferred when the sentinel arrived; without it, unknown.
|
|
421
|
+
if (sawSentinel && found.kitty === undefined) found.kitty = false;
|
|
422
|
+
// THE SAME INFERENCE FOR THE MOUSE MODES, and making it is the point of this change. A terminal
|
|
423
|
+
// that answers the sentinel and then says nothing about `?1003` does NOT implement it: DECRQM is
|
|
424
|
+
// mandatory for a mode the terminal knows, so silence in that position is a refusal.
|
|
425
|
+
//
|
|
426
|
+
// Without this, an unsupported mode was read as "unknown" and enabled anyway — the sequences went
|
|
427
|
+
// out, the terminal ignored them, and the mouse was dead with nothing to explain it. Leaving it
|
|
428
|
+
// unknown is right only when the sentinel never arrived, where the silence means the terminal does
|
|
429
|
+
// not speak DECRQM at all rather than that it declined.
|
|
430
|
+
if (sawSentinel) {
|
|
431
|
+
if (found.mouseClick === undefined) found.mouseClick = false;
|
|
432
|
+
if (found.mouseDrag === undefined) found.mouseDrag = false;
|
|
433
|
+
if (found.mouseHover === undefined) found.mouseHover = false;
|
|
434
|
+
}
|
|
435
|
+
resolve({ caps: setTerminalCaps(found), rest, complete: sawSentinel });
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const onData = (chunk) => {
|
|
439
|
+
buf += String(chunk);
|
|
440
|
+
const parsed = parseProbeReplies(buf);
|
|
441
|
+
Object.assign(found, parsed.found);
|
|
442
|
+
buf = parsed.rest;
|
|
443
|
+
if (parsed.complete) { sawSentinel = true; finish(); }
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
stdin.setEncoding?.('utf8');
|
|
448
|
+
stdin.on('data', onData);
|
|
449
|
+
} catch {
|
|
450
|
+
// No usable stdin: report "nothing learned" rather than hanging.
|
|
451
|
+
finish();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
timer = setTimeout(finish, timeoutMs);
|
|
455
|
+
if (timer.unref) timer.unref();
|
|
456
|
+
try { stdout.write(PROBE_QUERY); } catch { finish(); }
|
|
457
|
+
});
|
|
411
458
|
}
|