buninu 0.4.21 → 0.6.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/apps/bunmsh/CHANGELOG.md +4 -0
  3. package/apps/bunmsh/package.json +1 -1
  4. package/apps/bunmsh/src/shell.js +1 -1
  5. package/apps/jsgotty/LICENSES/MIT-jsmdcui.txt +22 -0
  6. package/apps/jsgotty/NOTICE +5 -0
  7. package/apps/jsgotty/README.en.md +57 -0
  8. package/apps/jsgotty/README.md +57 -0
  9. package/apps/jsgotty/client-modes.mjs +117 -0
  10. package/apps/jsgotty/client.mjs +100 -10
  11. package/apps/jsgotty/clipboard-powershell.js +18 -0
  12. package/apps/jsgotty/clipboard.js +148 -0
  13. package/apps/jsgotty/gotty.js +17 -2
  14. package/apps/jsgotty/package-lock.json +2 -2
  15. package/apps/jsgotty/package.json +1 -1
  16. package/apps/jsgotty/static/css/index.css +7 -1
  17. package/apps/jsgotty/static/index.html +1 -0
  18. package/apps/jsgotty/static/js/gotty.js +175 -19
  19. package/apps/jsgotty/static/js/kitty-keyboard.js +193 -0
  20. package/apps/jsgotty/terminal-modes.js +332 -0
  21. package/apps/jsgotty/tests/android-alt.test.js +115 -0
  22. package/apps/jsgotty/tests/arrow-modifiers.test.js +80 -0
  23. package/apps/jsgotty/tests/client-exit.test.mjs +45 -0
  24. package/apps/jsgotty/tests/client-modes.test.mjs +105 -0
  25. package/apps/jsgotty/tests/clipboard-browser.test.js +81 -0
  26. package/apps/jsgotty/tests/clipboard.test.js +75 -0
  27. package/apps/jsgotty/tests/kitty-keyboard-browser.test.js +53 -0
  28. package/apps/jsgotty/tests/kitty-keyboard.test.js +102 -0
  29. package/apps/jsgotty/tests/manual/README.md +20 -0
  30. package/apps/jsgotty/tests/manual/keyboard-collector.cjs +55 -0
  31. package/apps/jsgotty/tests/manual/keyboard-debug.js +74 -0
  32. package/apps/jsgotty/tests/manual/kitty-keyboard.js +80 -0
  33. package/apps/jsgotty/tests/manual-touch-diagnostics.js +33 -0
  34. package/apps/jsgotty/tests/manual-touch-diagnostics.mjs +22 -0
  35. package/apps/jsgotty/tests/nested-cli-pty.test.mjs +116 -0
  36. package/apps/jsgotty/tests/reconnect-modes-browser.test.js +144 -0
  37. package/apps/jsgotty/tests/reconnect-modes.md +44 -0
  38. package/apps/jsgotty/tests/reconnect-modes.test.js +166 -0
  39. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 - 2026-10-25
