buninu 0.4.21 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 - 2026-10-21
4
+
5
+ - Sync jsgotty to 1.2.0:
6
+ * Add opt-in Kitty keyboard support for disambiguated shortcuts, including
7
+ Super, and press/repeat/release events. Support flags 1, 2 and 8, mode
8
+ queries, and independent mode stacks for normal and alternate screens;
9
+ legacy keyboard encoding remains the default.
10
+ * Recover Super state on Android WebViews that deliver Meta key events but
11
+ omit the modifier state. Handle Android Alt shortcuts without accent
12
+ dead-key conversion, including recognizable keyup-only events.
13
+ * Preserve Alt on arrow, Insert, PageUp and PageDown shortcuts, and encode
14
+ Ctrl+/ as the conventional US control character.
15
+ * Add a manual keyboard tester with readable, colored shortcut names and
16
+ raw sequences; restore keyboard and raw modes when it exits.
17
+ * Allow local Kitty graphics consumers to request binary image data,
18
+ avoiding a base64 conversion; browser clients retain base64 by default.
19
+ - Sync bunmsh to 0.4.1: use the existing linker-aware shell launcher for the
20
+ awk fallback's subprocess commands instead of bypassing linker handling.
21
+
3
22
  ## 0.4.21 - 2026-10-15
4
23
 
5
24
  - Sync jsgotty to 1.1.16: Turn on pixel report
@@ -2,6 +2,10 @@
2
2
 
3
3
  All notable user-visible changes to bunmsh are documented here.
4
4
 
5
+ ## [0.4.1] - 2026-10-13
6
+
7
+ - Fixed awk bypassing linker protection
8
+
5
9
  ## [0.4.0] - 2026-10-13
6
10
 
