@cplieger/web-terminal-engine 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "exports": {
5
5
  ".": "./src/index.ts"
6
6
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "typecheck": "tsgo -p tsconfig.json",
package/src/connection.ts CHANGED
@@ -643,6 +643,7 @@ export function connect(): void {
643
643
  msg.applicationKeypad,
644
644
  msg.reverseVideo,
645
645
  msg.mousePixels,
646
+ msg.keyboardFlags,
646
647
  );
647
648
  if (typeof msg.inputAck === "number") {
648
649
  applyAck(st, msg.inputAck);
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) and DECKPAM (application keypad). Passed explicitly so the shared
29
- * input maps against the active tab's modes and so the mapping is testable
30
- * without mutating global state. The `modes` module namespace satisfies this
31
- * structurally; a tabbed shell passes its active session's modes.
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
- opts.send(ESC);
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
+ }
@@ -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
  }
@@ -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 = 2;
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;