@cplieger/web-terminal-engine 2.1.0 → 2.2.1
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/jsr.json +1 -1
- package/package.json +1 -1
- package/src/connection.ts +1 -0
- package/src/keyboard.ts +247 -7
- package/src/modes.ts +17 -0
- package/src/render.ts +48 -5
- package/src/status-stream.ts +6 -0
- package/src/types.ts +7 -0
- package/src/wire-binary.ts +10 -1
package/jsr.json
CHANGED
package/package.json
CHANGED
package/src/connection.ts
CHANGED
package/src/keyboard.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// vt screen tracks both bracketed paste (?2004) and DECCKM (?1) and
|
|
15
15
|
// emits a modes frame whenever they change.
|
|
16
16
|
|
|
17
|
-
import { isApplicationCursor, isBracketedPaste } from "./modes.js";
|
|
17
|
+
import { getKeyboardFlags, isApplicationCursor, isBracketedPaste } from "./modes.js";
|
|
18
18
|
|
|
19
19
|
/** Result of mapping a keyboard event. */
|
|
20
20
|
export type KeyboardResult =
|
|
@@ -25,14 +25,21 @@ export type KeyboardResult =
|
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* KeyboardModes is the mode state `mapKeyboardEvent` reads: DECCKM (application
|
|
28
|
-
* cursor keys)
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* cursor keys), DECKPAM (application keypad), and the kitty keyboard
|
|
29
|
+
* progressive-enhancement flags. Passed explicitly so the shared input maps
|
|
30
|
+
* against the active tab's modes and so the mapping is testable without mutating
|
|
31
|
+
* global state. The `modes` module namespace satisfies this structurally; a
|
|
32
|
+
* tabbed shell passes its active session's modes.
|
|
32
33
|
*/
|
|
33
34
|
export interface KeyboardModes {
|
|
34
35
|
isApplicationCursor: () => boolean;
|
|
35
36
|
isApplicationKeypad: () => boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Active kitty keyboard flags (bit0 disambiguate; higher bits reserved). When
|
|
39
|
+
* the disambiguate bit is set, non-text keys are encoded as kitty CSI-u
|
|
40
|
+
* sequences instead of the legacy encodings.
|
|
41
|
+
*/
|
|
42
|
+
getKeyboardFlags: () => number;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
const ESC = "\x1b";
|
|
@@ -126,6 +133,214 @@ const KEYPAD_SS3: Record<string, string | undefined> = {
|
|
|
126
133
|
Enter: "M",
|
|
127
134
|
};
|
|
128
135
|
|
|
136
|
+
// -- Kitty keyboard protocol (progressive enhancement) ----------------------
|
|
137
|
+
// When the server reports an active kitty flag (modes.getKeyboardFlags), keys
|
|
138
|
+
// that do NOT produce text are encoded per the kitty protocol instead of the
|
|
139
|
+
// legacy encodings, so an app that enabled the protocol (e.g. Codex via
|
|
140
|
+
// crossterm) gets unambiguous key events. We honor the disambiguate flag (0x1)
|
|
141
|
+
// — report-event-types (0x2) / report-alternate-keys (0x4) / report-all (0x8) /
|
|
142
|
+
// text (0x10) are masked off server-side, so the CSI ?u query reports only 0x1
|
|
143
|
+
// and the encoder never has to emit them.
|
|
144
|
+
//
|
|
145
|
+
// Under disambiguate, text-producing keys still flow through the hidden
|
|
146
|
+
// textarea as text (this encoder returns "ignore" for them), matching both the
|
|
147
|
+
// spec (text keys stay text under 0x1) and our IME/composition model. Only
|
|
148
|
+
// Escape, ctrl/alt/meta combinations, and functional keys are re-encoded.
|
|
149
|
+
|
|
150
|
+
/** Kitty progressive-enhancement flag bits (mirror vt/kitty.go). */
|
|
151
|
+
const KITTY_DISAMBIGUATE = 1;
|
|
152
|
+
|
|
153
|
+
// Functional keys encoded as CSI 1;{mod}{letter} (the 1;{mod} is omitted when
|
|
154
|
+
// unmodified). Under kitty these use the CSI form (legacy uses SS3 for F1-F4)
|
|
155
|
+
// and even under DECCKM. F3 is deliberately absent — it has no letter form in
|
|
156
|
+
// the kitty table (CSI R collides with the Cursor Position Report), so it goes
|
|
157
|
+
// in KITTY_TILDE as 13 instead. Do not add F3 here.
|
|
158
|
+
const KITTY_LETTER: Record<string, string | undefined> = {
|
|
159
|
+
ArrowUp: "A",
|
|
160
|
+
ArrowDown: "B",
|
|
161
|
+
ArrowRight: "C",
|
|
162
|
+
ArrowLeft: "D",
|
|
163
|
+
Home: "H",
|
|
164
|
+
End: "F",
|
|
165
|
+
F1: "P",
|
|
166
|
+
F2: "Q",
|
|
167
|
+
F4: "S",
|
|
168
|
+
};
|
|
169
|
+
// Functional keys encoded as CSI {num};{mod}~ (the ;{mod} omitted when unmodified).
|
|
170
|
+
const KITTY_TILDE: Record<string, number | undefined> = {
|
|
171
|
+
Insert: 2,
|
|
172
|
+
Delete: 3,
|
|
173
|
+
PageUp: 5,
|
|
174
|
+
PageDown: 6,
|
|
175
|
+
F3: 13, // no letter form — CSI R conflicts with the cursor-position report
|
|
176
|
+
F5: 15,
|
|
177
|
+
F6: 17,
|
|
178
|
+
F7: 18,
|
|
179
|
+
F8: 19,
|
|
180
|
+
F9: 20,
|
|
181
|
+
F10: 21,
|
|
182
|
+
F11: 23,
|
|
183
|
+
F12: 24,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// US-layout base (unshifted) codepoints for the non-alphanumeric physical keys,
|
|
187
|
+
// keyed by ev.code. Used so a ctrl/alt+shift+symbol event reports the UNSHIFTED
|
|
188
|
+
// key code the spec mandates (e.g. Ctrl+Shift+; is CSI 59;6u using ';'=59, not
|
|
189
|
+
// ':'=58). Letters/digits are handled separately from ev.code; other keys fall
|
|
190
|
+
// back to the case-folded ev.key.
|
|
191
|
+
const KITTY_CODE_BASE: Record<string, number | undefined> = {
|
|
192
|
+
Semicolon: 59, // ;
|
|
193
|
+
Equal: 61, // =
|
|
194
|
+
Comma: 44, // ,
|
|
195
|
+
Minus: 45, // -
|
|
196
|
+
Period: 46, // .
|
|
197
|
+
Slash: 47, // /
|
|
198
|
+
Backquote: 96, // `
|
|
199
|
+
BracketLeft: 91, // [
|
|
200
|
+
Backslash: 92, // \
|
|
201
|
+
BracketRight: 93, // ]
|
|
202
|
+
Quote: 39, // '
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Keypad NON-TEXT keys under disambiguate, keyed by ev.key (the key's current
|
|
206
|
+
// function — NumLock flips a numpad key between its text digit, which stays text
|
|
207
|
+
// under 0x1, and these navigation functions). ev.code identifies it as a keypad
|
|
208
|
+
// key. Per the spec these get dedicated KP_* codes (57414-57427) so an app can
|
|
209
|
+
// tell them apart from the main navigation keys. NumpadEnter is included as
|
|
210
|
+
// KP_ENTER (57414) — it is a distinct key, NOT the legacy-byte main Enter.
|
|
211
|
+
// KP_BEGIN (Numpad5, NumLock off -> "Clear") is emitted as 57427 in the `u`
|
|
212
|
+
// form: both kitty and crossterm decode `CSI 57427 u`, whereas the spec's
|
|
213
|
+
// alternate `CSI E` letter form is kitty-only (crossterm/ratatui can't parse it).
|
|
214
|
+
// The KP_ DIGIT/operator codes (57399-57416) are deliberately absent — they are
|
|
215
|
+
// a report-all (0x8) feature; under 0x1 a NumLock-on numpad digit is plain text.
|
|
216
|
+
const KITTY_KEYPAD: Record<string, number | undefined> = {
|
|
217
|
+
ArrowLeft: 57417, // KP_LEFT
|
|
218
|
+
ArrowRight: 57418, // KP_RIGHT
|
|
219
|
+
ArrowUp: 57419, // KP_UP
|
|
220
|
+
ArrowDown: 57420, // KP_DOWN
|
|
221
|
+
PageUp: 57421, // KP_PAGE_UP
|
|
222
|
+
PageDown: 57422, // KP_PAGE_DOWN
|
|
223
|
+
Home: 57423, // KP_HOME
|
|
224
|
+
End: 57424, // KP_END
|
|
225
|
+
Insert: 57425, // KP_INSERT
|
|
226
|
+
Delete: 57426, // KP_DELETE
|
|
227
|
+
Clear: 57427, // KP_BEGIN
|
|
228
|
+
Enter: 57414, // KP_ENTER
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/** CSI 1;{mod}{letter}, omitting `1;{mod}` when there are no modifiers. */
|
|
232
|
+
function kittyLetterSeq(letter: string, ev: KeyboardEvent): string {
|
|
233
|
+
const m = modifiersDigit(ev);
|
|
234
|
+
return m === 1 ? `${ESC}[${letter}` : `${ESC}[1;${m}${letter}`;
|
|
235
|
+
}
|
|
236
|
+
/** CSI {num};{mod}~, omitting `;{mod}` when there are no modifiers. */
|
|
237
|
+
function kittyTildeSeq(num: number, ev: KeyboardEvent): string {
|
|
238
|
+
const m = modifiersDigit(ev);
|
|
239
|
+
return m === 1 ? `${ESC}[${num}~` : `${ESC}[${num};${m}~`;
|
|
240
|
+
}
|
|
241
|
+
/** CSI {num};{mod}u, omitting `;{mod}` when there are no modifiers. */
|
|
242
|
+
function kittyUSeq(num: number, ev: KeyboardEvent): string {
|
|
243
|
+
const m = modifiersDigit(ev);
|
|
244
|
+
return m === 1 ? `${ESC}[${num}u` : `${ESC}[${num};${m}u`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** True when any modifier (ctrl / alt / meta / shift) is held. */
|
|
248
|
+
function anyModifier(ev: KeyboardEvent): boolean {
|
|
249
|
+
return ev.ctrlKey || ev.altKey || ev.metaKey || ev.shiftKey;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The kitty "unicode-key-code": the UNSHIFTED (lower-case / base-layout)
|
|
254
|
+
* codepoint of the key. Letters and digits are read from ev.code (the physical
|
|
255
|
+
* key), so a composed Alt/Option character on macOS still maps to its base key;
|
|
256
|
+
* other single-character keys fall back to the case-folded ev.key.
|
|
257
|
+
*/
|
|
258
|
+
function kittyKeyCodepoint(ev: KeyboardEvent): number {
|
|
259
|
+
const code = ev.code;
|
|
260
|
+
if (code.length === 4 && code.startsWith("Key")) {
|
|
261
|
+
return code.charCodeAt(3) + 32; // "KeyA".."KeyZ" -> 'a'..'z'
|
|
262
|
+
}
|
|
263
|
+
if (code.length === 6 && code.startsWith("Digit")) {
|
|
264
|
+
return code.charCodeAt(5); // "Digit0".."Digit9" -> '0'..'9'
|
|
265
|
+
}
|
|
266
|
+
const base = KITTY_CODE_BASE[code];
|
|
267
|
+
if (base !== undefined) {
|
|
268
|
+
return base; // symbol key -> unshifted base codepoint (spec: use the un-shifted key)
|
|
269
|
+
}
|
|
270
|
+
const k = ev.key;
|
|
271
|
+
return k.length === 1 ? (k.toLowerCase().codePointAt(0) ?? 0) : 0;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Encode a keydown under the kitty disambiguate flag (0x1). Returns "ignore"
|
|
276
|
+
* for text-producing keys so the hidden textarea still handles typing/IME.
|
|
277
|
+
* Called only when the flag is active; the modifier-only and local-scroll cases
|
|
278
|
+
* are handled by the shared preamble in mapKeyboardEvent (which skips its
|
|
279
|
+
* application-keypad SS3 branch under this flag, so keypad keys reach here).
|
|
280
|
+
*/
|
|
281
|
+
function encodeKittyDisambiguate(ev: KeyboardEvent): KeyboardResult {
|
|
282
|
+
const key = ev.key;
|
|
283
|
+
|
|
284
|
+
// Keypad NON-TEXT keys (NumLock-off navigation + NumpadEnter) get dedicated
|
|
285
|
+
// KP_* codes so an app can distinguish them from the main keys. Detected by
|
|
286
|
+
// ev.code (Numpad*); the specific code is chosen by ev.key. Text keypad keys
|
|
287
|
+
// (NumLock-on digits/operators) are NOT intercepted — they fall through to the
|
|
288
|
+
// printable path below (text when unmodified; the ASCII-codepoint CSI-u form
|
|
289
|
+
// when a ctrl/alt/meta modifier suppresses the text), because KP_ digit codes
|
|
290
|
+
// are a report-all (0x8) feature we don't honor.
|
|
291
|
+
if (ev.code.startsWith("Numpad")) {
|
|
292
|
+
const kp = KITTY_KEYPAD[key];
|
|
293
|
+
if (kp !== undefined) {
|
|
294
|
+
return { kind: "send", bytes: kittyUSeq(kp, ev) };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const letter = KITTY_LETTER[key];
|
|
299
|
+
if (letter !== undefined) {
|
|
300
|
+
return { kind: "send", bytes: kittyLetterSeq(letter, ev) };
|
|
301
|
+
}
|
|
302
|
+
const tnum = KITTY_TILDE[key];
|
|
303
|
+
if (tnum !== undefined) {
|
|
304
|
+
return { kind: "send", bytes: kittyTildeSeq(tnum, ev) };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Enter / Tab / Backspace keep their legacy bytes when UNMODIFIED (so a user
|
|
308
|
+
// can still type `reset` if a program leaves the mode on after crashing);
|
|
309
|
+
// with modifiers they take the disambiguated CSI-u form.
|
|
310
|
+
if (key === "Enter") {
|
|
311
|
+
return { kind: "send", bytes: anyModifier(ev) ? kittyUSeq(13, ev) : "\r" };
|
|
312
|
+
}
|
|
313
|
+
if (key === "Tab") {
|
|
314
|
+
return { kind: "send", bytes: anyModifier(ev) ? kittyUSeq(9, ev) : "\t" };
|
|
315
|
+
}
|
|
316
|
+
if (key === "Backspace") {
|
|
317
|
+
return { kind: "send", bytes: anyModifier(ev) ? kittyUSeq(127, ev) : DEL };
|
|
318
|
+
}
|
|
319
|
+
if (key === "Escape") {
|
|
320
|
+
return { kind: "send", bytes: kittyUSeq(27, ev) };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Single-character keys: text stays text (deferred to the textarea) UNLESS a
|
|
324
|
+
// ctrl/alt/meta modifier is held, in which case the legacy encoding is
|
|
325
|
+
// ambiguous (Ctrl+I == Tab, Alt+x == ESC x, …) so we send the CSI-u form with
|
|
326
|
+
// the unshifted codepoint.
|
|
327
|
+
if (key.length === 1) {
|
|
328
|
+
if (ev.ctrlKey || ev.altKey || ev.metaKey) {
|
|
329
|
+
const cp = kittyKeyCodepoint(ev);
|
|
330
|
+
if (cp > 0) {
|
|
331
|
+
return { kind: "send", bytes: kittyUSeq(cp, ev) };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return { kind: "ignore" };
|
|
335
|
+
}
|
|
336
|
+
return { kind: "ignore" };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** True when the kitty disambiguate flag is active in the global mode state. */
|
|
340
|
+
function kittyDisambiguateActive(): boolean {
|
|
341
|
+
return (getKeyboardFlags() & KITTY_DISAMBIGUATE) !== 0;
|
|
342
|
+
}
|
|
343
|
+
|
|
129
344
|
/**
|
|
130
345
|
* mapKeyboardEvent converts a KeyboardEvent into the terminal action
|
|
131
346
|
* to take. Returns "ignore" when the event is purely a modifier press
|
|
@@ -156,6 +371,7 @@ export function mapKeyboardEvent(ev: KeyboardEvent, modes: KeyboardModes): Keybo
|
|
|
156
371
|
// NumpadSubtract, NumpadAdd, NumpadMultiply, NumpadDivide, NumpadEnter).
|
|
157
372
|
if (
|
|
158
373
|
modes.isApplicationKeypad() &&
|
|
374
|
+
(modes.getKeyboardFlags() & KITTY_DISAMBIGUATE) === 0 &&
|
|
159
375
|
!ev.ctrlKey &&
|
|
160
376
|
!ev.altKey &&
|
|
161
377
|
!ev.metaKey &&
|
|
@@ -188,6 +404,15 @@ export function mapKeyboardEvent(ev: KeyboardEvent, modes: KeyboardModes): Keybo
|
|
|
188
404
|
return { kind: "scroll-down" };
|
|
189
405
|
}
|
|
190
406
|
|
|
407
|
+
// Kitty keyboard protocol: once the app enables the disambiguate flag, encode
|
|
408
|
+
// non-text keys as CSI-u sequences (Esc, ctrl/alt combos, functional keys);
|
|
409
|
+
// text keys still defer to the textarea. Placed after the shared preamble
|
|
410
|
+
// (modifier-only, application keypad, local scroll) so those behave
|
|
411
|
+
// identically in both modes.
|
|
412
|
+
if ((modes.getKeyboardFlags() & KITTY_DISAMBIGUATE) !== 0) {
|
|
413
|
+
return encodeKittyDisambiguate(ev);
|
|
414
|
+
}
|
|
415
|
+
|
|
191
416
|
// Cursor keys — CSI form with optional modifiers (CSI 1;{m}{letter}).
|
|
192
417
|
const arrow = ARROW_LETTER[ev.key];
|
|
193
418
|
if (arrow !== undefined) {
|
|
@@ -555,6 +780,11 @@ export function bindMobileToolbar(opts: BindMobileToolbarOptions): MobileToolbar
|
|
|
555
780
|
}
|
|
556
781
|
|
|
557
782
|
function arrowSeq(letter: "A" | "B" | "C" | "D"): string {
|
|
783
|
+
// Under kitty disambiguate, arrows use the CSI form regardless of DECCKM
|
|
784
|
+
// (the protocol supersedes cursor-key mode). Otherwise DECCKM chooses SS3.
|
|
785
|
+
if (kittyDisambiguateActive()) {
|
|
786
|
+
return `${ESC}[${letter}`;
|
|
787
|
+
}
|
|
558
788
|
return isApplicationCursor() ? `${ESC}O${letter}` : `${ESC}[${letter}`;
|
|
559
789
|
}
|
|
560
790
|
|
|
@@ -619,7 +849,9 @@ export function bindMobileToolbar(opts: BindMobileToolbarOptions): MobileToolbar
|
|
|
619
849
|
on(escBtn, (e) => {
|
|
620
850
|
e.preventDefault();
|
|
621
851
|
setCtrlArmed(false);
|
|
622
|
-
|
|
852
|
+
// Under kitty disambiguate, Escape is CSI 27 u (so the app never has to
|
|
853
|
+
// disambiguate a lone ESC byte from the start of an escape sequence).
|
|
854
|
+
opts.send(kittyDisambiguateActive() ? `${ESC}[27u` : ESC);
|
|
623
855
|
});
|
|
624
856
|
|
|
625
857
|
function applyStickyCtrl(text: string): string {
|
|
@@ -627,8 +859,16 @@ export function bindMobileToolbar(opts: BindMobileToolbarOptions): MobileToolbar
|
|
|
627
859
|
return text;
|
|
628
860
|
}
|
|
629
861
|
if (text.length === 1) {
|
|
630
|
-
const ctrl = ctrlByteFor(text);
|
|
631
862
|
setCtrlArmed(false);
|
|
863
|
+
// Under kitty disambiguate, Ctrl+char is the CSI-u form (ctrl modifier =
|
|
864
|
+
// 5) with the unshifted codepoint, matching the physical-keyboard path.
|
|
865
|
+
if (kittyDisambiguateActive()) {
|
|
866
|
+
const cp = text.toLowerCase().codePointAt(0);
|
|
867
|
+
if (cp !== undefined) {
|
|
868
|
+
return `${ESC}[${cp};5u`;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
const ctrl = ctrlByteFor(text);
|
|
632
872
|
return ctrl ?? text;
|
|
633
873
|
}
|
|
634
874
|
// Multi-char input (paste, IME commit) — applying Ctrl to a string
|
package/src/modes.ts
CHANGED
|
@@ -23,6 +23,9 @@ let mouseSGR = false;
|
|
|
23
23
|
let mousePixels = false; // DEC 1016: report pixel coords instead of cell coords
|
|
24
24
|
let focusReporting = false;
|
|
25
25
|
let reverseVideo = false;
|
|
26
|
+
// Kitty keyboard progressive-enhancement flags (bit0 disambiguate, bit1
|
|
27
|
+
// event-types, bit2 alternate-keys). 0 = protocol disabled (legacy encoding).
|
|
28
|
+
let keyboardFlags = 0;
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* Update the cached mode state. Called by the consumer whenever the server
|
|
@@ -38,6 +41,7 @@ export function setModes(
|
|
|
38
41
|
appKeypad?: boolean,
|
|
39
42
|
revVideo?: boolean,
|
|
40
43
|
mPixels?: boolean,
|
|
44
|
+
kbdFlags?: number,
|
|
41
45
|
): void {
|
|
42
46
|
bracketedPaste = bracketed;
|
|
43
47
|
applicationCursor = appCursor;
|
|
@@ -59,6 +63,9 @@ export function setModes(
|
|
|
59
63
|
if (mPixels !== undefined) {
|
|
60
64
|
mousePixels = mPixels;
|
|
61
65
|
}
|
|
66
|
+
if (kbdFlags !== undefined) {
|
|
67
|
+
keyboardFlags = kbdFlags;
|
|
68
|
+
}
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
/** True when the server has DEC 2004 (bracketed paste) enabled. */
|
|
@@ -104,3 +111,13 @@ export function isApplicationKeypad(): boolean {
|
|
|
104
111
|
export function isReverseVideo(): boolean {
|
|
105
112
|
return reverseVideo;
|
|
106
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Kitty keyboard progressive-enhancement flags currently in effect (bit0
|
|
117
|
+
* disambiguate, bit1 report-event-types, bit2 report-alternate-keys). 0 means
|
|
118
|
+
* the protocol is disabled and keys use legacy encoding. Read by keyboard.ts to
|
|
119
|
+
* choose between legacy and kitty CSI-u encoding.
|
|
120
|
+
*/
|
|
121
|
+
export function getKeyboardFlags(): number {
|
|
122
|
+
return keyboardFlags;
|
|
123
|
+
}
|
package/src/render.ts
CHANGED
|
@@ -346,7 +346,14 @@ function linkifySpans(
|
|
|
346
346
|
a.href = match[0];
|
|
347
347
|
a.target = "_blank";
|
|
348
348
|
a.rel = "noopener noreferrer";
|
|
349
|
-
|
|
349
|
+
// Auto-detected bare URLs are tightly scoped to the matched URL text
|
|
350
|
+
// (never a padded/bordered region), so `.term-autolink` keeps a persistent
|
|
351
|
+
// underline for discoverability. OSC 8 hyperlinks (buildRowSpans below) get
|
|
352
|
+
// only `.term-link`, which the UI underlines on hover — an app can attach a
|
|
353
|
+
// single OSC 8 link to a whole region (e.g. a URL wrapping inside a table
|
|
354
|
+
// cell, where the link stays open across the cell padding/borders), and a
|
|
355
|
+
// persistent underline would then bleed across the cell/row.
|
|
356
|
+
a.className = "term-link term-autolink";
|
|
350
357
|
a.textContent = match[0];
|
|
351
358
|
// Copy inline styles from the source span
|
|
352
359
|
a.style.cssText = span.style.cssText;
|
|
@@ -364,6 +371,29 @@ function linkifySpans(
|
|
|
364
371
|
return out;
|
|
365
372
|
}
|
|
366
373
|
|
|
374
|
+
// A hyperlink run is "link text" only if it has at least one glyph that is not
|
|
375
|
+
// whitespace and not a box-drawing (U+2500–U+257F) or block-element
|
|
376
|
+
// (U+2580–U+259F) character. Terminal table structure (borders `│`, padding,
|
|
377
|
+
// empty columns, margins) is made of exactly those, and an app may keep an OSC 8
|
|
378
|
+
// hyperlink open across it while a URL wraps; such decorative runs are not
|
|
379
|
+
// anchored, so the link decoration hugs the actual text instead of bleeding
|
|
380
|
+
// across the cell/row.
|
|
381
|
+
function runHasLinkText(spans: (HTMLSpanElement | HTMLAnchorElement)[]): boolean {
|
|
382
|
+
for (const s of spans) {
|
|
383
|
+
for (const ch of s.textContent) {
|
|
384
|
+
if (/\s/.test(ch)) {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
388
|
+
if (cp >= 0x2500 && cp <= 0x259f) {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
|
|
367
397
|
// --- Build row DOM ---
|
|
368
398
|
function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HTMLAnchorElement)[] {
|
|
369
399
|
const out: (HTMLSpanElement | HTMLAnchorElement)[] = [];
|
|
@@ -509,20 +539,33 @@ function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HT
|
|
|
509
539
|
col++;
|
|
510
540
|
}
|
|
511
541
|
flush();
|
|
512
|
-
// Wrap
|
|
542
|
+
// Wrap this run's spans in an <a> when it has a hyperlink URL — but only if
|
|
543
|
+
// the run actually contains link *text*. An OSC 8 hyperlink can stay open
|
|
544
|
+
// across a whole region the app never meant as the clickable "link": a URL
|
|
545
|
+
// that wraps inside a table cell keeps the link open across the cell padding,
|
|
546
|
+
// the borders `│` and the empty adjacent column. Anchoring those decorative
|
|
547
|
+
// cells makes the link's decoration (the underline) bleed across the cell and,
|
|
548
|
+
// on wrap, the full row. Skip them so the anchor — and its underline, at rest
|
|
549
|
+
// or on hover — hugs the visible link text (they stay as plain, non-clickable
|
|
550
|
+
// spans; the wrapped URL's text runs each still carry the full href).
|
|
513
551
|
const href = run.u && /^https?:\/\//i.test(run.u) ? run.u : null;
|
|
514
|
-
|
|
552
|
+
const runSpans = out.splice(runStartIdx);
|
|
553
|
+
if (href && runHasLinkText(runSpans)) {
|
|
515
554
|
const a = document.createElement("a");
|
|
516
555
|
a.href = href;
|
|
517
556
|
a.target = "_blank";
|
|
518
557
|
a.rel = "noopener";
|
|
558
|
+
// OSC 8 app hyperlink: base `.term-link`. Auto-detected URLs additionally
|
|
559
|
+
// get `.term-autolink` (see linkifySpans).
|
|
519
560
|
a.className = "term-link";
|
|
520
|
-
// Move spans from this run into the anchor.
|
|
521
|
-
const runSpans = out.splice(runStartIdx);
|
|
522
561
|
for (const s of runSpans) {
|
|
523
562
|
a.appendChild(s);
|
|
524
563
|
}
|
|
525
564
|
out.push(a);
|
|
565
|
+
} else {
|
|
566
|
+
for (const s of runSpans) {
|
|
567
|
+
out.push(s);
|
|
568
|
+
}
|
|
526
569
|
}
|
|
527
570
|
}
|
|
528
571
|
if (cursorAt >= 0 && col <= cursorAt) {
|
package/src/status-stream.ts
CHANGED
|
@@ -14,6 +14,12 @@ export interface SessionStatus {
|
|
|
14
14
|
readonly createdAt: string;
|
|
15
15
|
/** true when the session is gone (closed or reaped); the consumer drops it. */
|
|
16
16
|
readonly removed?: boolean;
|
|
17
|
+
/** true once the session has emitted a genuine activity signal — OSC 9;4
|
|
18
|
+
* progress (kiro-cli, Claude Code, …) or a classified OSC 9 notification.
|
|
19
|
+
* Sticky for the session's life. Consumers reveal the per-tab activity dot
|
|
20
|
+
* only when this is set; a program that emits no OSC 9 signal (a plain shell)
|
|
21
|
+
* keeps its tab dot hidden. */
|
|
22
|
+
readonly reportsActivity?: boolean;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
/** StatusStreamCallbacks are the consumer's hooks. Declared as function-typed
|
package/src/types.ts
CHANGED
|
@@ -133,6 +133,13 @@ export interface ModesMessage {
|
|
|
133
133
|
mousePixels: boolean;
|
|
134
134
|
/** Mouse tracking: 0=off, 1000=normal, 1002=button-event, 1003=any-event. */
|
|
135
135
|
mouseMode: number;
|
|
136
|
+
/**
|
|
137
|
+
* Kitty keyboard progressive-enhancement flags (bit0 disambiguate, bit1
|
|
138
|
+
* report-event-types, bit2 report-alternate-keys); 0 means the protocol is
|
|
139
|
+
* disabled and keys use legacy encoding. The keyboard encoder emits kitty
|
|
140
|
+
* CSI-u sequences for the set flags.
|
|
141
|
+
*/
|
|
142
|
+
keyboardFlags: number;
|
|
136
143
|
/** Server-confirmed bytesReceived for the input ACK protocol. */
|
|
137
144
|
inputAck?: number;
|
|
138
145
|
}
|
package/src/wire-binary.ts
CHANGED
|
@@ -29,8 +29,12 @@ const MSG_CLIPBOARD = 6;
|
|
|
29
29
|
* (e.g. a stale cached bundle after a breaking change) and warn rather than
|
|
30
30
|
* silently mis-decode. Bump on any breaking change to the binary frame layout
|
|
31
31
|
* or control-message shape. Mirrors `wireProtocolVersion` in wire_binary.go.
|
|
32
|
+
*
|
|
33
|
+
* v3: the modes frame gained a trailing kbdFlags byte (kitty keyboard
|
|
34
|
+
* protocol). Decoding is tolerant of a pre-v3 server (kbdFlags defaults to 0
|
|
35
|
+
* when the byte is absent), so the bump only drives the mismatch warning.
|
|
32
36
|
*/
|
|
33
|
-
export const WIRE_PROTOCOL_VERSION =
|
|
37
|
+
export const WIRE_PROTOCOL_VERSION = 3;
|
|
34
38
|
const MODE_FLAG_BRACKETED_PASTE = 1;
|
|
35
39
|
const MODE_FLAG_APP_CURSOR_KEYS = 2;
|
|
36
40
|
const MODE_FLAG_MOUSE_SGR = 4;
|
|
@@ -220,6 +224,10 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
|
|
|
220
224
|
if (msgType === MSG_MODES) {
|
|
221
225
|
const flags = c.u8();
|
|
222
226
|
const mouseMode = c.u16();
|
|
227
|
+
// kbdFlags (kitty keyboard protocol) was appended in wire protocol v3.
|
|
228
|
+
// Read it only if the frame carries it, so a pre-v3 server's shorter frame
|
|
229
|
+
// decodes with the protocol disabled rather than reading past the buffer.
|
|
230
|
+
const keyboardFlags = c.off < c.bytes.length ? c.u8() : 0;
|
|
223
231
|
const msg: ModesMessage = {
|
|
224
232
|
type: "modes",
|
|
225
233
|
bracketedPaste: (flags & MODE_FLAG_BRACKETED_PASTE) !== 0,
|
|
@@ -230,6 +238,7 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
|
|
|
230
238
|
reverseVideo: (flags & MODE_FLAG_REVERSE_VIDEO) !== 0,
|
|
231
239
|
mousePixels: (flags & MODE_FLAG_MOUSE_PIXELS) !== 0,
|
|
232
240
|
mouseMode,
|
|
241
|
+
keyboardFlags,
|
|
233
242
|
inputAck,
|
|
234
243
|
};
|
|
235
244
|
return msg;
|