4
+
5
+ - Sync jsgotty to 1.3.0:
6
+ * Add OSC 52 clipboard reads and writes for browser terminals using the
7
+ server host's clipboard. Try xclip first on every platform, then available
8
+ platform tools, with an in-memory fallback when all tools fail. Only the
9
+ session writer performs clipboard operations; CLI clients pass OSC 52
10
+ through to their terminal emulator.
11
+ * Restore mouse reporting, mouse encoding, Kitty keyboard state and other
12
+ terminal modes when reconnecting to an existing session, including from
13
+ a fresh terminal and through nested CLI clients. Preserve keyboard mode
14
+ stacks without unnecessarily switching screens.
15
+ * Probe and restore the CLI client's original terminal modes on exit;
16
+ handle unsupported mode replies and independently enabled mouse modes
17
+ without leaving stale mouse reporting behind.
18
+ * Fix read-only CLI viewer exit shortcuts (Ctrl-C, Ctrl-D and Ctrl-Q) when
19
+ the enclosing terminal uses Kitty keyboard encoding.
20
+ * Fix Android WebView double-tap handling at non-default viewport zoom so
21
+ terminal clicks reach applications instead of triggering browser zoom,
22
+ while retaining two-finger pinch zoom.
23
+
24
+ ## 0.5.0 - 2026-10-21
25
+
26
+ - Sync jsgotty to 1.2.0:
27
+ * Add opt-in Kitty keyboard support for disambiguated shortcuts, including
28
+ Super, and press/repeat/release events. Support flags 1, 2 and 8, mode
29
+ queries, and independent mode stacks for normal and alternate screens;
30
+ legacy keyboard encoding remains the default.
31
+ * Recover Super state on Android WebViews that deliver Meta key events but
32
+ omit the modifier state. Handle Android Alt shortcuts without accent
33
+ dead-key conversion, including recognizable keyup-only events.
34
+ * Preserve Alt on arrow, Insert, PageUp and PageDown shortcuts, and encode
35
+ Ctrl+/ as the conventional US control character.
36
+ * Add a manual keyboard tester with readable, colored shortcut names and
37
+ raw sequences; restore keyboard and raw modes when it exits.
38
+ * Allow local Kitty graphics consumers to request binary image data,
39
+ avoiding a base64 conversion; browser clients retain base64 by default.
40
+ - Sync bunmsh to 0.4.1: use the existing linker-aware shell launcher for the
41
+ awk fallback's subprocess commands instead of bypassing linker handling.
42
+
3
43
  ## 0.4.21 - 2026-10-15
4
44
 
5
45
  - 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;
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2016-2020: Zachary Yedidia, et al.
4
+ Copyright (c) 2026 Dr. John (醫者小智)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -119,3 +119,8 @@ such an executable must also follow Bun's license terms, including the LGPL
119
119
  relinking provisions described at
120
120
  https://github.com/oven-sh/bun/blob/main/LICENSE.md. The executables carry
121
121
  LICENSE, NOTICE and LICENSES/ as embedded assets.
122
+
123
+ Clipboard backend helpers reference jsmdcui (MIT).
124
+ clipboard-powershell.js retains its powershellClipboard function verbatim from
125
+ jsmdcui/src/platform/clipboard.js, Copyright (c) 2026 Dr. John (醫者小智).
126
+ Full source license: LICENSES/MIT-jsmdcui.txt.
@@ -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
@@ -292,3 +332,20 @@ bun sz.js file.txt
292
332
  - Bun is the primary runtime.
293
333
  - On Linux / macOS, PTY support will not work unless the program is launched with Bun.
294
334
  - GoTTY config file loading is not implemented yet.
335
+
336
+ ## OSC 52 clipboard
337
+
338
+ The browser frontend supports OSC 52 writes and `?` queries against the **server host's clipboard**, not the browser device's clipboard. Only the session writer can request access; viewers do not duplicate operations. Query replies are sent directly back to the originating PTY as OSC 52 sequences.
339
+
340
+ Each operation tries `xclip` first, including on Windows and Android. Missing or failing commands fall through to applicable `termux-clipboard-*`, `wl-copy`/`wl-paste`, `xsel`, macOS `pbcopy`/`pbpaste`, then Windows PowerShell. The PowerShell function is copied verbatim from jsmdcui. If all tools fail, the server uses its in-memory cache. A successful empty read remains empty, rather than returning stale cached text. There is no polling; external commands run in a worker.
341
+
342
+ Supported selections: `c` (clipboard), `p` (primary), `q` (secondary); `s` maps to clipboard, and `0`–`7` are in-memory buffers. An empty selector uses `s0`. Tools without primary/secondary support do not substitute the clipboard. Requests are limited to 1 MiB.
343
+
344
+ ```sh
345
+ # Write to the server clipboard
346
+ printf '\033]52;c;%s\007' "$(printf 'Hello' | base64 | tr -d '\n')"
347
+ ```
348
+
349
+ Applications can send `ESC ] 52 ; c ; ? ST` to read and receive a base64-encoded OSC 52 reply on stdin.
350
+
351
+ `jsgotty --client` **does not intercept OSC 52**: it forwards reads and writes unchanged for the outer terminal emulator to handle.
@@ -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
@@ -294,3 +334,20 @@ bun sz.js file.txt
294
334
  - 以 Bun 為主要執行方式。