7
11
  ### Added and fixed
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunmsh",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Cross-platform command shell for Bun JavaScript",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -3236,7 +3236,7 @@ async function runAwkFallback(argv, state, input) {
3236
3236
  try {
3237
3237
  const file=resolvePath(directory,"state.json");
3238
3238
  writeFileSync(file,pipelineBuiltinState(state).env[PIPELINE_STATE_ENV],{mode:0o600});
3239
- const p=Bun.spawnSync([process.execPath,BUNMSH_ENTRY,"-c",command],{
3239
+ const p=Bun.spawnSync(pipelineCompoundArgv(command),{
3240
3240
  cwd:nativePath(state.cwd),env:{...externalEnvironment(state),[PIPELINE_STATE_ENV]:`@file:${file}`},
3241
3241
  stdin:bytes(text),stdout:"pipe",stderr:"pipe"});
3242
3242
  if(p.error)throw p.error;
@@ -3,6 +3,11 @@
3
3
  [中文 README](README.md)
4
4
 
5
5
  ## News
6
+
7
+ ### 2026/10/21
8
+
9
+ - Added [Kitty keyboard](#kitty-keyboard) support for distinct modifier combinations, including Super, and press/repeat/release events.
10
+
6
11
  ### 2026/08/16
7
12
  - Fixed the disconnected-screen `Reconnect` button not being clickable in WebGL mode.
8
13
  - The `Reconnect` UI now stays above the WebGL canvas and receives mouse and touch events correctly; Kitty image handling no longer intercepts right-click / long-press for entering a reconnect token or PID.
@@ -238,6 +243,41 @@ The `jsgotty` server has two connection modes:
238
243
  - On disconnect, the overlay provides a `Reconnect` button for immediate manual retry.
239
244
  - Right-click / long-press on that button lets you enter a custom reconnect token or PID.
240
245
 
246
+ ## Kitty keyboard
247
+
248
+ The web terminal supports the shortcut-related parts of the [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/), separately from Kitty graphics. Legacy keyboard encoding remains the default. A terminal application can opt in:
249
+
250
+ ```sh
251
+ printf '\033[>11u' # 1: disambiguation + 2: repeat/release + 8: all keys
252
+ printf '\033[<u' # restore the previous mode when the application exits
253
+ ```
254
+
255
+ The receiving application must decode these sequences and restore the mode in its exit/finally handler; do not leave an ordinary shell in this mode. Negotiation supports `CSI ? u`, `CSI = flags ; mode u`, push and pop. Normal and alternate screens have independent stacks, limited to 32 entries. Reconnection, full reset and soft reset clear the state. Use flag `1` for unambiguous shortcuts, or `11` for modifier keys and press/repeat/release reporting.
256
+
257
+ DOM Meta maps to Kitty **Super (bit 8)**, not the separate Meta bit (32). Super+X is `CSI 120;9u`; Ctrl+Shift+Super+X is `CSI 120;14u`. Release events add `:3` when enabled.
258
+
259
+ Supported flags are **1, 2 and 8**; alternate keys (4) and associated text (16) are not implemented or reflected in the negotiated flags. IME composition/text commits and paste retain their existing paths; physical keys and releases cannot be inferred from committed text. Letters, digits, punctuation, navigation, Tab/Enter, function keys, keypad keys and standard Kitty media keys retain distinct modifiers. OS/browser-reserved shortcuts may never reach the page. Keys without standard Kitty codes, such as brightness and microphone mute, are not assigned private encodings.
260
+
261
+ This implements **jsgotty → PTY** encoding. Controlling Hyprland/Omarchy still requires Casty or another receiver to enable/decode Kitty keyboard and forward Super and release events. It does not complete the remote desktop input chain or change the mouse protocol.
262
+
263
+ Reload the jsgotty page after updating. Run the manual tester inside its terminal:
264
+
265
+ ```sh
266
+ bun tests/manual/kitty-keyboard.js
267
+ ```
268
+
269
+ It prints negotiated flags, raw sequences, modifiers and event types, exits after 60 seconds or unmodified Escape/q/Ctrl-C, and restores the previous keyboard/raw modes. An optional real bundled-xterm test uses an already installed browser:
270
+
271
+ ```sh
272
+ CHROMIUM_HEADLESS=/path/to/chromium-headless-shell node --test tests/kitty-keyboard-browser.test.js
273
+ ```
274
+
275
+ ## Android Keyboards
276
+
277
+ For Android WebViews that deliver Meta down/up but lose `metaKey`, jsgotty tracks both Meta keys inside its own input handler. Blur or page hiding clears held state and sends releases for reported keys when event reporting is enabled.
278
+
279
+ In the Android terminal, Alt with letters, digits, punctuation, or space sends terminal Alt sequences based on physical key positions (US layout, including Shift), bypassing accent dead-key handling. Alt-E/I/N/U therefore no longer compose characters such as é, ê, ñ, and ü; input methods and paste remain available. When WebView omits `code`, recognized legacy `keyCode` values provide a fallback. Dead keys delivered only as `keyup` send the shortcut on release, without duplicating a previously handled `keydown`. Active composition, Ctrl, Meta, AltGr, and unidentified keys retain their existing handling. This applies only inside the jsgotty terminal and does not modify Android system keyboard settings.
280
+
241
281
  ## Tested platforms
242
282
  - Windows x64: Windows 11
243
283
  - Linux x64: CachyOS
@@ -3,6 +3,11 @@
3
3
  [English README](README.en.md)
4
4
 
5
5
  ## News
6
+
7
+ ### 2026/10/21
8
+
9
+ - 新增 [Kitty keyboard](#kitty-keyboard) 支援,可區分 Super 等修飾鍵組合,並回報按下、重複與放開事件。
10
+
6
11
  ### 2026/08/16
7
12
  - 修正 WebGL 模式下斷線畫面的 `Reconnect` 按鈕無法點擊。
8
13
  - `Reconnect` 介面現在會正確位於 WebGL canvas 上方並接收滑鼠與觸控事件;右鍵/長按輸入 reconnect token 或 PID 也不會被 Kitty 圖片事件攔截。
@@ -238,6 +243,41 @@ bun gotty.js --client -k <token|pid> [target]
238
243
  - 斷線時會提供 `Reconnect` 按鈕,可手動立即重試。
239
244
  - 該按鈕右鍵/長按可輸入自訂 reconnect token 或 PID。
240
245
 
246
+ ## Kitty keyboard
247
+
248
+ 網頁終端現在支援 [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) 的快捷鍵相關功能,與 Kitty 圖片協定分開。預設仍是原本的鍵盤編碼;由終端內的程式選擇開啟:
249
+
250
+ ```sh
251
+ printf '\033[>11u' # 1:消除歧義 + 2:重複/放開事件 + 8:回報所有按鍵
252
+ printf '\033[<u' # 程式退出時還原上一層模式
253
+ ```
254
+
255
+ 程式必須能解析新編碼,並在 finally/退出處理中還原模式,不建議直接在一般 shell 長期開啟。支援 `CSI ? u` 查詢、`CSI = flags ; mode u` 設定/加上/移除旗標、`CSI > flags u` push 與 `CSI < count u` pop。主畫面與 alternate screen 各有獨立堆疊(上限 32 層);重連、完整重設或 soft reset 會清除狀態。只需要無歧義快捷鍵時可用旗標 `1`;需要獨立修飾鍵及所有按鍵的 down/repeat/up 時使用 `11`。
256
+
257
+ 網頁的 Meta 對應 Kitty 的 **Super(bit 8)**,不是協定中的另一個 Meta(bit 32)。例如 Super+X 是 `CSI 120;9u`,Ctrl+Shift+Super+X 是 `CSI 120;14u`;啟用事件回報後,放開會帶 `:3`。
258
+
259
+ 目前支援旗標 **1、2、8**;尚未實作替代鍵位(4)與附帶文字(16),查詢只回報實際啟用的旗標。IME 組字/文字提交與貼上保留既有路徑,不保證能回報實體按鍵或放開事件。字母、數字、標點、方向鍵、Tab、Enter、功能鍵、數字鍵盤與有標準 Kitty 編碼的媒體鍵可區分修飾組合;系統/瀏覽器先攔截的快捷鍵不會到達網頁,亮度、麥克風靜音等沒有標準 Kitty 鍵碼的鍵也不會擅自編成私有序列。
260
+
261
+ 這完成的是 **jsgotty → PTY** 的鍵盤編碼。要操作 Hyprland/Omarchy,Casty 或其他接收程式仍須啟用、解析 Kitty keyboard 並轉送 Super 與放開事件;此項改動不代表整條遠端桌面輸入鏈已完成,也不改變滑鼠協定。
262
+
263
+ 在 jsgotty 終端內手動確認(60 秒後自動退出,或按未修飾的 Esc/q/Ctrl+C):
264
+
265
+ ```sh
266
+ bun tests/manual/kitty-keyboard.js
267
+ ```
268
+
269
+ 測試會印出協商結果、原始序列、修飾鍵及 press/repeat/release;結束時還原鍵盤模式與 raw mode。更新後請重新載入 jsgotty 網頁。可用既有 Chromium 執行真正 xterm 前端測試,不會自動安裝瀏覽器:
270
+
271
+ ```sh
272
+ CHROMIUM_HEADLESS=/path/to/chromium-headless-shell node --test tests/kitty-keyboard-browser.test.js
273
+ ```
274
+
275
+ ## Android 鍵盤
276
+
277
+ Android WebView 若有 `Meta` down/up 卻漏掉 `metaKey`,jsgotty 會在自己的輸入處理中追蹤左右 Meta 並還原 Super。失焦或頁面隱藏時清除按住狀態;已送出的按鍵在事件回報模式下會補送放開。
278
+
279
+ Android 終端的 Alt+字母、數字、標點及空白會依實體按鍵位置(美式配置,支援 Shift)送出終端 Alt 序列,不進入重音死鍵流程。因此 Alt-E/I/N/U 等組合不再用來輸入 é、ê、ñ、ü;仍可使用輸入法或貼上。若 WebView 省略 `code`,會回退使用可辨識的 `keyCode`;若死鍵只送出 `keyup`,則在放開時補送一次,避免與已處理的 `keydown` 重複。正在組字、Ctrl、Meta、AltGr,以及無法辨識按鍵的事件維持原本處理。此規則只作用於 jsgotty 終端,不修改 Android 的系統鍵盤配置。
280
+
241
281
  ## Tested platforms 已測試作業系統
242
282
  - Windows x64: Windows 11
243
283
  - Linux x64: CachyOS
@@ -1001,7 +1001,9 @@ class CursorStateTracker {
1001
1001
  }
1002
1002
 
1003
1003
  class KittyGraphicsParser {
1004
- constructor() {
1004
+ constructor({ binaryData = false } = {}) {
1005
+ // Local renderers can consume bytes directly; browser clients retain base64.
1006
+ this.binaryData = binaryData;
1005
1007
  this.pending = Buffer.alloc(0);
1006
1008
  this.transfers = new Map();
1007
1009
  this.images = new Map();
@@ -1220,7 +1222,7 @@ class KittyGraphicsParser {
1220
1222
  width: Number.parseInt(existing.control.s || "0", 10),
1221
1223
  height: Number.parseInt(existing.control.v || "0", 10),
1222
1224
  mime: existing.control.U || existing.control.mime || null,
1223
- data: binary.toString("base64")
1225
+ data: this.binaryData ? binary : binary.toString("base64")
1224
1226
  };
1225
1227
 
1226
1228
  this.images.set(imageId, image);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "jsgotty",
3
- "version": "1.1.16",
3
+ "version": "1.2.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "jsgotty",
9
- "version": "1.1.16",
9
+ "version": "1.2.0",
10
10
  "dependencies": {
11
11
  "node-addon-api": "^7.1.1",
12
12
  "ws": "^8.18.3"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsgotty",
3
- "version": "1.1.16",
3
+ "version": "1.2.0",
4
4
  "description": "Share your terminal as a web application: A JavaScript rewrite of GoTTY",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -30,6 +30,7 @@
30
30
  <script src="./js/windows_bridge.js"></script>
31
31
  <script src="./js/zmodem.js"></script>
32
32
  <script src="./js/xterm-addon-unicode11.js"></script>
33
+ <script src="./js/kitty-keyboard.js"></script>
33
34
  <script src="./js/gotty.js"></script>
34
35
 
35
36
 
@@ -12543,6 +12543,12 @@
12543
12543
  toTerminal: (e) => this.term.write(e),
12544
12544
  toServer: (e) => this.sendInput(e),
12545
12545
  })),
12546
+ (this.kittyKeyboard = new globalThis.GoTTYKittyKeyboard.KittyKeyboard(this.term, {
12547
+ android: /Android/i.test(navigator.userAgent),
12548
+ })),
12549
+ this.term.attachCustomKeyEventHandler((event) =>
12550
+ this.kittyKeyboard.handle(event) && this.handleAndroidAltKey(event),
12551
+ ),
12546
12552
  this.setPreferences(t),
12547
12553
  (() => {
12548
12554
  try {
@@ -12594,12 +12600,73 @@
12594
12600
  ));
12595
12601
  }),
12596
12602
  this.term.open(e),
12603
+ this.kittyKeyboard.attach(this.term.textarea),
12604
+ this.term.textarea?.addEventListener("blur", () => {
12605
+ this.androidAltDownKeys?.clear();
12606
+ }),
12597
12607
  this.term.focus(),
12598
12608
  this.resizeListener(),
12599
12609
  window.addEventListener("resize", () => {
12600
12610
  this.resizeListener();
12601
12611
  }));
12602
12612
  }
12613
+ // Android can translate physical Alt shortcuts into accent dead keys.
12614
+ // Resolve printable physical keys before xterm's composition handling.
12615
+ handleAndroidAltKey(event) {
12616
+ if (!/Android/i.test(navigator.userAgent)) return true;
12617
+ let code = event.code;
12618
+ // Some Android IMEs omit code and swallow the dead-key keydown,
12619
+ // but still deliver keyup with a usable legacy keyCode.
12620
+ if (!code) {
12621
+ if (event.keyCode >= 65 && event.keyCode <= 90)
12622
+ code = "Key" + String.fromCharCode(event.keyCode);
12623
+ else if (event.keyCode >= 48 && event.keyCode <= 57)
12624
+ code = "Digit" + String.fromCharCode(event.keyCode);
12625
+ else code = {
12626
+ 32: "Space", 186: "Semicolon", 187: "Equal", 188: "Comma",
12627
+ 189: "Minus", 190: "Period", 191: "Slash", 192: "Backquote",
12628
+ 219: "BracketLeft", 220: "Backslash", 221: "BracketRight", 222: "Quote",
12629
+ }[event.keyCode];
12630
+ }
12631
+ this.androidAltDownKeys ??= new Set();
12632
+ const alreadySent = event.type === "keyup" && this.androidAltDownKeys.delete(code);
12633
+ if (
12634
+ !["keydown", "keypress", "keyup"].includes(event.type) ||
12635
+ !event.altKey || event.ctrlKey || event.metaKey ||
12636
+ event.isComposing || event.getModifierState?.("AltGraph") ||
12637
+ (event.type === "keyup" && event.key !== "Dead")
12638
+ ) return true;
12639
+
12640
+ let character;
12641
+ if (/^Key[A-Z]$/.test(code)) {
12642
+ const letter = code.slice(3);
12643
+ character = event.shiftKey ? letter : letter.toLowerCase();
12644
+ } else if (/^Digit[0-9]$/.test(code)) {
12645
+ const digit = code.slice(5);
12646
+ character = event.shiftKey ? ")!@#$%^&*("[Number(digit)] : digit;
12647
+ } else {
12648
+ const punctuation = {
12649
+ Backquote: ["`", "~"], Minus: ["-", "_"], Equal: ["=", "+"],
12650
+ BracketLeft: ["[", "{"], BracketRight: ["]", "}"],
12651
+ Backslash: ["\\", "|"], Semicolon: [";", ":"], Quote: ["'", '"'],
12652
+ Comma: [",", "<"], Period: [".", ">"], Slash: ["/", "?"],
12653
+ Space: [" ", " "],
12654
+ };
12655
+ character = Object.hasOwn(punctuation, code)
12656
+ ? punctuation[code][event.shiftKey ? 1 : 0]
12657
+ : undefined;
12658
+ }
12659
+ if (character === undefined) return true;
12660
+ event.preventDefault();
12661
+ event.stopPropagation();
12662
+ if (event.type === "keydown") {
12663
+ this.androidAltDownKeys.add(code);
12664
+ this.term.input("\x1b" + character, true);
12665
+ } else if (event.type === "keyup" && !alreadySent) {
12666
+ this.term.input("\x1b" + character, true);
12667
+ }
12668
+ return false;
12669
+ }
12603
12670
  info() {
12604
12671
  return { columns: this.term.cols, rows: this.term.rows };
12605
12672
  }
@@ -12610,6 +12677,7 @@
12610
12677
  return this.message;
12611
12678
  }
12612
12679
  setDisconnected(e) {
12680
+ if (e) this.kittyKeyboard.reset();
12613
12681
  this.disconnected = !!e;
12614
12682
  }
12615
12683
  setReconnectAction(e) {
@@ -12721,9 +12789,11 @@
12721
12789
  this.term.blur();
12722
12790
  }
12723
12791
  reset() {
12792
+ this.kittyKeyboard.reset();
12724
12793
  (this.removeMessage(), this.term.clear());
12725
12794
  }
12726
12795
  close() {
12796
+ this.kittyKeyboard.dispose();
12727
12797
  (window.removeEventListener("resize", this.resizeListener),
12728
12798
  this.term.dispose());
12729
12799
  }
@@ -26746,39 +26816,32 @@
26746
26816
  case 37:
26747
26817
  if (e.metaKey) break;
26748
26818
  a
26749
- ? ((o.key = r.C0.ESC + "[1;" + (a + 1) + "D"),
26750
- o.key === r.C0.ESC + "[1;3D" &&
26751
- (o.key = r.C0.ESC + (i ? "b" : "[1;5D")))
26819
+ ? (o.key = r.C0.ESC + "[1;" + (a + 1) + "D")
26752
26820
  : (o.key = t ? r.C0.ESC + "OD" : r.C0.ESC + "[D");
26753
26821
  break;
26754
26822
  case 39:
26755
26823
  if (e.metaKey) break;
26756
26824
  a
26757
- ? ((o.key = r.C0.ESC + "[1;" + (a + 1) + "C"),
26758
- o.key === r.C0.ESC + "[1;3C" &&
26759
- (o.key = r.C0.ESC + (i ? "f" : "[1;5C")))
26825
+ ? (o.key = r.C0.ESC + "[1;" + (a + 1) + "C")
26760
26826
  : (o.key = t ? r.C0.ESC + "OC" : r.C0.ESC + "[C");
26761
26827
  break;
26762
26828
  case 38:
26763
26829
  if (e.metaKey) break;
26764
26830
  a
26765
- ? ((o.key = r.C0.ESC + "[1;" + (a + 1) + "A"),
26766
- i ||
26767
- o.key !== r.C0.ESC + "[1;3A" ||
26768
- (o.key = r.C0.ESC + "[1;5A"))
26831
+ ? (o.key = r.C0.ESC + "[1;" + (a + 1) + "A")
26769
26832
  : (o.key = t ? r.C0.ESC + "OA" : r.C0.ESC + "[A");
26770
26833
  break;
26771
26834
  case 40:
26772
26835
  if (e.metaKey) break;
26773
26836
  a
26774
- ? ((o.key = r.C0.ESC + "[1;" + (a + 1) + "B"),
26775
- i ||
26776
- o.key !== r.C0.ESC + "[1;3B" ||
26777
- (o.key = r.C0.ESC + "[1;5B"))
26837
+ ? (o.key = r.C0.ESC + "[1;" + (a + 1) + "B")
26778
26838
  : (o.key = t ? r.C0.ESC + "OB" : r.C0.ESC + "[B");
26779
26839
  break;
26780
26840
  case 45:
26781
- e.shiftKey || e.ctrlKey || (o.key = r.C0.ESC + "[2~");
26841
+ e.shiftKey || e.ctrlKey ||
26842
+ (o.key = e.altKey
26843
+ ? r.C0.ESC + "[2;" + (a + 1) + "~"
26844
+ : r.C0.ESC + "[2~");
26782
26845
  break;
26783
26846
  case 46:
26784
26847
  o.key = a
@@ -26802,14 +26865,14 @@
26802
26865
  case 33:
26803
26866
  e.shiftKey
26804
26867
  ? (o.type = 2)
26805
- : e.ctrlKey
26868
+ : e.ctrlKey || e.altKey
26806
26869
  ? (o.key = r.C0.ESC + "[5;" + (a + 1) + "~")
26807
26870
  : (o.key = r.C0.ESC + "[5~");
26808
26871
  break;
26809
26872
  case 34:
26810
26873
  e.shiftKey
26811
26874
  ? (o.type = 3)
26812
- : e.ctrlKey
26875
+ : e.ctrlKey || e.altKey
26813
26876
  ? (o.key = r.C0.ESC + "[6;" + (a + 1) + "~")
26814
26877
  : (o.key = r.C0.ESC + "[6~");
26815
26878
  break;
@@ -26927,6 +26990,8 @@
26927
26990
  ))
26928
26991
  : 56 === e.keyCode
26929
26992
  ? (o.key = r.C0.DEL)
26993
+ : "/" === e.key
26994
+ ? (o.key = r.C0.US)
26930
26995
  : 219 === e.keyCode
26931
26996
  ? (o.key = r.C0.ESC)
26932
26997
  : 220 === e.keyCode
@@ -0,0 +1,193 @@
1
+ // Kitty keyboard transport for the shipped xterm wrapper (no build step).
2
+ // Spec: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
3
+ (function (root) {
4
+ 'use strict';
5
+ // Supported progressive enhancements: disambiguation, event types, all keys.
6
+ // Do not advertise alternate-layout/text reporting until those are implemented.
7
+ const SUPPORTED = 1 | 2 | 8;
8
+ const functional = {
9
+ Escape:[27,'u'], Enter:[13,'u'], Tab:[9,'u'], Backspace:[127,'u'],
10
+ Insert:[2,'~'], Delete:[3,'~'], ArrowLeft:[1,'D'], ArrowRight:[1,'C'],
11
+ ArrowUp:[1,'A'], ArrowDown:[1,'B'], PageUp:[5,'~'], PageDown:[6,'~'],
12
+ Home:[1,'H'], End:[1,'F'], CapsLock:[57358,'u'], ScrollLock:[57359,'u'],
13
+ NumLock:[57360,'u'], PrintScreen:[57361,'u'], Pause:[57362,'u'], ContextMenu:[57363,'u'],
14
+ MediaPlay:[57428,'u'], MediaPause:[57429,'u'], MediaPlayPause:[57430,'u'],
15
+ MediaStop:[57432,'u'], MediaFastForward:[57433,'u'], MediaRewind:[57434,'u'],
16
+ MediaTrackNext:[57435,'u'], MediaTrackPrevious:[57436,'u'], MediaRecord:[57437,'u'],
17
+ AudioVolumeDown:[57438,'u'], AudioVolumeUp:[57439,'u'], AudioVolumeMute:[57440,'u'],
18
+ };
19
+ [11,12,13,14,15,17,18,19,20,21,23,24].forEach((n,i) => functional['F'+(i+1)] = [n,'~']);
20
+ for (let i=13;i<=35;i++) functional['F'+i] = [57376+i-13,'u'];
21
+ const punctuation = {
22
+ Backquote:['`','~'], Minus:['-','_'], Equal:['=','+'], BracketLeft:['[','{'],
23
+ BracketRight:[']','}'], Backslash:['\\','|'], Semicolon:[';',':'], Quote:["'",'"'],
24
+ Comma:[',','<'], Period:['.','>'], Slash:['/','?'], Space:[' ',' '],
25
+ };
26
+ const modifiers = {Shift:[1,57441], Control:[4,57442], Alt:[2,57443], Meta:[8,57444],
27
+ OS:[8,57444], Super:[8,57444], Hyper:[16,57445], AltGraph:[0,57453]};
28
+ const has = (object,key) => Object.prototype.hasOwnProperty.call(object,key);
29
+ const lock = (e,name) => Boolean(e.getModifierState?.(name));
30
+ function physicalCode(e) {
31
+ if (e.code && e.code !== 'Unidentified') return e.code;
32
+ if (e.keyCode>=65 && e.keyCode<=90) return 'Key'+String.fromCharCode(e.keyCode);
33
+ if (e.keyCode>=48 && e.keyCode<=57) return 'Digit'+String.fromCharCode(e.keyCode);
34
+ return ({32:'Space',186:'Semicolon',187:'Equal',188:'Comma',189:'Minus',190:'Period',
35
+ 191:'Slash',192:'Backquote',219:'BracketLeft',220:'Backslash',221:'BracketRight',222:'Quote'})[e.keyCode] || '';
36
+ }
37
+ function baseCharacter(e, android) {
38
+ const code=physicalCode(e);
39
+ // Android's Alt accent conversion must not turn an E/I/N/U shortcut into a dead key.
40
+ const recover=android && e.altKey && !lock(e,'AltGraph');
41
+ if (recover || ([...e.key || ''].length===1 && e.key.codePointAt(0)<32)) {
42
+ if (/^Key[A-Z]$/.test(code)) return code.slice(3).toLowerCase();
43
+ if (/^Digit[0-9]$/.test(code)) return code.slice(5);
44
+ if (has(punctuation,code)) return punctuation[code][0];
45
+ }
46
+ if ([...e.key || ''].length!==1) return null;
47
+ if (e.shiftKey) {
48
+ if (has(punctuation,code) && punctuation[code][1]===e.key) return punctuation[code][0];
49
+ if (/^Digit[0-9]$/.test(code) && ')!@#$%^&*('[Number(code.slice(5))]===e.key) return code.slice(5);
50
+ }
51
+ const lower=e.key.toLowerCase();
52
+ return [...lower].length===1 ? lower : e.key;
53
+ }
54
+ function keyDefinition(e, flags, android) {
55
+ if (has(modifiers,e.key)) {
56
+ const [bit,left]=modifiers[e.key];
57
+ const right=e.location===2 || /Right$/.test(e.code || '');
58
+ return {number:left+(right && e.key!=='AltGraph'?6:0),suffix:'u',modifier:bit};
59
+ }
60
+ if ((flags & 9) && (e.location===3 || /^Numpad/.test(e.code || ''))) {
61
+ const numpad={NumpadDecimal:57409,NumpadDivide:57410,NumpadMultiply:57411,
62
+ NumpadSubtract:57412,NumpadAdd:57413,NumpadEnter:57414,NumpadEqual:57415,
63
+ NumpadComma:57416};
64
+ const nav={ArrowLeft:57417,ArrowRight:57418,ArrowUp:57419,ArrowDown:57420,
65
+ PageUp:57421,PageDown:57422,Home:57423,End:57424,Insert:57425,Delete:57426,Clear:57427};
66
+ const number=has(nav,e.key)?nav[e.key]:/^Numpad[0-9]$/.test(e.code || '')
67
+ ?57399+Number(e.code.slice(6)):numpad[e.code];
68
+ if (number) return {number,suffix:'u'};
69
+ }
70
+ if (has(functional,e.key)) {
71
+ const [number,suffix]=functional[e.key];
72
+ return {number,suffix,recovery:['Enter','Tab','Backspace'].includes(e.key)};
73
+ }
74
+ const ch=baseCharacter(e,android);
75
+ return ch===null?null:{number:ch.codePointAt(0),suffix:'u',text:true};
76
+ }
77
+ class KittyKeyboard {
78
+ constructor(term, {android=false}={}) {
79
+ this.term=term; this.android=android; this.disposables=[];
80
+ this.metaKeys=new Set(); this.down=new Map(); this.suppressed=new Set(); this.reset();
81
+ const register=(prefix,fn) => this.disposables.push(term.parser.registerCsiHandler({prefix,final:'u'},p=>{fn(p);return true;}));
82
+ const value=(p,i,fallback=0) => Number.isInteger(p[i])?p[i]:fallback;
83
+ register('?',()=>term.input(`\x1b[?${this.flags}u`,false));
84
+ register('>',p=>{const s=this.state;s.stack.push(s.flags);if(s.stack.length>32)s.stack.shift();s.flags=value(p,0)&SUPPORTED;});
85
+ register('<',p=>{const s=this.state;let count=Math.min(value(p,0)||1,33);while(count--)s.flags=s.stack.pop()??0;});
86
+ register('=',p=>{const s=this.state,f=value(p,0)&SUPPORTED,m=value(p,1)||1;if(m===1)s.flags=f;else if(m===2)s.flags|=f;else if(m===3)s.flags&=~f;});
87
+ this.disposables.push(term.parser.registerEscHandler({final:'c'},()=>{this.reset();return false;}));
88
+ this.disposables.push(term.parser.registerCsiHandler({intermediates:'!',final:'p'},()=>{this.reset();return false;}));
89
+ }
90
+ get state() { return this.states[this.term.buffer.active.type==='alternate'?'alternate':'normal']; }
91
+ get flags() { return this.state.flags; }
92
+ reset() {
93
+ this.states={normal:{flags:0,stack:[]},alternate:{flags:0,stack:[]}};
94
+ this.clearHeld(false);
95
+ }
96
+ clearHeld(report=true) {
97
+ // Release keys reported to the client before losing focus, avoiding stuck remote keys.
98
+ if (report && (this.flags & 2)) for (const key of this.down.values()) {
99
+ if(key.reported) this.term.input(this.sequence(key.def,0,3),true);
100
+ }
101
+ this.metaKeys.clear();this.down.clear();this.suppressed.clear();this.suppressKeypress=false;
102
+ }
103
+ attach(element) {
104
+ const doc=element.ownerDocument, win=doc.defaultView;
105
+ const blur=()=>this.clearHeld();
106
+ const visibility=()=>{if(doc.hidden)this.clearHeld();};
107
+ element.addEventListener('blur',blur);win.addEventListener('blur',blur);
108
+ doc.addEventListener('visibilitychange',visibility);
109
+ this.disposables.push({dispose:()=>{element.removeEventListener('blur',blur);win.removeEventListener('blur',blur);doc.removeEventListener('visibilitychange',visibility);}});
110
+ }
111
+ dispose() {this.clearHeld(false);for(const d of this.disposables)d.dispose();}
112
+ sequence(def,bits,type) {
113
+ const event=(this.flags&2) && type!==1?':'+type:'';
114
+ const mods=bits+1;
115
+ if(def.suffix!=='u' && mods===1 && !event)
116
+ return '\x1b['+(def.number===1?'':def.number)+def.suffix;
117
+ return `\x1b[${def.number}${mods!==1||event?';'+mods+event:''}${def.suffix}`;
118
+ }
119
+ handle(event) {
120
+ if (!['keydown','keyup','keypress'].includes(event.type)) return true;
121
+ const identity=physicalCode(event)||event.key;
122
+ const metaKey=['Meta','OS','Super'].includes(event.key);
123
+ if(this.android && metaKey) {
124
+ if(event.type==='keydown')this.metaKeys.add(event.code||String(event.location||0));
125
+ if(event.type==='keyup')this.metaKeys.delete(event.code||String(event.location||0));
126
+ }
127
+ // Patch only this terminal's Android event, preserving the native DOM event.
128
+ if(this.android && this.metaKeys.size && !event.metaKey) {
129
+ const original=event.getModifierState?.bind(event);
130
+ Object.defineProperties(event,{
131
+ metaKey:{configurable:true,value:true},
132
+ getModifierState:{configurable:true,value:name=>name==='Meta'||Boolean(original?.(name))},
133
+ });
134
+ }
135
+ if(!this.flags || this.term.options.disableStdin) return true;
136
+ const consume=()=>{event.preventDefault();event.stopPropagation();return false;};
137
+ if(event.type==='keypress')return this.suppressKeypress?consume():true;
138
+ let previous=this.down.get(identity);
139
+ if(event.type==='keyup') {
140
+ // Some Android IMEs emit only the release of an Alt dead key. Recover
141
+ // that chord here too, never falling back to legacy ESC text in Kitty mode.
142
+ if(!previous && this.android && event.altKey && event.key==='Dead' &&
143
+ !event.isComposing && !lock(event,'AltGraph') && (this.flags&9)) {
144
+ const def=keyDefinition(event,this.flags,this.android);
145
+ if(def) {
146
+ this.term.input(this.sequence(def,this.bits(event),1),true);
147
+ previous={def,reported:true};this.suppressed.add(identity);
148
+ }
149
+ }
150
+ this.down.delete(identity);
151
+ const suppressed=this.suppressed.delete(identity);
152
+ this.suppressKeypress=false;
153
+ if(previous?.reported && (this.flags&2)) {
154
+ let bits=this.bits(event);
155
+ if(previous.def.modifier && ![...this.down.values()].some(k=>k.def.modifier===previous.def.modifier)) bits &= ~previous.def.modifier;
156
+ if(metaKey && this.metaKeys.size) bits|=8;
157
+ this.term.input(this.sequence(previous.def,bits,3),true);
158
+ }
159
+ return suppressed?consume():true;
160
+ }
161
+ this.suppressKeypress=false;
162
+ if(event.isComposing || event.keyCode===229 && event.key==='Unidentified')return true;
163
+ if(event.key==='Dead' && !(this.android && event.altKey && !lock(event,'AltGraph')))return true;
164
+ const def=keyDefinition(event,this.flags,this.android);
165
+ if(!def)return true;
166
+ let bits=this.bits(event);
167
+ if(def.modifier!==undefined) {
168
+ if(!(this.flags&8))return true;
169
+ bits|=def.modifier;
170
+ }
171
+ const all=Boolean(this.flags&8);
172
+ // Enter/Tab/Backspace remain recovery keys unless modified or report-all is requested.
173
+ if(!all && def.recovery && !(bits&~192))return true;
174
+ const shortcut=Boolean(bits&62);
175
+ if(def.text && !all && !(this.flags&1 && shortcut))return true;
176
+ // With only event reporting requested, leave legacy printable shortcuts untouched.
177
+ if(def.text && !all && !(this.flags&1))return true;
178
+ const type=event.repeat?2:1;
179
+ this.down.set(identity,{def,reported:!(def.recovery && !all)});this.suppressed.add(identity);this.suppressKeypress=true;
180
+ this.term.input(this.sequence(def,bits,type),true);
181
+ return consume();
182
+ }
183
+ bits(e) {
184
+ const altgr=lock(e,'AltGraph');
185
+ return (e.shiftKey?1:0)|(!altgr&&e.altKey?2:0)|(!altgr&&e.ctrlKey?4:0)|
186
+ (e.metaKey || this.android&&this.metaKeys.size?8:0)|
187
+ (lock(e,'Hyper')?16:0)|(lock(e,'CapsLock')?64:0)|(lock(e,'NumLock')?128:0);
188
+ }
189
+ }
190
+ const api={KittyKeyboard,SUPPORTED};
191
+ if(typeof module==='object' && module.exports)module.exports=api;
192
+ else root.GoTTYKittyKeyboard=api;
193
+ })(globalThis);
@@ -0,0 +1,115 @@
1
+ const assert = require('node:assert/strict');
2
+ const test = require('node:test');
3
+ const fs = require('node:fs');
4
+ const vm = require('node:vm');
5
+ const path = require('node:path');
6
+
7
+ // Exercise the shipped bundle's wrapper method, without a DOM or new dependencies.
8
+ const bundle = fs.readFileSync(path.join(__dirname, '../static/js/gotty.js'), 'utf8');
9
+ const start = bundle.indexOf(' handleAndroidAltKey(event) {');
10
+ assert.ok(start >= 0);
11
+ const end = bundle.indexOf(' info() {', start);
12
+ assert.ok(end > start);
13
+ const method = bundle.slice(start, end);
14
+ function setup(userAgent = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 wv') {
15
+ const handler = vm.runInNewContext(`({${method}}).handleAndroidAltKey`, {
16
+ navigator: { userAgent },
17
+ });
18
+ const sent = [];
19
+ const target = { term: { input: (text, userInput) => sent.push([text, userInput]) } };
20
+ function dispatch(overrides = {}) {
21
+ const event = {
22
+ type: 'keydown', code: 'KeyE', key: 'Dead', keyCode: 69,
23
+ altKey: true, ctrlKey: false, metaKey: false, shiftKey: false,
24
+ isComposing: false, getModifierState: () => false,
25
+ prevented: false, stopped: false,
26
+ preventDefault() { this.prevented = true; },
27
+ stopPropagation() { this.stopped = true; },
28
+ ...overrides,
29
+ };
30
+ return { result: handler.call(target, event), event };
31
+ }
32
+ return { sent, dispatch };
33
+ }
34
+
35
+ test('Android accent shortcuts emit one ESC sequence; keypress/keyup do not duplicate it', () => {
36
+ for (const letter of ['E', 'I', 'N', 'U']) {
37
+ for (const keyCode of [letter.charCodeAt(0), 229]) {
38
+ const { sent, dispatch } = setup();
39
+ const { event, result } = dispatch({ code: `Key${letter}`, keyCode });
40
+ assert.equal(result, false);
41
+ assert.equal(event.prevented, true);
42
+ assert.equal(event.stopped, true);
43
+ dispatch({ type: 'keypress', code: `Key${letter}`, keyCode });
44
+ dispatch({ type: 'keyup', code: `Key${letter}`, keyCode });
45
+ assert.deepEqual(sent, [[`\x1b${letter.toLowerCase()}`, true]]);
46
+ // No dead-key state is retained in this handler for the next ordinary key.
47
+ assert.equal(dispatch({ altKey: false, key: 'e' }).result, true);
48
+ assert.equal(sent.length, 1);
49
+ }
50
+ }
51
+ });
52
+
53
+ test('all printable physical Alt keys bypass dead-key handling, including Shift and repeat', () => {
54
+ const { sent, dispatch } = setup();
55
+ for (const [code, shiftKey, expected] of [
56
+ ['KeyA', false, 'a'], ['KeyE', true, 'E'], ['Digit0', false, '0'],
57
+ ['Digit1', true, '!'], ['Digit0', true, ')'], ['Quote', false, "'"],
58
+ ['Backquote', true, '~'], ['Backslash', false, '\\'], ['Space', false, ' '],
59
+ ]) {
60
+ dispatch({ code, shiftKey, key: 'Dead' });
61
+ assert.deepEqual(sent.at(-1), [`\x1b${expected}`, true]);
62
+ }
63
+ dispatch({ code: 'KeyE', key: 'é', repeat: true });
64
+ assert.deepEqual(sent.at(-1), ['\x1be', true]);
65
+ });
66
+
67
+ test('composition, AltGr, other modifiers, and unmapped keys retain existing handling', () => {
68
+ for (const overrides of [
69
+ { isComposing: true, keyCode: 229 }, { getModifierState: key => key === 'AltGraph' },
70
+ { altKey: false }, { ctrlKey: true }, { metaKey: true },
71
+ { code: '', keyCode: 229 }, { code: 'ArrowLeft' }, { code: 'Enter' },
72
+ { code: 'Unidentified', keyCode: 229 }, { code: 'toString' },
73
+ { type: 'keyup', key: 'e' },
74
+ ]) {
75
+ const { sent, dispatch } = setup();
76
+ const { result, event } = dispatch(overrides);
77
+ assert.equal(result, true);
78
+ assert.equal(event.prevented, false);
79
+ assert.deepEqual(sent, []);
80
+ }
81
+ });
82
+
83
+ test('non-Android browsers retain existing Alt/dead-key handling', () => {
84
+ for (const ua of ['Mozilla/5.0 (X11; Linux x86_64)', 'Mozilla/5.0 (Macintosh)', 'Windows']) {
85
+ const { sent, dispatch } = setup(ua);
86
+ assert.equal(dispatch().result, true);
87
+ assert.deepEqual(sent, []);
88
+ }
89
+ });
90
+
91
+ // Replay the actual Android 16 WebView trace: no letter keydown and empty code.
92
+ test('Android keyup-only dead keys use legacy keyCode, and ordinary IME input stays untouched', () => {
93
+ const { sent, dispatch } = setup();
94
+ for (const keyCode of [69, 78, 85, 73]) {
95
+ dispatch({ type: 'keydown', code: '', key: 'Alt', keyCode: 18 });
96
+ assert.equal(dispatch({ type: 'keyup', code: '', key: 'Dead', keyCode }).result, false);
97
+ dispatch({ type: 'keyup', code: '', key: 'Alt', keyCode: 18 });
98
+ }
99
+ assert.deepEqual(sent, ['e', 'n', 'u', 'i'].map(c => ['\x1b' + c, true]));
100
+ assert.equal(dispatch({ code: '', key: 'Unidentified', keyCode: 229, altKey: false }).result, true);
101
+ assert.equal(sent.length, 4);
102
+ });
103
+
104
+ test('legacy keydown plus dead keyup emits once, even with different code availability', () => {
105
+ for (const [downCode, upCode] of [['', ''], ['KeyE', ''], ['', 'KeyE']]) {
106
+ const { sent, dispatch } = setup();
107
+ dispatch({ code: downCode, keyCode: 69 });
108
+ dispatch({ type: 'keypress', code: downCode, keyCode: 69 });
109
+ dispatch({ type: 'keyup', code: upCode, keyCode: 69 });
110
+ assert.deepEqual(sent, [['\x1be', true]]);
111
+ // The next keyup-only shortcut must not be suppressed by the preceding press.
112
+ dispatch({ type: 'keyup', code: '', keyCode: 69 });
113
+ assert.equal(sent.length, 2);
114
+ }
115
+ });
@@ -0,0 +1,80 @@
1
+ const assert = require('node:assert/strict');
2
+ const test = require('node:test');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const vm = require('node:vm');
6
+
7
+ // Exercise the keyboard encoder shipped in the frontend bundle.
8
+ const bundle = fs.readFileSync(path.join(__dirname, '../static/js/gotty.js'), 'utf8');
9
+ const marker = 't.evaluateKeyboardEvent = function (e, t, i, n) {';
10
+ const start = bundle.indexOf(marker);
11
+ assert.ok(start >= 0);
12
+ const end = bundle.indexOf('\n },\n 482:', start);
13
+ assert.ok(end > start);
14
+ const source = bundle.slice(start + 't.evaluateKeyboardEvent = '.length, end).trim().replace(/;$/, '');
15
+ const evaluate = vm.runInNewContext(`(${source})`, { r: { C0: { ESC: '\x1b', US: '\x1f' } }, s: {} });
16
+ const arrows = [[37, 'D'], [38, 'A'], [39, 'C'], [40, 'B']];
17
+
18
+ for (const isMac of [false, true]) {
19
+ test(`arrow modifiers stay distinct (isMac=${isMac})`, () => {
20
+ for (const [keyCode, suffix] of arrows) {
21
+ for (const applicationCursorMode of [false, true]) {
22
+ const sequences = new Set();
23
+ for (let mask = 0; mask < 8; mask++) {
24
+ const result = evaluate({
25
+ keyCode, metaKey: false,
26
+ shiftKey: Boolean(mask & 1), altKey: Boolean(mask & 2), ctrlKey: Boolean(mask & 4),
27
+ }, applicationCursorMode, isMac, false);
28
+ const expected = mask
29
+ ? `\x1b[1;${mask + 1}${suffix}`
30
+ : `\x1b${applicationCursorMode ? 'O' : '['}${suffix}`;
31
+ assert.equal(result.key, expected);
32
+ sequences.add(result.key);
33
+ }
34
+ assert.equal(sequences.size, 8);
35
+ }
36
+ }
37
+ });
38
+ }
39
+
40
+ test('Alt Insert/PageUp/PageDown keep Alt while preserving existing shortcuts', () => {
41
+ for (const isMac of [false, true]) {
42
+ const encode = (keyCode, modifiers = {}) => evaluate({
43
+ keyCode, shiftKey: false, altKey: false, ctrlKey: false, metaKey: false,
44
+ ...modifiers,
45
+ }, false, isMac, false);
46
+ for (const [keyCode, parameter] of [[45, 2], [33, 5], [34, 6]]) {
47
+ assert.equal(encode(keyCode).key, `\x1b[${parameter}~`);
48
+ assert.equal(encode(keyCode, { altKey: true }).key, `\x1b[${parameter};3~`);
49
+ }
50
+ for (const [keyCode, parameter, scrollType] of [[33, 5, 2], [34, 6, 3]]) {
51
+ assert.equal(encode(keyCode, { ctrlKey: true }).key, `\x1b[${parameter};5~`);
52
+ assert.equal(encode(keyCode, { ctrlKey: true, altKey: true }).key, `\x1b[${parameter};7~`);
53
+ for (const ctrlKey of [false, true]) {
54
+ for (const altKey of [false, true]) {
55
+ const result = encode(keyCode, { shiftKey: true, ctrlKey, altKey });
56
+ assert.equal(result.type, scrollType);
57
+ assert.equal(result.key, undefined);
58
+ }
59
+ }
60
+ }
61
+ for (const modifiers of [
62
+ { ctrlKey: true }, { shiftKey: true }, { ctrlKey: true, shiftKey: true },
63
+ { ctrlKey: true, altKey: true }, { shiftKey: true, altKey: true },
64
+ ]) assert.equal(encode(45, modifiers).key, undefined);
65
+ }
66
+ });
67
+
68
+
69
+ test('Ctrl slash sends US like Ctrl underscore, including alternate layout keyCodes', () => {
70
+ for (const isMac of [false, true]) {
71
+ for (const keyCode of [191, 226]) {
72
+ const result = evaluate({ key: '/', keyCode, ctrlKey: true,
73
+ shiftKey: false, altKey: false, metaKey: false }, false, isMac, false);
74
+ assert.equal(result.key, '\x1f');
75
+ }
76
+ const plain = evaluate({ key: '/', keyCode: 191, ctrlKey: false,
77
+ shiftKey: false, altKey: false, metaKey: false }, false, isMac, false);
78
+ assert.equal(plain.key, '/');
79
+ }
80
+ });
@@ -0,0 +1,53 @@
1
+ // Opt-in real bundled-xterm integration; no browser/dependency downloads.
2
+ // CHROMIUM_HEADLESS=/path/to/chromium-headless-shell node --test tests/kitty-keyboard-browser.test.js
3
+ const test=require('node:test');
4
+ const assert=require('node:assert/strict');
5
+ const fs=require('node:fs');
6
+ const path=require('node:path');
7
+ const http=require('node:http');
8
+ const {spawn}=require('node:child_process');
9
+ const {once}=require('node:events');
10
+ const WebSocket=require('ws');
11
+ const root=path.join(__dirname,'../static');
12
+ test('shipped xterm parses Kitty modes and delivers one encoded DOM event per key', {skip:!process.env.CHROMIUM_HEADLESS,timeout:60000},async()=>{
13
+ const bundle=fs.readFileSync(path.join(root,'js/gotty.js'),'utf8').replace('(i.nc = void 0));','(i.nc = void 0)); window.TestGoTTYXterm = i(634).GoTTYXterm;');
14
+ const server=http.createServer((req,res)=>{
15
+ if(req.url==='/bundle.js'){res.setHeader('Content-Type','text/javascript');res.end(bundle);}
16
+ else if(req.url==='/keyboard.js'){res.setHeader('Content-Type','text/javascript');res.end(fs.readFileSync(path.join(root,'js/kitty-keyboard.js')));}
17
+ else res.end('<!doctype html><div id="fixture" style="width:800px;height:600px"></div><script src="/keyboard.js"></script><script src="/bundle.js"></script><script>window.wrapper=new TestGoTTYXterm(document.getElementById("fixture"));window.sent=[];wrapper.onInput(b=>sent.push(new TextDecoder().decode(b)));wrapper.kittyKeyboard.android=true;</script>');
18
+ });
19
+ server.listen(0,'127.0.0.1');await once(server,'listening');
20
+ const child=spawn(process.env.CHROMIUM_HEADLESS,['--no-sandbox','--disable-gpu','--remote-debugging-port=0','http://127.0.0.1:'+server.address().port],{detached:true,stdio:['ignore','ignore','pipe']});
21
+ let ws;
22
+ try{
23
+ const endpoint=await new Promise((resolve,reject)=>{let log='';const timer=setTimeout(()=>reject(Error('Browser startup timeout: '+log)),15000);child.on('error',reject);child.on('exit',code=>{clearTimeout(timer);reject(Error('Browser exited '+code+': '+log));});child.stderr.on('data',b=>{log+=b;const m=log.match(/DevTools listening on (ws:\/\/[^\s]+)/);if(m){clearTimeout(timer);resolve(m[1]);}});});
24
+ const u=new URL(endpoint);let tab;
25
+ for(let i=0;i<50;i++){tab=(await (await fetch('http://'+u.host+'/json/list')).json()).find(x=>x.type==='page');if(tab)break;await new Promise(r=>setTimeout(r,100));}
26
+ ws=new WebSocket(tab.webSocketDebuggerUrl);await once(ws,'open');let id=0;const pending=new Map();ws.on('message',data=>{const m=JSON.parse(data);if(pending.has(m.id)){const {resolve,reject}=pending.get(m.id);pending.delete(m.id);m.error?reject(m.error):resolve(m.result);}});
27
+ const call=(method,params)=>new Promise((resolve,reject)=>{pending.set(++id,{resolve,reject});ws.send(JSON.stringify({id,method,params}));});
28
+ for(let i=0;i<50;i++){const r=await call('Runtime.evaluate',{expression:'Boolean(window.wrapper)',returnByValue:true});if(r.result.value)break;await new Promise(r=>setTimeout(r,100));}
29
+ const result=await call('Runtime.evaluate',{awaitPromise:true,returnByValue:true,expression:`(async()=>{
30
+ const term=wrapper.term, kb=wrapper.kittyKeyboard;
31
+ const write=s=>new Promise(r=>term.write(s,r));
32
+ const equal=(a,b)=>{if(JSON.stringify(a)!==JSON.stringify(b))throw Error(JSON.stringify({actual:a,expected:b}));};
33
+ const key=(type,key,code,mods={})=>term.textarea.dispatchEvent(new KeyboardEvent(type,{key,code,bubbles:true,cancelable:true,...mods}));
34
+ await write('\\x1b[?u');equal(sent.splice(0),['\\x1b[?0u']);
35
+ // Split escape sequence across writes: only the parser may enable the mode.
36
+ await write('\\x1b[>');await write('11u');equal(kb.flags,11);
37
+ key('keydown','Meta','MetaLeft');key('keydown','x','KeyX',{ctrlKey:true,shiftKey:true});
38
+ key('keypress','x','KeyX',{ctrlKey:true,shiftKey:true});key('keyup','x','KeyX',{ctrlKey:true,shiftKey:true});key('keyup','Meta','MetaLeft');
39
+ equal(sent.splice(0),['\\x1b[57444;9u','\\x1b[120;14u','\\x1b[120;14:3u','\\x1b[57444;1:3u']);
40
+ await write('\\x1b[?1049h');equal(kb.flags,0);await write('\\x1b[>1u');equal(kb.flags,1);
41
+ await write('\\x1b[?1049l');equal(kb.flags,11);
42
+ await write('\\x1b[<u');equal(kb.flags,0);
43
+ key('keydown','c','KeyC',{ctrlKey:true,keyCode:67});key('keyup','c','KeyC',{ctrlKey:true,keyCode:67});equal(sent.splice(0),['\\x03']);
44
+ await write('\\x1b[>11u');await write('\\x1bc');equal(kb.flags,0);
45
+ await write('\\x1b[?u');equal(sent.splice(0),['\\x1b[?0u']);
46
+ wrapper.close();return 'ok';
47
+ })()`});
48
+ assert.equal(result.exceptionDetails,undefined,JSON.stringify(result.exceptionDetails));assert.equal(result.result.value,'ok');
49
+ }finally{
50
+ ws?.close();if(child.pid){try{process.kill(-child.pid,'SIGTERM');}catch{}}
51
+ server.closeAllConnections();await new Promise(r=>server.close(r));
52
+ }
53
+ });
@@ -0,0 +1,102 @@
1
+ const test=require('node:test');
2
+ const assert=require('node:assert/strict');
3
+ const {KittyKeyboard,SUPPORTED}=require('../static/js/kitty-keyboard.js');
4
+ function setup(android=true) {
5
+ const handlers=new Map(),sent=[];
6
+ const term={parser:{registerCsiHandler:(id,fn)=>{handlers.set((id.prefix||id.intermediates||'')+id.final,fn);return {dispose(){}};},registerEscHandler:(id,fn)=>{handlers.set('ESC'+id.final,fn);return {dispose(){}};}},buffer:{active:{type:'normal'}},options:{},input:(s,user)=>sent.push([s,user])};
7
+ const keyboard=new KittyKeyboard(term,{android});
8
+ const mode=(prefix,params=[])=>handlers.get(prefix+'u')(params);
9
+ const event=(options={})=>({type:'keydown',key:'a',code:'KeyA',keyCode:65,location:0,repeat:false,ctrlKey:false,altKey:false,shiftKey:false,metaKey:false,getModifierState:()=>false,preventDefault(){this.prevented=true;},stopPropagation(){},...options});
10
+ const send=options=>{const e=event(options);return {e,result:keyboard.handle(e)};};
11
+ return {keyboard,term,sent,handlers,mode,send,event};
12
+ }
13
+ test('negotiation, bit operations, bounded stacks, separate screens and reset',()=>{
14
+ const t=setup();t.mode('?');assert.deepEqual(t.sent.pop(),['\x1b[?0u',false]);
15
+ t.mode('>',[11]);t.mode('>',[1]);t.mode('<');assert.equal(t.keyboard.flags,11);
16
+ t.mode('=',[2,3]);assert.equal(t.keyboard.flags,9);t.mode('=',[2,2]);assert.equal(t.keyboard.flags,11);
17
+ t.term.buffer.active.type='alternate';assert.equal(t.keyboard.flags,0);t.mode('>',[3]);
18
+ t.term.buffer.active.type='normal';assert.equal(t.keyboard.flags,11);
19
+ for(let i=0;i<100;i++)t.mode('>',[1]);assert.equal(t.keyboard.state.stack.length,32);
20
+ t.mode('<',[99999]);assert.equal(t.keyboard.flags,0);
21
+ t.mode('=',[31]);assert.equal(t.keyboard.flags,SUPPORTED);t.mode('=',[0]);assert.equal(t.keyboard.flags,0);
22
+ t.handlers.get('ESCc')();t.term.buffer.active.type='alternate';assert.equal(t.keyboard.flags,0);
23
+ });
24
+ test('all sixteen Shift/Alt/Ctrl/Super combinations stay distinct for all letters and navigation',()=>{
25
+ for(const key of [...'abcdefghijklmnopqrstuvwxyz','ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Tab','Enter','Backspace','Insert','PageUp','PageDown','F1','F12','PrintScreen']) {
26
+ const seen=new Set();
27
+ for(let mask=0;mask<16;mask++) {
28
+ const t=setup();t.mode('>',[11]);t.send({key,code:key.length===1?'Key'+key.toUpperCase():key,shiftKey:!!(mask&1),altKey:!!(mask&2),ctrlKey:!!(mask&4),metaKey:!!(mask&8)});
29
+ assert.equal(t.sent.length,1,`${key} ${mask}`);seen.add(t.sent[0][0]);
30
+ }
31
+ assert.equal(seen.size,16,key);
32
+ }
33
+ });
34
+ test('Android Meta pair repairs missing metaKey, including keyup and combinations',()=>{
35
+ const t=setup();t.mode('>',[11]);
36
+ t.send({key:'Meta',code:'',keyCode:91});
37
+ assert.deepEqual(t.sent.pop(),['\x1b[57444;9u',true]);
38
+ const {e}=t.send({key:'x',code:'',keyCode:88,ctrlKey:true,shiftKey:true});
39
+ assert.equal(e.metaKey,true);assert.equal(e.getModifierState('Meta'),true);
40
+ assert.deepEqual(t.sent.pop(),['\x1b[120;14u',true]);
41
+ t.send({type:'keyup',key:'x',code:'',keyCode:88,ctrlKey:true,shiftKey:true});assert.equal(t.sent.pop()[0],'\x1b[120;14:3u');
42
+ t.send({type:'keyup',key:'Meta',code:'',keyCode:91});assert.equal(t.sent.pop()[0],'\x1b[57444;1:3u');
43
+ t.send({key:'x',code:'KeyX'});assert.equal(t.sent.pop()[0],'\x1b[120u');
44
+ });
45
+ test('both Meta keys, repeats and focus cleanup do not leave modifiers held',()=>{
46
+ const t=setup();t.mode('>',[11]);
47
+ t.send({key:'Meta',code:'MetaLeft',location:1});t.send({key:'Meta',code:'MetaRight',location:2});
48
+ t.send({type:'keyup',key:'Meta',code:'MetaLeft',location:1});assert.equal(t.sent.at(-1)[0],'\x1b[57444;9:3u');
49
+ t.send({repeat:true});assert.equal(t.sent.at(-1)[0],'\x1b[97;9:2u');
50
+ t.keyboard.clearHeld();assert.equal(t.keyboard.metaKeys.size,0);
51
+ t.send();assert.equal(t.sent.at(-1)[0],'\x1b[97u');
52
+ });
53
+ test('legacy remains unchanged; disabling mode restores text and ordinary controls',()=>{
54
+ const t=setup();assert.equal(t.send({ctrlKey:true}).result,true);assert.equal(t.sent.length,0);
55
+ t.mode('>',[1]);assert.equal(t.send().result,true);
56
+ for(const key of ['Tab','Enter','Backspace'])assert.equal(t.send({key,code:key}).result,true);
57
+ t.send({ctrlKey:true,key:'i',code:'KeyI'});assert.equal(t.sent.pop()[0],'\x1b[105;5u');
58
+ t.send({ctrlKey:true,shiftKey:true,key:'I',code:'KeyI'});assert.equal(t.sent.pop()[0],'\x1b[105;6u');
59
+ t.mode('<');assert.equal(t.send({ctrlKey:true}).result,true);
60
+ });
61
+ test('printable keys in disambiguation mode do not generate release events',()=>{
62
+ const t=setup();t.mode('>',[3]);t.send();t.send({type:'keyup'});assert.equal(t.sent.length,0);
63
+ t.send({ctrlKey:true});t.send({type:'keyup',ctrlKey:true});assert.deepEqual(t.sent.map(s=>s[0]),['\x1b[97;5u','\x1b[97;5:3u']);
64
+ });
65
+ test('dead Alt keys recover before encoding; regular composition is untouched',()=>{
66
+ const t=setup();t.mode('>',[11]);
67
+ for(const k of 'ENUI'){t.send({key:'Dead',code:'Key'+k,altKey:true});assert.equal(t.sent.pop()[0],`\x1b[${k.toLowerCase().charCodeAt(0)};3u`);}
68
+ assert.equal(t.send({isComposing:true,key:'Process'}).result,true);
69
+ assert.equal(t.send({key:'Dead',altKey:false}).result,true);
70
+ });
71
+ test('AltGr is not encoded as Ctrl+Alt; non-Latin layout key values are preserved',()=>{
72
+ const t=setup();t.mode('>',[11]);
73
+ t.send({key:'€',code:'KeyE',ctrlKey:true,altKey:true,getModifierState:k=>k==='AltGraph'});assert.equal(t.sent.pop()[0],'\x1b[8364u');
74
+ t.send({key:'ф',code:'KeyA',ctrlKey:true});assert.equal(t.sent.pop()[0],'\x1b[1092;5u');
75
+ });
76
+ test('keypad, media and lock modifiers have dedicated encodings',()=>{
77
+ const t=setup();t.mode('>',[11]);
78
+ t.send({key:'Enter',code:'NumpadEnter',location:3});assert.equal(t.sent.pop()[0],'\x1b[57414u');
79
+ t.send({key:'AudioVolumeUp',code:'AudioVolumeUp'});assert.equal(t.sent.pop()[0],'\x1b[57439u');
80
+ t.send({getModifierState:k=>k==='CapsLock'});assert.equal(t.sent.pop()[0],'\x1b[97;65u');
81
+ });
82
+ test('normal non-Android Meta and input disabled are not rewritten',()=>{
83
+ const t=setup(false);t.mode('>',[11]);t.send({key:'Meta',code:'MetaLeft'});const {e}=t.send();assert.equal(e.metaKey,false);
84
+ t.term.options.disableStdin=true;const count=t.sent.length;assert.equal(t.send({metaKey:true}).result,true);assert.equal(t.sent.length,count);
85
+ });
86
+ test('keypress cannot duplicate encoded keydown, but later ordinary text is not swallowed',()=>{
87
+ const t=setup();t.mode('>',[1]);t.send({ctrlKey:true});assert.equal(t.send({type:'keypress',ctrlKey:true}).result,false);
88
+ t.send({key:'b',code:'KeyB'});assert.equal(t.send({type:'keypress',key:'b',code:'KeyB'}).result,true);
89
+ });
90
+ test('Android release-only accent shortcuts still use Kitty encoding',()=>{
91
+ const t=setup();t.mode('>',[11]);t.send({type:'keyup',key:'Dead',code:'',keyCode:69,altKey:true});
92
+ assert.deepEqual(t.sent.map(x=>x[0]),['\x1b[101;3u','\x1b[101;3:3u']);
93
+ });
94
+ test('Omarchy workspace and monitor chords retain all modifiers',()=>{
95
+ for(const key of [...'1234567890','ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Tab','Home','`']) {
96
+ const t=setup();t.mode('>',[11]);
97
+ t.send({key,code:key.length===1?'':key,metaKey:true,shiftKey:true,altKey:true});
98
+ assert.match(t.sent.at(-1)[0],/;12[~uABCDH]$/);
99
+ t.send({key,code:key.length===1?'':key,metaKey:true,shiftKey:true,ctrlKey:true});
100
+ assert.match(t.sent.at(-1)[0],/;14[~uABCDH]$/);
101
+ }
102
+ });
@@ -0,0 +1,20 @@
1
+ # 鍵盤診斷(手動)
2
+
3
+ 此工具不由正式前端載入,也不需要修改 `static/js/gotty.js` 或安裝依賴。
4
+
5
+ 1. 在與 Android WebView 共用本機網路的環境啟動收集器:
6
+
7
+ ```sh
8
+ node tests/manual/keyboard-collector.cjs
9
+ # 可指定 port 與輸出檔;預設 18766、系統暫存目錄的 jsgotty-keyboard-debug.jsonl
10
+ ```
11
+
12
+ 2. 連上 jsgotty 後,透過該頁面的 DevTools Console 或 CDP `Runtime.evaluate` 執行收集器印出的 JavaScript。每次啟動會產生新 token。若要從另一台裝置連線,需要另行調整收集器位址與監聽設定。
13
+ 3. 按下要測試的組合鍵,讀取收集器顯示的 JSONL 路徑。每頁最多 2000 筆、10 分鐘;重跑載入指令即可開始新一輪。檔案採追加寫入,以 `session` 分組並依 `sequence` 排序,避免 HTTP 到達順序不同造成誤判。
14
+ 4. 停止頁面診斷:`window.stopGottyKeyboardDebug()`;或重新載入頁面。收集器按 Ctrl-C 停止。
15
+
16
+ 記錄內容包含原始 keydown/keypress/keyup、beforeinput/input、composition 事件、既有按鍵處理器回傳值、事件處理完成後的取消狀態,以及 xterm `onData` 的 UTF-8 位元組(前 64 bytes)。onData 是前端產生的終端資料,並非伺服器 PTY 接收確認。診斷期間只輸入測試內容,避免留下密碼或其他敏感輸入。
17
+
18
+ 紀錄也保留於 `window.__gottyKeyboardRecords`;傳輸失敗原因在 `window.__gottyKeyboardDeliveryError`。若無需 HTTP 收集器,可直接在 Console/CDP 執行 `keyboard-debug.js` 的內容,再呼叫 `window.startGottyKeyboardDebug()`。
19
+
20
+ 工具透過 `element.__gottyXterm` 尋找終端,暫時包裝 xterm 的 `_core._customKeyEventHandler`,停止時還原。因此升級 xterm 後需要再確認這個內部欄位;正式產品不依賴此診斷工具。舊的 `#keyboard-debug` 網址片段不再啟用任何診斷。
@@ -0,0 +1,55 @@
1
+ // Usage: node tests/manual/keyboard-collector.cjs [port] [output.jsonl]
2
+ const http = require('node:http');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const { randomBytes } = require('node:crypto');
7
+ const port = Number(process.argv[2] || 18766);
8
+ const file = path.resolve(process.argv[3] || path.join(os.tmpdir(), 'jsgotty-keyboard-debug.jsonl'));
9
+ const token = randomBytes(16).toString('hex');
10
+ const endpoint = `http://127.0.0.1:${port}/${token}`;
11
+ const client = fs.readFileSync(path.join(__dirname, 'keyboard-debug.js'), 'utf8') +
12
+ `\nwindow.startGottyKeyboardDebug({endpoint:${JSON.stringify(endpoint)}});\n`;
13
+ let count = 0;
14
+ http.createServer((req, res) => {
15
+ res.setHeader('Access-Control-Allow-Origin', '*');
16
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
17
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
18
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
19
+ res.setHeader('Cache-Control', 'no-store');
20
+ if (req.url !== '/' + token && req.url !== '/' + token + '/client.js') {
21
+ res.writeHead(404); return res.end();
22
+ }
23
+ if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); }
24
+ if (req.method === 'GET' && req.url.endsWith('/client.js')) {
25
+ res.setHeader('Content-Type', 'application/javascript');
26
+ return res.end(client);
27
+ }
28
+ if (req.method !== 'POST' || req.url !== '/' + token) {
29
+ res.writeHead(405); return res.end();
30
+ }
31
+ let body = '';
32
+ req.on('data', chunk => {
33
+ body += chunk;
34
+ if (body.length > 16384) req.destroy();
35
+ });
36
+ req.on('end', () => {
37
+ if (count >= 20000) { res.writeHead(429); return res.end(); }
38
+ let event;
39
+ try { event = JSON.parse(body); }
40
+ catch { res.writeHead(400); return res.end(); }
41
+ try {
42
+ fs.appendFileSync(file, JSON.stringify({receivedAt: new Date().toISOString(), event}) + '\n', {mode: 0o600});
43
+ count++;
44
+ res.writeHead(204); res.end();
45
+ } catch (error) {
46
+ console.error(error.message);
47
+ res.writeHead(500); res.end();
48
+ }
49
+ });
50
+ }).listen(port, '127.0.0.1', () => {
51
+ console.log('Log (append): ' + file);
52
+ console.log('Run in the connected jsgotty page via DevTools or CDP:');
53
+ console.log(`fetch(${JSON.stringify(endpoint + '/client.js')}).then(r => r.text()).then(source => (0, eval)(source))`);
54
+ console.log('Stop browser capture: window.stopGottyKeyboardDebug()');
55
+ });
@@ -0,0 +1,74 @@
1
+ // Manually loaded diagnostic helper. Never loaded by the production frontend.
2
+ window.startGottyKeyboardDebug = ({ endpoint, term } = {}) => {
3
+ window.stopGottyKeyboardDebug?.();
4
+ term ??= Array.from(document.querySelectorAll("*")).find(el => el.__gottyXterm)?.__gottyXterm;
5
+ if (!term) throw new Error("No jsgotty xterm found; connect the terminal first.");
6
+ const listeners = [];
7
+ let stopped = false;
8
+ const records = [];
9
+ const started = Date.now();
10
+ const session = Math.random().toString(36).slice(2);
11
+ window.__gottyKeyboardRecords = records;
12
+ window.__gottyKeyboardDebug = (stage, details = {}) => {
13
+ if (stopped || records.length >= 2000 || Date.now() - started > 600000) return;
14
+ const record = { session, sequence: records.length, time: new Date().toISOString(), stage, ...details };
15
+ records.push(record);
16
+ console.log("[gotty-keyboard]", record);
17
+ if (endpoint) fetch(endpoint, {
18
+ method: "POST", headers: { "Content-Type": "text/plain" },
19
+ body: JSON.stringify(record),
20
+ }).catch(error => { window.__gottyKeyboardDeliveryError = String(error); });
21
+ };
22
+ window.__gottyKeyboardDebug("ready", {
23
+ userAgent: navigator.userAgent, platform: navigator.platform,
24
+ userAgentData: navigator.userAgentData?.platform,
25
+ });
26
+ for (const type of ["keydown", "keypress", "keyup", "beforeinput", "input",
27
+ "compositionstart", "compositionupdate", "compositionend"]) {
28
+ const listener = event => {
29
+ const details = {
30
+ type, key: event.key, code: event.code, keyCode: event.keyCode,
31
+ charCode: event.charCode, which: event.which, altKey: event.altKey,
32
+ ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey,
33
+ altGraph: event.getModifierState?.("AltGraph"),
34
+ isComposing: event.isComposing, repeat: event.repeat,
35
+ cancelable: event.cancelable, defaultPrevented: event.defaultPrevented,
36
+ inputType: event.inputType, data: event.data?.slice(0, 64),
37
+ target: event.target?.tagName, trusted: event.isTrusted,
38
+ };
39
+ window.__gottyKeyboardDebug("capture", details);
40
+ setTimeout(() => window.__gottyKeyboardDebug("after-dispatch", {
41
+ ...details, defaultPrevented: event.defaultPrevented,
42
+ }));
43
+ };
44
+ window.addEventListener(type, listener, true);
45
+ listeners.push([type, listener]);
46
+ }
47
+ // Preserve the existing Android handler, including its return value.
48
+ const previousHandler = term._core?._customKeyEventHandler;
49
+ if (!term._core) throw new Error("Unsupported xterm internals");
50
+ const handler = event => {
51
+ const result = previousHandler ? previousHandler.call(term._core, event) : true;
52
+ window.__gottyKeyboardDebug("alt-handler", {
53
+ type: event.type, key: event.key, code: event.code,
54
+ result, defaultPrevented: event.defaultPrevented,
55
+ });
56
+ return result;
57
+ };
58
+ term.attachCustomKeyEventHandler(handler);
59
+ const subscription = term.onData(text => {
60
+ const bytes = new TextEncoder().encode(text);
61
+ window.__gottyKeyboardDebug("terminal-data", {
62
+ length: bytes.length, bytes: Array.from(bytes.slice(0, 64)),
63
+ });
64
+ });
65
+ window.stopGottyKeyboardDebug = () => {
66
+ stopped = true;
67
+ for (const [type, listener] of listeners) window.removeEventListener(type, listener, true);
68
+ subscription.dispose();
69
+ if (term._core._customKeyEventHandler === handler)
70
+ term.attachCustomKeyEventHandler(previousHandler);
71
+ };
72
+ return { records, stop: window.stopGottyKeyboardDebug };
73
+ };
74
+
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ // Run inside jsgotty. Exits after 60 seconds or unmodified Escape/q/Ctrl-C.
3
+ // Uses only built-ins and restores the prior Kitty mode and raw mode on exit.
4
+ const input=process.stdin, output=process.stdout;
5
+ const cyan = '\x1b[96m', reset = '\x1b[0m';
6
+ const keyNames = {
7
+ 9: 'tab', 13: 'enter', 27: 'esc', 32: 'space', 127: 'backspace',
8
+ 57358: 'caps-lock', 57359: 'scroll-lock', 57360: 'num-lock',
9
+ 57361: 'print-screen', 57362: 'pause', 57363: 'menu',
10
+ 57409: 'kp-decimal', 57410: 'kp-divide', 57411: 'kp-multiply',
11
+ 57412: 'kp-subtract', 57413: 'kp-add', 57414: 'kp-enter',
12
+ 57415: 'kp-equal', 57416: 'kp-separator',
13
+ 57417: 'kp-left', 57418: 'kp-right', 57419: 'kp-up', 57420: 'kp-down',
14
+ 57421: 'kp-page-up', 57422: 'kp-page-down', 57423: 'kp-home',
15
+ 57424: 'kp-end', 57425: 'kp-insert', 57426: 'kp-delete', 57427: 'kp-begin',
16
+ 57428: 'media-play', 57429: 'media-pause', 57430: 'media-play-pause',
17
+ 57431: 'media-reverse', 57432: 'media-stop', 57433: 'media-fast-forward',
18
+ 57434: 'media-rewind', 57435: 'media-next', 57436: 'media-previous',
19
+ 57437: 'media-record', 57438: 'volume-down', 57439: 'volume-up',
20
+ 57440: 'volume-mute', 57453: 'altgr', 57454: 'iso-level5-shift',
21
+ };
22
+ for (let n=13; n<=35; n++) keyNames[57376+n-13] = 'f'+n;
23
+ for (let n=0; n<=9; n++) keyNames[57399+n] = 'kp-'+n;
24
+ const modifierKeys = ['shift', 'ctrl', 'alt', 'super', 'hyper', 'meta'];
25
+ const modifierBits = [1, 4, 2, 8, 16, 32];
26
+ function readableChord(key, suffix, bits) {
27
+ const number = Number(key.split(':')[0] || 1);
28
+ let name;
29
+ if (suffix === 'u') {
30
+ if (number >= 57441 && number <= 57452) {
31
+ const index = (number-57441)%6;
32
+ name = modifierKeys[index] + (number >= 57447 ? '-right' : '-left');
33
+ bits &= ~modifierBits[index]; // "super-left", not "super-super-left".
34
+ } else {
35
+ name = keyNames[number];
36
+ if (!name) name = number >= 33 && number <= 0x10ffff &&
37
+ !(number >= 0x7f && number <= 0x9f) &&
38
+ !(number >= 0xd800 && number <= 0xf8ff)
39
+ ? String.fromCodePoint(number) : 'key-'+number;
40
+ }
41
+ } else if (suffix === '~') {
42
+ name = {2:'insert', 3:'delete', 5:'page-up', 6:'page-down', 7:'home', 8:'end',
43
+ 11:'f1', 12:'f2', 13:'f3', 14:'f4', 15:'f5', 17:'f6',
44
+ 18:'f7', 19:'f8', 20:'f9', 21:'f10', 23:'f11', 24:'f12'}[number];
45
+ } else {
46
+ name = {A:'up', B:'down', C:'right', D:'left', E:'begin',
47
+ F:'end', H:'home', P:'f1', Q:'f2', S:'f4'}[suffix];
48
+ }
49
+ const modifiers = [[4,'ctrl'], [2,'alt'], [1,'shift'], [8,'super'],
50
+ [16,'hyper'], [32,'meta'], [64,'caps-lock'], [128,'num-lock']]
51
+ .filter(([bit]) => bits & bit).map(([,label]) => label);
52
+ return [...modifiers, name || `key-${number}${suffix}`].join('-');
53
+ }
54
+ if(!input.isTTY || !output.isTTY) {console.error('Run this inside an interactive jsgotty terminal.');process.exit(1);}
55
+ const wasRaw=Boolean(input.isRaw);let closed=false,buffer='';
56
+ const finish=()=>{if(closed)return;closed=true;clearTimeout(timer);output.write('\x1b[<u\r\nKeyboard mode restored.\r\n');input.setRawMode(wasRaw);input.pause();};
57
+ const timer=setTimeout(finish,60000);
58
+ process.on('SIGINT',finish);process.on('SIGTERM',finish);process.on('exit',finish);
59
+ input.setRawMode(true);input.setEncoding('utf8');input.resume();
60
+ output.write('Kitty keyboard test: try SUP, CTRL, ALT, SHFT + letters/arrows.\r\nEscape, q or Ctrl-C exits; automatic exit in 60 seconds.\r\n\x1b[>11u\x1b[?u');
61
+ input.on('data',data=>{
62
+ if(closed)return;
63
+ buffer+=data;
64
+ while(buffer) {
65
+ const match=/^\x1b\[([?]?)([0-9:;]*)([u~A-Z])/.exec(buffer);
66
+ if(!match) {
67
+ if(buffer==='\x1b' || /^\x1b\[[?0-9:;]*$/.test(buffer))break;
68
+ const ch=[...buffer][0];buffer=buffer.slice(ch.length);output.write('legacy '+JSON.stringify(ch)+'\r\n');
69
+ if(ch==='q'||ch==='\x03') {finish();return;}continue;
70
+ }
71
+ buffer=buffer.slice(match[0].length);
72
+ if(match[1]==='?'){output.write('negotiated flags='+match[2]+' (expected 11)\r\n');continue;}
73
+ const [key,mod='1']=match[2].split(';');const [encoded,type='1']=mod.split(':');const bits=Number(encoded)-1;
74
+ const names=[[1,'Shift'],[2,'Alt'],[4,'Ctrl'],[8,'Super'],[16,'Hyper'],[32,'Meta'],[64,'CapsLock'],[128,'NumLock']].filter(([bit])=>bits&bit).map(([,name])=>name);
75
+ const chord = readableChord(key, match[3], bits);
76
+ output.write(`${cyan}${chord}${reset} ${{1:'press',2:'repeat',3:'release'}[type]||type} raw=${JSON.stringify(match[0])} key=${key}${match[3]} modifiers=${names.join('+')||'none'}\r\n`);
77
+ if(match[3]==='u' && type==='1' && ((!(bits&63)&&['27','113'].includes(key)) || ((bits&63)===4 && key==='99'))) {finish();return;}
78
+ }
79
+ if(buffer.length>4096)buffer='';
80
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buninu",
3
- "version": "0.4.21",
3
+ "version": "0.5.0",
4
4
  "description": "Buninu is a portable, self-bootstrapping Unix-like userspace built on Bun that runs across operating systems",
5
5
  "type": "module",
6
6
  "main": "./bin/init.js",