295
335
  - Linux / macOS 如果不是用 Bun 執行,PTY 會無法使用。
296
336
  - 目前未實作 Go 版的 config 檔載入
337
+
338
+ ## OSC 52 剪貼板
339
+
340
+ 瀏覽器版 jsgotty 支援 OSC 52 寫入與 `?` 查詢。操作的是 **server 所在主機**的剪貼板,不是瀏覽器裝置的剪貼板。只有 session 的 writer 可以執行;viewer 不會重複操作。查詢結果以 OSC 52 回覆直接送回原本的 PTY。
341
+
342
+ 每次操作都先嘗試 `xclip`(包括 Windows/Android),不存在或執行失敗才依序嘗試適用的 `termux-clipboard-*`、`wl-copy`/`wl-paste`、`xsel`、macOS 的 `pbcopy`/`pbpaste`、Windows 的 PowerShell。PowerShell 函式原封不動取自 jsmdcui。所有工具都失敗時使用 server 記憶體快取;成功讀到空字串就是空剪貼板,不會被舊快取取代。沒有輪詢,外部工具在 worker 中執行。
343
+
344
+ 支援 `c`(clipboard)、`p`(primary)、`q`(secondary);`s` 對應 clipboard,`0`~`7` 為記憶體暫存。空選擇使用 `s0`。不提供 primary/secondary 的工具不會拿 clipboard 冒充。單次 OSC 52 請求上限 1 MiB。
345
+
346
+ ```sh
347
+ # 寫入 server 剪貼板
348
+ printf '\033]52;c;%s\007' "$(printf 'Hello' | base64 | tr -d '\n')"
349
+ ```
350
+
351
+ 程式可送出 `ESC ] 52 ; c ; ? ST` 讀取,並從 stdin 接收 base64 編碼的 OSC 52 回覆。
352
+
353
+ `jsgotty --client` **不攔截 OSC 52**,讀寫控制碼都原樣轉送,交給最外層真正的終端模擬器決定。
@@ -0,0 +1,117 @@
1
+ // Read the terminal before attaching. No reset and no guessed default modes.
2
+ import { randomBytes } from 'node:crypto';
3
+ import modes from './terminal-modes.js';
4
+ const {TerminalModeTracker, validateSnapshot, TRACKED_DEC_FLAGS, MOUSE_PROTOCOLS, MOUSE_ENCODINGS} = modes;
5
+ const ESC = '\x1b';
6
+ export class ModeProbe {
7
+ constructor(nonce = randomBytes(16).toString('hex')) {
8
+ this.nonce = nonce;
9
+ this.pending = Buffer.alloc(0);
10
+ this.complete = false;
11
+ this.kittyRestore = false;
12
+ this.state = new TerminalModeTracker().snapshot();
13
+ for (const key of Object.keys(this.state.dec)) this.state.dec[key] = null;
14
+ for (const key of ['alternate','cursorHidden','mouse','encoding']) this.state[key] = null;
15
+ this.state.kitty.normal.flags = this.state.kitty.alternate.flags = null;
16
+ this.state.mouseModeBits = Object.fromEntries(Object.keys(MOUSE_PROTOCOLS).map(n=>[n,null]));
17
+ this.state.mouseEncodingBits = Object.fromEntries(Object.keys(MOUSE_ENCODINGS).map(n=>[n,null]));
18
+ this.replies = new Map();
19
+ this.outstanding = new Set();
20
+ this.kittyOutstanding = false;
21
+ this.privatePrefix = `${ESC}]777;jsgotty-modes;${nonce};`;
22
+ }
23
+ privateQuery() { return `${ESC}]777;jsgotty-modes;${this.nonce}\x07`; }
24
+ standardQuery() {
25
+ this.outstanding = new Set([...Object.keys(TRACKED_DEC_FLAGS),25,1049,...Object.keys(MOUSE_PROTOCOLS),...Object.keys(MOUSE_ENCODINGS)].map(Number));
26
+ this.kittyOutstanding = true;
27
+ this.replyDeadline = Date.now() + 1500;
28
+ return [...this.outstanding]
29
+ .map(n => `${ESC}[?${n}$p`).join('') + `${ESC}[?u`;
30
+ }
31
+ reply(mode, value) {
32
+ if (this.complete) return;
33
+ const yes = value === 1 || value === 3;
34
+ // An unsupported mode cannot be active, but unlike silence it is a reply.
35
+ // Still aggregate below when 0 is the last answer in a mouse-mode group.
36
+ this.replies.set(mode, yes);
37
+ if (MOUSE_PROTOCOLS[mode]) this.state.mouseModeBits[mode] = yes;
38
+ if (MOUSE_ENCODINGS[mode]) this.state.mouseEncodingBits[mode] = yes;
39
+ if (value !== 0) {
40
+ if (TRACKED_DEC_FLAGS[mode]) this.state.dec[TRACKED_DEC_FLAGS[mode]] = yes;
41
+ else if (mode === 25) this.state.cursorHidden = !yes;
42
+ else if (mode === 1049) this.state.alternate = yes;
43
+ }
44
+ for (const [name, table, fallback, priority] of [
45
+ ['mouse',MOUSE_PROTOCOLS,'NONE',[1003,1002,1000,9]],
46
+ ['encoding',MOUSE_ENCODINGS,'DEFAULT',[1016,1006]],
47
+ ]) {
48
+ const active = priority.find(key => this.replies.get(key) === true);
49
+ if (active) this.state[name] = table[active];
50
+ else if (Object.keys(table).every(key => this.replies.has(+key))) this.state[name] = fallback;
51
+ }
52
+ }
53
+ consume(chunk) {
54
+ if ((this.complete && !this.outstanding.size && !this.kittyOutstanding) ||
55
+ (this.replyDeadline && Date.now() > this.replyDeadline)) {
56
+ this.outstanding.clear(); this.kittyOutstanding = false;
57
+ return Buffer.concat([this.flush(), Buffer.from(chunk)]);
58
+ }
59
+ this.pending = Buffer.concat([this.pending, Buffer.from(chunk)]);
60
+ const out = [];
61
+ while (this.pending.length) {
62
+ const text = this.pending.toString('latin1');
63
+ if (text.startsWith(this.privatePrefix)) {
64
+ const end = text.indexOf('\x07', this.privatePrefix.length);
65
+ if (end < 0 && text.length < 16384) break;
66
+ if (end >= 0) {
67
+ try {
68
+ const reply = JSON.parse(Buffer.from(text.slice(this.privatePrefix.length,end),'base64').toString());
69
+ this.state = validateSnapshot(reply);
70
+ this.kittyRestore = reply.kittyRestore === 1;
71
+ this.complete = true;
72
+ } catch {}
73
+ this.pending = this.pending.subarray(end+1); continue;
74
+ }
75
+ }
76
+ const dec = /^\x1b\[\?(\d+);([0-4])\$y/.exec(text);
77
+ const kitty = /^\x1b\[\?(\d+)u/.exec(text);
78
+ if ((dec && this.outstanding.delete(+dec[1])) || (kitty && this.kittyOutstanding)) {
79
+ if (dec) this.reply(+dec[1],+dec[2]);
80
+ else {
81
+ this.kittyOutstanding = false;
82
+ if (!this.complete && this.state.alternate != null)
83
+ this.state.kitty[this.state.alternate?'alternate':'normal'].flags = +kitty[1] & 31;
84
+ }
85
+ this.pending = this.pending.subarray((dec||kitty)[0].length); continue;
86
+ }
87
+ // Hold only possible query replies; normal keystrokes and mouse packets pass unchanged.
88
+ if (this.privatePrefix.startsWith(text) || (text.length < 64 && /^\x1b\[\?[0-9;\$]*$/.test(text)) || text === ESC+'[') break;
89
+ // Forward ordinary input in spans; large pastes must not become quadratic.
90
+ const next = this.pending.indexOf(0x1b, 1);
91
+ const count = next < 0 ? this.pending.length : next;
92
+ out.push(this.pending.subarray(0,count));this.pending=this.pending.subarray(count);
93
+ }
94
+ return Buffer.concat(out);
95
+ }
96
+ flush() { const data=this.pending;this.pending=Buffer.alloc(0);return data; }
97
+ }
98
+ export async function captureTerminalModes(input, output, {timeout=1000, probe=new ModeProbe()}={}) {
99
+ const buffered=[];
100
+ if (!input.isTTY || !output.isTTY) {
101
+ probe.replyDeadline = 1;
102
+ return {state:probe.state, probe, buffered:Buffer.alloc(0)};
103
+ }
104
+ const raw=!!input.isRaw;
105
+ input.setRawMode(true);
106
+ await new Promise(resolve=>{
107
+ let finished=false;
108
+ const finish=()=>{if(finished)return;finished=true;clearTimeout(fallback);clearTimeout(end);input.off('data',data);resolve();};
109
+ const data=chunk=>{const rest=probe.consume(chunk);if(rest.length)buffered.push(rest);if(probe.complete)finish();};
110
+ const fallback=setTimeout(()=>output.write(probe.standardQuery()),Math.min(200,timeout/2));
111
+ const end=setTimeout(finish,timeout);
112
+ input.on('data',data);input.resume();output.write(probe.privateQuery());
113
+ });
114
+ input.pause();input.setRawMode(raw);
115
+ buffered.push(probe.flush());
116
+ return {state:validateSnapshot(probe.state),probe,buffered:Buffer.concat(buffered)};
117
+ }
@@ -4,6 +4,10 @@ import process from "node:process";
4
4
  import { Buffer } from "node:buffer";
5
5
  import WebSocket from "ws";
6
6
  import { encodeKittyGraphics, syntheticKittyCursorMotion } from "./client-kitty.mjs";
7
+ import terminalModes from "./terminal-modes.js";
8
+
9
+ const { TerminalModeTracker, modeTransition, validateSnapshot } = terminalModes;
10
+ import { captureTerminalModes } from "./client-modes.mjs";
7
11
 
8
12
  const MSG_INPUT = "1";
9
13
  const MSG_PING = "2";
@@ -19,6 +23,7 @@ const MSG_SET_BUFFER_SIZE = "6";
19
23
  const MSG_KITTY_GRAPHICS = "7";
20
24
  const MSG_SET_ROLE = "a";
21
25
  const MSG_CONTROL_RESULT = "b";
26
+ const MSG_RESTORE_MODES = "m";
22
27
 
23
28
  const DEFAULT_URL = "ws://127.0.0.1:8080/ws";
24
29
 
@@ -409,6 +414,40 @@ async function main() {
409
414
  return;
410
415
  }
411
416
  const args = buildArguments(options, initArguments);
417
+ const originalRawMode = !!process.stdin.isRaw;
418
+ const localModes = new TerminalModeTracker();
419
+ // A closed terminal surfaces as an async EIO/EPIPE, not a throw. Nobody is
420
+ // left to read anything, and the PTY keeps its modes, so just exit.
421
+ for (const stream of [process.stdin, process.stdout, process.stderr]) {
422
+ stream.on("error", (error) => {
423
+ if (error.code !== "EIO" && error.code !== "EPIPE") {
424
+ throw error;
425
+ }
426
+ process.exit(process.exitCode ?? 1);
427
+ });
428
+ }
429
+ const writeTerminal = (data) => {
430
+ process.stdout.write(data);
431
+ localModes.consume(Buffer.from(data));
432
+ };
433
+ const captured = await captureTerminalModes(process.stdin, process.stdout);
434
+ const originalModes = captured.state;
435
+ Object.assign(localModes, validateSnapshot(originalModes));
436
+ let bufferedInput = captured.buffered;
437
+ let probeFlushTimer, replayingProbe = false;
438
+ const terminalInput = chunk => {
439
+ if (replayingProbe) return Buffer.from(chunk);
440
+ const input = captured.probe.consume(chunk);
441
+ clearTimeout(probeFlushTimer);
442
+ // A lone Escape is user input too. Never hold it indefinitely while
443
+ // filtering a split or late response to our startup queries.
444
+ if (captured.probe.pending.length) probeFlushTimer = setTimeout(() => {
445
+ const pending = captured.probe.flush();
446
+ replayingProbe = true;
447
+ try { process.stdin.emit("data", pending); } finally { replayingProbe = false; }
448
+ }, 30);
449
+ return input;
450
+ };
412
451
  writeStatus(`[gotty] connecting to\n ${wsUrl}`);
413
452
  const ws = new WebSocket(wsUrl, "webtty");
414
453
  let pingTimer = null;
@@ -432,7 +471,8 @@ async function main() {
432
471
  if (!writable || ws.readyState !== WebSocket.OPEN) {
433
472
  return;
434
473
  }
435
- ws.send(MSG_INPUT + Buffer.from(chunk).toString("base64"));
474
+ const input = terminalInput(chunk);
475
+ if (input.length) ws.send(MSG_INPUT + input.toString("base64"));
436
476
  };
437
477
 
438
478
  const handleResize = () => {
@@ -441,21 +481,52 @@ async function main() {
441
481
  }
442
482
  };
443
483
 
484
+ let exiting = false;
444
485
  const exitClient = (code = 0) => {
486
+ if (exiting) return;
487
+ exiting = true;
445
488
  cleanup();
446
489
  reportDisconnected();
447
490
  ws.close();
448
- process.exit(code);
491
+ process.stdout.write("", () => process.exit(code));
449
492
  };
450
493
 
494
+ // Restoring the viewed session also restores Kitty keyboard modes. Exit
495
+ // keys can therefore arrive as CSI u rather than legacy control bytes.
496
+ // Keep CSI packets across chunks; never treat a key-release as a press.
497
+ let viewerSequence = "";
451
498
  const handleViewerInput = (chunk) => {
452
- for (const byte of Buffer.from(chunk)) {
453
- if (byte === 0x03) {
454
- exitClient(130);
499
+ for (const byte of terminalInput(chunk)) {
500
+ if (viewerSequence) {
501
+ viewerSequence += String.fromCharCode(byte);
502
+ if (viewerSequence === "\x1b[") continue;
503
+ if (viewerSequence.length === 2 && byte !== 0x5b) {
504
+ viewerSequence = "";
505
+ continue;
506
+ }
507
+ if (viewerSequence.length > 2 && byte >= 0x40 && byte <= 0x7e) {
508
+ const sequence = viewerSequence;
509
+ viewerSequence = "";
510
+ const kitty = /^\x1b\[(\d+)(?::[0-9:]*)?(?:;(\d+)(?::([123]))?)?(?:;[0-9:]+)?u$/.exec(sequence);
511
+ const modified = /^\x1b\[27;(\d+);(\d+)~$/.exec(sequence);
512
+ const key = kitty ? +kitty[1] : modified ? +modified[2] : 0;
513
+ const modifiers = (kitty ? +(kitty[2] || 1) : modified ? +modified[1] : 1) - 1;
514
+ // Ignore Caps/Num Lock, but require Ctrl without Alt/Shift/Super.
515
+ if ((modifiers & ~192) === 4 && (!kitty || kitty[3] !== "3")) {
516
+ if (key === 99) return exitClient(130);
517
+ if (key === 100 || key === 113) return exitClient(0);
518
+ }
519
+ } else if (viewerSequence.length > 128) {
520
+ viewerSequence = "";
521
+ }
522
+ continue;
455
523
  }
456
- if (byte === 0x04 || byte === 0x11) {
457
- exitClient(0);
524
+ if (byte === 0x1b) {
525
+ viewerSequence = "\x1b";
526
+ continue;
458
527
  }
528
+ if (byte === 0x03) return exitClient(130);
529
+ if (byte === 0x04 || byte === 0x11) return exitClient(0);
459
530
  }
460
531
  };
461
532
 
@@ -498,7 +569,8 @@ async function main() {
498
569
  return;
499
570
  }
500
571
 
501
- const input = Buffer.from(chunk);
572
+ const input = terminalInput(chunk);
573
+ if (!input.length) return;
502
574
  fallbackProbe = normalizeEcho(input.toString("utf8"));
503
575
  ws.send(MSG_INPUT + input.toString("base64"));
504
576
  stopFallbackInput();
@@ -532,6 +604,10 @@ async function main() {
532
604
  setRawMode(true);
533
605
  rawEnabled = process.stdin.isTTY;
534
606
  sendResize(ws, options);
607
+ if (bufferedInput.length) {
608
+ ws.send(MSG_INPUT + bufferedInput.toString("base64"));
609
+ bufferedInput = Buffer.alloc(0);
610
+ }
535
611
  return;
536
612
  }
537
613
 
@@ -558,6 +634,7 @@ pid:
558
634
  return;
559
635
  }
560
636
  closed = true;
637
+ clearTimeout(probeFlushTimer);
561
638
  if (pingTimer) {
562
639
  clearInterval(pingTimer);
563
640
  pingTimer = null;
@@ -565,8 +642,13 @@ pid:
565
642
  setWritable(false);
566
643
  stopFallbackInput();
567
644
  stopViewerInput();
645
+ if (process.stdout.isTTY) {
646
+ try {
647
+ writeTerminal(modeTransition(localModes.snapshot(), originalModes, { normalizeMouse: true, kittyRestore: captured.probe.kittyRestore }));
648
+ } catch {}
649
+ }
568
650
  if (rawEnabled) {
569
- setRawMode(false);
651
+ setRawMode(originalRawMode);
570
652
  rawEnabled = false;
571
653
  }
572
654
  process.stdin.pause();
@@ -611,7 +693,7 @@ pid:
611
693
  break;
612
694
  }
613
695
  pendingKittyCursorMotion = "";
614
- process.stdout.write(output);
696
+ writeTerminal(output);
615
697
  if (fallbackProbe && accessRole === "") {
616
698
  fallbackOutputTail = normalizeEcho(
617
699
  `${fallbackOutputTail}${output.toString("utf8")}`
@@ -641,6 +723,14 @@ pid:
641
723
  if (encoded) process.stdout.write(encoded);
642
724
  } catch {}
643
725
  break;
726
+ case MSG_RESTORE_MODES:
727
+ // Only local terminal state; the PTY keeps its modes for the next resume.
728
+ if (process.stdout.isTTY) {
729
+ try {
730
+ writeTerminal(modeTransition(localModes.snapshot(), validateSnapshot(JSON.parse(payload)), { normalizeMouse: true, kittyRestore: captured.probe.kittyRestore }));
731
+ } catch {}
732
+ }
733
+ break;
644
734
  case MSG_SET_RECONNECT: {
645
735
  try {
646
736
  const reconnect = JSON.parse(payload);
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ // Copied verbatim from jsmdcui/src/platform/clipboard.js (MIT).
3
+ // Keep the function below unchanged; inject its existing command helpers.
4
+ module.exports = function createPowerShellClipboard(shell, runSync, outputOrThrow) {
5
+ function powershellClipboard(shell) {
6
+ return {
7
+ name: "powershell",
8
+ // Get-Clipboard -Raw appends \r\n to stdout; strip exactly one trailing line ending.
9
+ read: () => outputOrThrow(runSync([shell, "-NoProfile", "-Command", "Get-Clipboard -Raw"], {})).replace(/\r?\n$/, ""),
10
+ write: (text) => {
11
+ text=(text+'').replaceAll("'","''") ;
12
+ return runSync([shell, "-NoProfile", "-Command", `Set-Clipboard '${text}'`], { stdout: "ignore" }).ok ;
13
+ },
14
+ };
15
+ }
16
+
17
+ return powershellClipboard(shell);
18
+ };