From 8ad6be21930370937f7fe6ed168e2483fb696bb6 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 29 Mar 2026 09:35:01 -0400 Subject: [PATCH 01/19] feat: add ADR 019 for keyboard sequence compatibility in nested terminal chains --- .../adrs/019.config.keyboard-binding-chars.md | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 docs/adrs/019.config.keyboard-binding-chars.md diff --git a/docs/adrs/019.config.keyboard-binding-chars.md b/docs/adrs/019.config.keyboard-binding-chars.md new file mode 100644 index 0000000..99c6d06 --- /dev/null +++ b/docs/adrs/019.config.keyboard-binding-chars.md @@ -0,0 +1,240 @@ +# ADR 019: Config — Keyboard sequence compatibility in nested terminal chains + +**SPEC:** [config](../specs/config.md) +**Status:** Accepted +**Date:** 2026-03-29 + +--- + +## Context + +ADR 018 introduced `keyboardBindings` and recommended `"\u001b[13;2u"` as the +`chars` value for Shift+Enter — the Kitty Keyboard Protocol (KKP) encoding. +That recommendation holds for the common case of a single terminal layer. It +breaks silently in nested terminal environments for reasons that are +architectural, not incidental. + +### The terminal chain architecture problem + +A "terminal chain" is any setup where a terminal emulator sits between the +outer terminal and the target application — for example, vim's `:terminal`, +tmux, GNU screen, or any shell running inside another shell. Each link in the +chain is an independent terminal emulator with its own capability model. + +**Keyboard protocol negotiation is point-to-point, not end-to-end.** + +When an app starts, it queries its immediate terminal for capability support: +`\u001b[?u` for KKP, or equivalent for other protocols. The terminal it is +talking to is the process on the other end of its PTY — which in a nested +setup is the intermediate emulator, not the outermost terminal. The outer +terminal (webtty, Alacritty, etc.) is not in that negotiation at all. + +For KKP to work across a nested chain, every intermediate emulator would need +to implement **protocol forwarding**: detect the inner app's capability query, +proxy it up through all layers to the true outer terminal, collect the +response, and relay it back down. It would then need to forward all KKP-encoded +input from the outer terminal to the inner app transparently, without +consuming or re-encoding it. + +This is a hard problem. It requires every link in the chain to actively +participate. In practice, intermediate emulators (vim `:terminal`, tmux, +screen) present their own terminal model to inner processes. They do not +transparently expose the outer terminal's capabilities, and most do not +implement KKP forwarding at all. + +**The consequence:** KKP is only reliable between directly adjacent processes. +In any chain longer than one hop, KKP support depends entirely on whether +every intermediate emulator implements forwarding — a property the outermost +terminal cannot observe or control. + +**Legacy encoding does not have this problem.** Legacy escape codes require no +negotiation. Every terminal emulator in the chain forwards input bytes to its +inner PTY unconditionally. The sequence arrives at the target application +regardless of how many layers it passed through or which capabilities any of +them advertise. + +The tradeoff is that legacy encoding carries no formal protocol: meaning is +agreed upon by convention between terminal and app, not guaranteed by a +negotiated handshake. Legacy escape codes are old and widely supported, but +there is no in-band capability flag that confirms the app will interpret them +correctly. + +### The two sequences for Shift+Enter + +| Sequence | Encoding | How activated | +|---|---|---| +| `\u001b[13;2u` | `ESC [ 13 ; 2 u` | KKP — requires negotiation between adjacent terminal and app | +| `\u001b\r` | `ESC CR` | Legacy encoding — no negotiation required | + +Both are understood by opencode, Helix, and other modern TUI apps as +Shift+Enter. They differ in whether they require protocol agreement between +adjacent layers. + +### Our use case + +In the direct setup (single hop): + +``` +browser → webtty ←→ opencode +``` + +opencode negotiates KKP directly with webtty's PTY environment, enters KKP +mode, and `\u001b[13;2u` is understood. `\u001b\r` also works here — opencode +supports both KKP and legacy encoding. + +When a user runs opencode inside vim's `:terminal` (a common workflow): + +``` +browser → webtty ←→ vim :terminal ←→ opencode +``` + +opencode now negotiates KKP with **vim's terminal emulator**. Vim `:terminal` +does not implement KKP. It neither responds to `\u001b[?u` affirmatively nor +forwards the query up to webtty. opencode never enters KKP mode. When webtty +sends `\u001b[13;2u`, opencode does not recognise it as Shift+Enter. The +keypress is silently lost. + +The legacy sequence (`\u001b\r`) passes through the same chain cleanly: +vim `:terminal` forwards it to its inner PTY, and opencode receives it +regardless of whether KKP was negotiated. + +The same breakage with `\u001b[13;2u` reproduces in VS Code's integrated +terminal hosting a vim `:terminal` session, confirming it is a property of +the chain structure, not of webtty specifically. + +Alacritty's default Shift+Enter binding uses legacy encoding for this reason: + +```toml +[[keyboard.bindings]] +key = "Return" +mods = "Shift" +chars = "\u001B\r" +``` + +Users running `alacritty → vim :terminal → opencode` report Shift+Enter +working correctly — the legacy sequence survives all layers. + +### Compatibility matrix + +| Sequence | direct (1 hop) | via vim :term (2 hops) | alacritty direct | +|---|---|---|---| +| `\u001b[13;2u` | ✅ KKP negotiated | ❌ vim :term does not forward KKP | ✅ | +| `\u001b\r` | ✅ | ✅ | ✅ | + +--- + +## Decision + +The recommended `chars` value for Shift+Enter in `keyboardBindings` is +`"\u001b\r"`, not `"\u001b[13;2u"`. + +`"\u001b[13;2u"` is not deprecated. It still works in direct single-hop +setups. Users who are certain their workflow never involves an intermediate +terminal may prefer it. It should not be the primary recommendation because +nested terminal usage is common and the failure is silent. + +The `config.md` spec will be updated: `"\u001b\r"` becomes the primary +example; `"\u001b[13;2u"` is noted as valid for single-hop setups only. + +--- + +## Considered Options + +### Option A: Keep `"\u001b[13;2u"` as the recommendation + +Correct for the direct case. Silently broken whenever an intermediate terminal +emulator is in the chain — a common setup. Users get no Shift+Enter and no +error message. + +**Rejected** — silent failure in a common workflow is worse than a less +"modern" default. + +### Option B: Recommend `"\u001b\r"`, note `"\u001b[13;2u"` as an alternative (chosen) + +`"\u001b\r"` works across all tested scenarios. The cost is that it uses +legacy encoding rather than a negotiated protocol. Both sequences are +understood by every app that supports Shift+Enter at all, so the cost is +theoretical for current apps. + +### Option C: Detect nested environments and switch sequences automatically + +Not feasible. webtty is the outermost layer; it sends bytes into a PTY and +has no visibility into the process tree on the other side. The number of +terminal layers between webtty and the target app is not observable. + +--- + +## Consequences + +- `config.md` will be updated: `"\u001b\r"` replaces `"\u001b[13;2u"` as the + primary binding example for Shift+Enter. +- Existing users with `"\u001b[13;2u"` who use webtty directly are unaffected. +- Users running TUI apps inside vim `:terminal` (or any other non-KKP-forwarding + intermediate terminal) should switch to `"\u001b\r"`. + +--- + +## Q&A + +**Q: What if an app in the chain only supports KKP and not legacy encoding?** + +Then Shift+Enter is broken in nested terminal setups and no `chars` value that +webtty sends can fix it. webtty is the outermost terminal; it has no mechanism +to reach an inner app directly over the intermediate emulator's head. The fix +must be in the intermediate emulator: it needs to implement KKP protocol +forwarding so that the inner app's negotiation reaches the outer terminal. + +In practice this scenario does not arise today. No widely-used TUI framework +(bubbletea, ratatui, textual) drops legacy encoding support, because KKP is +still not universal and doing so would break compatibility with the majority of +terminals. Apps negotiate KKP when available and fall back to legacy encoding +when not. A future app that deliberately drops legacy support would be making +an explicit compatibility tradeoff. + +**Q: Can this problem ever be fully solved at the webtty layer?** + +No. The problem is structural: capability negotiation is point-to-point and +intermediate emulators present their own terminal model. webtty can only +control what it sends into the PTY. It cannot know or influence how many +emulator layers sit between it and the target app, or whether those layers +implement protocol forwarding. + +The complete solution requires the intermediate emulators to participate — +either by implementing KKP forwarding (as kitty can be configured to do) or +by using legacy encoding, which requires no negotiation and passes through +all layers by default. + +**Q: Does tmux or screen have the same problem?** + +Yes. tmux and GNU screen are terminal multiplexers that act as intermediate +emulators. Neither implements KKP forwarding by default. Apps running inside +a tmux or screen session will not enter KKP mode regardless of whether the +outer terminal supports it. Legacy encoding passes through both without issue +for the same reason it passes through vim `:terminal`. + +**Q: KKP sequences like `\u001b[13;2u` still start with `\u001b` — why does KKP need ESC if it encodes everything in `13;2u`?** + +`\u001b[` together is CSI — Control Sequence Introducer — a two-byte prefix +inherited from ECMA-48 (1976) that tells the terminal parser "switch from +character mode into control sequence mode." The `[` on its own would just be +a literal `[`; ESC is what signals the parser to treat what follows as a +structured control sequence rather than printable text. KKP only defines the +payload — the parameter format (`keycode;modifier`) and the final byte (`u`). +It is built on top of the existing CSI framework, not a replacement for it. + +ESC plays a different role in each encoding: + +| Sequence | Role of ESC | +|---|---| +| `\u001b\r` (legacy) | Prefix modifier — "the next character is modified" | +| `\u001b[13;2u` (KKP) | CSI introducer — "what follows is a structured control sequence" | + +In legacy encoding ESC carries the meaning. In KKP, ESC is framing +infrastructure — the meaning lives in `13;2u`. + +--- + +## Related Decisions + +- [ADR 018 — Configurable keyboard bindings](018.client.keyboard-bindings.md): + introduced `keyboardBindings` and originally recommended `"\u001b[13;2u"`. From f2c435e3fe1403578ee8c8cee41a413c5c55a094 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 29 Mar 2026 09:40:11 -0400 Subject: [PATCH 02/19] feat: update keyboard binding specifications and add new keyboard spec document --- docs/adrs/018.client.keyboard-bindings.md | 2 +- .../adrs/019.config.keyboard-binding-chars.md | 2 +- docs/specs/client.md | 2 +- docs/specs/config.md | 66 +-------- docs/specs/keyboard.md | 140 ++++++++++++++++++ 5 files changed, 146 insertions(+), 66 deletions(-) create mode 100644 docs/specs/keyboard.md diff --git a/docs/adrs/018.client.keyboard-bindings.md b/docs/adrs/018.client.keyboard-bindings.md index ab70e08..b1946b6 100644 --- a/docs/adrs/018.client.keyboard-bindings.md +++ b/docs/adrs/018.client.keyboard-bindings.md @@ -1,6 +1,6 @@ # ADR 018: Client — Configurable keyboard bindings -**SPEC:** [client](../specs/client.md), [config](../specs/config.md) +**SPEC:** [client](../specs/client.md), [config](../specs/config.md), [keyboard](../specs/keyboard.md) **Status:** Accepted **Date:** 2026-03-28 diff --git a/docs/adrs/019.config.keyboard-binding-chars.md b/docs/adrs/019.config.keyboard-binding-chars.md index 99c6d06..7567f38 100644 --- a/docs/adrs/019.config.keyboard-binding-chars.md +++ b/docs/adrs/019.config.keyboard-binding-chars.md @@ -1,6 +1,6 @@ # ADR 019: Config — Keyboard sequence compatibility in nested terminal chains -**SPEC:** [config](../specs/config.md) +**SPEC:** [keyboard](../specs/keyboard.md) **Status:** Accepted **Date:** 2026-03-29 diff --git a/docs/specs/client.md b/docs/specs/client.md index c0f6de1..d14fb0d 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -110,7 +110,7 @@ A capture-phase `keydown` listener on the terminal container fires before ghostt **`chars` encoding:** The client sends `binding.chars` verbatim. Standard JSON escapes (`\uXXXX`, `\r`, `\n`, `\t`) are resolved by `JSON.parse` at config load — no further processing occurs. -See [config SPEC](config.md#keyboard-binding-objects) for the binding object schema and built-in defaults. +See [keyboard spec](keyboard.md) for the binding object schema and examples. ## Copy Behavior diff --git a/docs/specs/config.md b/docs/specs/config.md index e6a5f5d..bb9d8d4 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -120,7 +120,7 @@ All keys are optional — omit any key to use the default value. | `fontSize` | number | `13` | Font size in px | | `fontFamily` | string | `"Menlo, Consolas, 'DejaVu Sans Mono', monospace"` | CSS font-family stack | | `theme` | object | Campbell | Terminal color palette — see theme keys below | -| `keyboardBindings` | array | see below | Custom key-to-sequence bindings sent to the PTY. Merged with defaults by `key`+`mods` identity — see keyboard bindings below. | +| `keyboardBindings` | array | `[]` | Custom key-to-sequence bindings sent to the PTY. See [keyboard spec](keyboard.md) for schema and examples. | ### Theme keys @@ -149,65 +149,6 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter | `brightCyan` | `#61D6D6` | ANSI 14 | | `brightWhite` | `#F2F2F2` | ANSI 15 | -### Keyboard binding objects - -Each entry in `keyboardBindings` is an object with the following fields: - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `key` | string | yes | Key name, matched case-insensitively against `KeyboardEvent.key`. Special keys: `"enter"`, `"escape"`, `"tab"`, `"backspace"`, `"delete"`, `"space"`, `"arrowup"`, `"arrowdown"`, `"arrowleft"`, `"arrowright"`, `"f1"`–`"f12"`. Printable characters: `"a"`–`"z"`, `"0"`–`"9"`, etc. | -| `mods` | string[] | no | Array of modifier names. Accepted values: `"shift"`, `"ctrl"`, `"alt"`, `"meta"`. Unknown values are silently filtered out at config load time. Examples: `["shift"]`, `["ctrl", "shift"]`. Omit or `[]` for no modifiers. Order does not matter — `["ctrl", "shift"]` and `["shift", "ctrl"]` are equivalent. | -| `chars` | string | yes | Escape sequence sent verbatim to the PTY. Must be a valid JSON string — use `\uXXXX` for non-printable bytes (e.g. `"\u001b"` for ESC). `\x` hex notation is **not valid JSON** and will cause a parse error. Standard escapes `\r`, `\n`, `\t` work as expected. All escapes are resolved by `JSON.parse` at config load; the string is sent as-is with no further processing. | - -#### How to define `chars` - -The `chars` value is the byte sequence your TUI app expects to receive for that key combo. - -Take `"\u001b[13;2u"` as an example — the kitty keyboard protocol sequence for Shift+Enter, used by opencode, Helix, and most modern TUI apps. Most TUI apps follow one of two conventions: - -| Convention | Shift+Enter | Used by | -|---|---|---| -| [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) | `"\u001b[13;2u"` | Helix, opencode, modern TUI apps | -| Legacy ESC CR | `"\u001b\r"` | older TUI apps | - -**Legacy convention** — the sequence is app-specific. Check the binding examples below, or search the app's source for `\x1b\r` near its input handling code. - -**Kitty keyboard protocol** — sequences are standardized and can be derived from a formula: - -``` -\u001b [ {keycode} ; {modifier} u -``` - -Modifier value = `1` + sum of active modifiers (Shift `1`, Alt `2`, Ctrl `4`, Meta `8`): - -| Modifiers | Modifier value | Shift+Enter example | -|---|---|---| -| Shift | 1+1 = 2 | `"\u001b[13;2u"` | -| Alt | 1+2 = 3 | `"\u001b[13;3u"` | -| Ctrl | 1+4 = 5 | `"\u001b[13;5u"` | -| Shift+Ctrl | 1+1+4 = 6 | `"\u001b[13;6u"` | - -Common keycodes for the formula: - -| Key | Keycode | Shift example | -|---|---|---| -| Enter | 13 | `"\u001b[13;2u"` | -| Tab | 9 | `"\u001b[9;2u"` | -| Backspace | 127 | `"\u001b[127;2u"` | -| Escape | 27 | `"\u001b[27;2u"` | -| Space | 32 | `"\u001b[32;2u"` | - -Full keycode table: [kitty keyboard protocol — functional key definitions](https://sw.kovidgoyal.net/kitty/keyboard-protocol/#functional-key-definitions). - -#### Binding examples - -| Intent | `key` | `mods` | `chars` | -|---|---|---|---| -| Shift+Enter → new line (opencode, Helix, etc.) | `"enter"` | `["shift"]` | `"\u001b[13;2u"` | -| Ctrl+Enter → same | `"enter"` | `["ctrl"]` | `"\u001b[13;5u"` | -| Shift+Tab → backtab | `"tab"` | `["shift"]` | `"\u001b[9;2u"` | -| Suppress a key (consume without sending) | `"enter"` | `["shift"]` | `""` | - ### Example ```json @@ -229,8 +170,7 @@ Full keycode table: [kitty keyboard protocol — functional key definitions](htt "fontFamily": "Menlo, Consolas, 'DejaVu Sans Mono', monospace", "keyboardBindings": [ - { "key": "enter", "mods": ["shift"], "chars": "\u001b[13;2u" }, - { "key": "enter", "mods": ["ctrl"], "chars": "\u001b[13;5u" } + { "key": "enter", "mods": ["shift"], "chars": "\u001b\r" } ], "theme": { @@ -270,4 +210,4 @@ Full keycode table: [kitty keyboard protocol — functional key definitions](htt | Server logs | `logs: true` appends server stdout/stderr to `~/.config/webtty/server.log` | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Cursor style | `cursorStyle` sets the default cursor shape; DECSCUSR sequences from apps override at runtime | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | | Mouse scroll speed | `mouseScrollSpeed` scales SGR events per wheel tick for apps with mouse tracking; default `1` | [ADR 017](../adrs/017.client.mouse-scroll.md) | ✅ | -| Keyboard bindings | `keyboardBindings` — configurable key-to-sequence mappings sent to PTY; defaults to `[]`, users add entries in `~/.config/webtty/config.json` | [ADR 018](../adrs/018.client.keyboard-bindings.md) | ✅ | +| Keyboard bindings | `keyboardBindings` — configurable key-to-sequence mappings sent to PTY; defaults to `[]` | [ADR 018](../adrs/018.client.keyboard-bindings.md), [keyboard spec](keyboard.md) | ✅ | diff --git a/docs/specs/keyboard.md b/docs/specs/keyboard.md new file mode 100644 index 0000000..2acb084 --- /dev/null +++ b/docs/specs/keyboard.md @@ -0,0 +1,140 @@ +# SPEC: Keyboard Bindings + +**Author:** jesse23 +**Last Updated:** 2026-03-29 + +--- + +## Description + +Browser `KeyboardEvent` objects carry no terminal escape sequence knowledge. +When a user presses Shift+Enter in webtty, ghostty-web receives a `keydown` +with `key="Enter"` and `shiftKey=true` — and sends `\r` to the PTY, identical +to plain Enter. TUI apps that distinguish modifier+key combos (e.g. opencode +expecting Shift+Enter as a "new line" action) never receive the sequence they +expect. + +`keyboardBindings` solves this with a config-driven mapping layer. A +capture-phase `keydown` listener intercepts matching combos before ghostty-web +sees them and sends the configured `chars` directly to the PTY. + +## Binding schema + +`keyboardBindings` is an array of binding objects in +`~/.config/webtty/config.json`. Each entry has the following fields: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `key` | string | yes | Key name, matched case-insensitively against `KeyboardEvent.key`. Special keys: `"enter"`, `"escape"`, `"tab"`, `"backspace"`, `"delete"`, `"space"`, `"arrowup"`, `"arrowdown"`, `"arrowleft"`, `"arrowright"`, `"f1"`–`"f12"`. Printable characters: `"a"`–`"z"`, `"0"`–`"9"`, etc. | +| `mods` | string[] | no | Array of modifier names. Accepted values: `"shift"`, `"ctrl"`, `"alt"`, `"meta"`. Unknown values are silently filtered out at config load. Order does not matter — `["ctrl", "shift"]` and `["shift", "ctrl"]` are equivalent. Omit or `[]` for no modifiers. | +| `chars` | string | yes | Byte sequence sent verbatim to the PTY. Must be a valid JSON string — use `\uXXXX` for non-printable bytes (e.g. `"\u001b"` for ESC). `\x` hex notation is **not valid JSON** and will cause a parse error. Standard escapes `\r`, `\n`, `\t` work as expected. All escapes are resolved by `JSON.parse` at config load; the string is sent as-is with no further processing. | + +`keyboardBindings` defaults to `[]`. No bindings ship with webtty — users opt +in by adding entries in `~/.config/webtty/config.json`. + +User entries are **merged with defaults by `(key, mods)` identity**: + +- A user entry whose `(key, mods)` matches a default replaces that default. +- All other defaults are preserved. +- To consume a key without sending anything, set `"chars": ""`. + +## Defining `chars` + +The `chars` value is the byte sequence the target TUI app expects for that +key combo. Two encoding approaches exist: + +| Approach | Example (Shift+Enter) | Compatibility | +|---|---|---| +| Legacy encoding | `"\u001b\r"` | Works across all terminal chains | +| [Kitty Keyboard Protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) (KKP) | `"\u001b[13;2u"` | Works in direct single-hop setups only | + +**Legacy encoding is recommended for general use.** Legacy escape codes require +no capability negotiation — they pass through every terminal layer +unconditionally. KKP sequences are only reliable when the app negotiates +directly with webtty. In nested terminal setups (e.g. running a TUI app inside +vim `:terminal`, tmux, or screen), the intermediate emulator does not forward +KKP capability negotiation, so the app never enters KKP mode and the sequence +is silently ignored. See [ADR 019](../adrs/019.config.keyboard-binding-chars.md) +for the full analysis. + +### Legacy encoding + +Legacy escape codes are convention-based: ESC followed by the unmodified key +bytes. There is no in-band handshake — meaning is agreed by convention between +the terminal and the app. + +Common examples: + +| Key combo | `chars` | +|---|---| +| Shift+Enter | `"\u001b\r"` | +| Alt+Enter | `"\u001b\r"` (same as Shift+Enter in many apps — check app docs) | + +### Kitty Keyboard Protocol + +KKP sequences are structured and derivable from a formula. Use them only when +you are certain the app runs directly against webtty with no intermediate +terminal emulator. + +``` +\u001b [ {keycode} ; {modifier} u +``` + +Modifier value = `1` + sum of active modifiers (Shift `1`, Alt `2`, Ctrl `4`, +Meta `8`): + +| Modifiers | Modifier value | Shift+Enter example | +|---|---|---| +| Shift | 1+1 = 2 | `"\u001b[13;2u"` | +| Alt | 1+2 = 3 | `"\u001b[13;3u"` | +| Ctrl | 1+4 = 5 | `"\u001b[13;5u"` | +| Shift+Ctrl | 1+1+4 = 6 | `"\u001b[13;6u"` | + +Common keycodes: + +| Key | Keycode | Shift example | +|---|---|---| +| Enter | 13 | `"\u001b[13;2u"` | +| Tab | 9 | `"\u001b[9;2u"` | +| Backspace | 127 | `"\u001b[127;2u"` | +| Escape | 27 | `"\u001b[27;2u"` | +| Space | 32 | `"\u001b[32;2u"` | + +Full keycode table: [kitty keyboard protocol — functional key definitions](https://sw.kovidgoyal.net/kitty/keyboard-protocol/#functional-key-definitions). + +## Binding examples + +| Intent | `key` | `mods` | `chars` | +|---|---|---|---| +| Shift+Enter → new line (opencode, Helix, etc.) | `"enter"` | `["shift"]` | `"\u001b\r"` | +| Ctrl+Enter → same | `"enter"` | `["ctrl"]` | `"\u001b[13;5u"` | +| Shift+Tab → backtab | `"tab"` | `["shift"]` | `"\u001b[9;2u"` | +| Suppress a key (consume without sending) | `"enter"` | `["shift"]` | `""` | + +## Client implementation + +A capture-phase `keydown` listener on the terminal container fires before +ghostty-web's canvas handlers and intercepts matching bindings: + +1. Walk `config.keyboardBindings`. +2. Normalize `event.key` to lowercase and compare against each binding's + `key` + `mods`. +3. On match: call `e.preventDefault()` + `e.stopPropagation()` to suppress + ghostty-web's default handling, then send `binding.chars` verbatim over + WebSocket to the PTY. +4. No match: return immediately — ghostty-web handles as normal. + +`stopPropagation` (not `stopImmediatePropagation`) is sufficient: it prevents +the event from reaching the canvas so ghostty-web never fires its default +handling. + +`chars` is sent verbatim. Standard JSON escapes (`\uXXXX`, `\r`, `\n`, `\t`) +are resolved by `JSON.parse` at config load — no further processing occurs at +send time. + +## Features + +| Feature | Description | ADR | Done? | +|---------|-------------|-----|-------| +| Configurable bindings | `keyboardBindings` array in `~/.config/webtty/config.json`; capture-phase `keydown` handler sends `chars` to PTY; defaults to `[]` | [ADR 018](../adrs/018.client.keyboard-bindings.md) | ✅ | +| Legacy encoding recommendation | `"\u001b\r"` recommended over KKP sequences for Shift+Enter and similar combos; works across nested terminal chains | [ADR 019](../adrs/019.config.keyboard-binding-chars.md) | ✅ | From 4008f3ffaf31f5e943f5c8726a783bdcac33b241 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 29 Mar 2026 10:30:03 -0400 Subject: [PATCH 03/19] Refactor keyboard binding documentation and update recommendations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deleted ADR 019: Config — Keyboard sequence compatibility in nested terminal chains, and created a new ADR 019 with updated content. - Updated references in client and config specifications to point to the new ADR 019. - Revised keyboard specification to reflect the changes in recommended sequences for Shift+Enter, emphasizing the use of legacy encoding. - Introduced ADR 018: Client — Configurable keyboard bindings, detailing the new keyboard binding system and its configuration. - Adjusted compatibility matrix and decision sections to clarify the implications of using KKP versus legacy encoding in nested terminal environments. --- ...t.keyboard-bindings.md => 018.keyboard.md} | 2 +- ...board-binding-chars.md => 019.keyboard.md} | 4 +- docs/specs/client.md | 2 +- docs/specs/config.md | 2 +- docs/specs/keyboard.md | 53 +++++++++++++++++-- 5 files changed, 55 insertions(+), 8 deletions(-) rename docs/adrs/{018.client.keyboard-bindings.md => 018.keyboard.md} (98%) rename docs/adrs/{019.config.keyboard-binding-chars.md => 019.keyboard.md} (98%) diff --git a/docs/adrs/018.client.keyboard-bindings.md b/docs/adrs/018.keyboard.md similarity index 98% rename from docs/adrs/018.client.keyboard-bindings.md rename to docs/adrs/018.keyboard.md index b1946b6..dae95f6 100644 --- a/docs/adrs/018.client.keyboard-bindings.md +++ b/docs/adrs/018.keyboard.md @@ -1,6 +1,6 @@ # ADR 018: Client — Configurable keyboard bindings -**SPEC:** [client](../specs/client.md), [config](../specs/config.md), [keyboard](../specs/keyboard.md) +**SPEC:** [Keyboard Bindings](../specs/keyboard.md) **Status:** Accepted **Date:** 2026-03-28 diff --git a/docs/adrs/019.config.keyboard-binding-chars.md b/docs/adrs/019.keyboard.md similarity index 98% rename from docs/adrs/019.config.keyboard-binding-chars.md rename to docs/adrs/019.keyboard.md index 7567f38..9b29ed3 100644 --- a/docs/adrs/019.config.keyboard-binding-chars.md +++ b/docs/adrs/019.keyboard.md @@ -1,6 +1,6 @@ # ADR 019: Config — Keyboard sequence compatibility in nested terminal chains -**SPEC:** [keyboard](../specs/keyboard.md) +**SPEC:** [Keyboard Bindings](../specs/keyboard.md) **Status:** Accepted **Date:** 2026-03-29 @@ -236,5 +236,5 @@ infrastructure — the meaning lives in `13;2u`. ## Related Decisions -- [ADR 018 — Configurable keyboard bindings](018.client.keyboard-bindings.md): +- [ADR 018 — Configurable keyboard bindings](018.keyboard.md): introduced `keyboardBindings` and originally recommended `"\u001b[13;2u"`. diff --git a/docs/specs/client.md b/docs/specs/client.md index d14fb0d..a6dd1a4 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -148,4 +148,4 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Cursor style | `cursorStyle` / `cursorStyleBlink` defaults; DECSCUSR from PTY overrides at runtime via client-side intercept | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | | Non-text paste | Ctrl+V with no `text/plain` in clipboard forwards `\x16` to PTY; TUI apps read non-text content via their native OS clipboard API | [ADR 014](../adrs/014.client.image-paste.md) | ✅ | | Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), wheel events are forwarded as SGR mouse sequences (`\x1b[<64/65;col;rowM`) instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md) | ✅ | -| Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.client.keyboard-bindings.md) | ✅ | +| Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.keyboard.md) | ✅ | diff --git a/docs/specs/config.md b/docs/specs/config.md index bb9d8d4..49d4c25 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -210,4 +210,4 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter | Server logs | `logs: true` appends server stdout/stderr to `~/.config/webtty/server.log` | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Cursor style | `cursorStyle` sets the default cursor shape; DECSCUSR sequences from apps override at runtime | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | | Mouse scroll speed | `mouseScrollSpeed` scales SGR events per wheel tick for apps with mouse tracking; default `1` | [ADR 017](../adrs/017.client.mouse-scroll.md) | ✅ | -| Keyboard bindings | `keyboardBindings` — configurable key-to-sequence mappings sent to PTY; defaults to `[]` | [ADR 018](../adrs/018.client.keyboard-bindings.md), [keyboard spec](keyboard.md) | ✅ | +| Keyboard bindings | `keyboardBindings` — configurable key-to-sequence mappings sent to PTY; defaults to `[]` | [ADR 018](../adrs/018.keyboard.md), [keyboard spec](keyboard.md) | ✅ | diff --git a/docs/specs/keyboard.md b/docs/specs/keyboard.md index 2acb084..752c61c 100644 --- a/docs/specs/keyboard.md +++ b/docs/specs/keyboard.md @@ -54,7 +54,7 @@ unconditionally. KKP sequences are only reliable when the app negotiates directly with webtty. In nested terminal setups (e.g. running a TUI app inside vim `:terminal`, tmux, or screen), the intermediate emulator does not forward KKP capability negotiation, so the app never enters KKP mode and the sequence -is silently ignored. See [ADR 019](../adrs/019.config.keyboard-binding-chars.md) +is silently ignored. See [ADR 019](../adrs/019.keyboard.md) for the full analysis. ### Legacy encoding @@ -70,6 +70,53 @@ Common examples: | Shift+Enter | `"\u001b\r"` | | Alt+Enter | `"\u001b\r"` (same as Shift+Enter in many apps — check app docs) | +#### Porting from another terminal + +| App | Shift+Enter example | How to convert to `chars` | +|---|---|---| +| Alacritty | `chars = "\u001B\r"` | `\uNNNN` copies as-is (case-insensitive); `\xHH` → `\u00HH` (pad to 4 digits) | +| Ghostty | `keybind = shift+enter=text:\x1b\r` | `\xHH` → `\u00HH`; `\r`, `\n`, `\t` copy as-is | +| VS Code | `"args": { "text": "\u001b\r" }` | `\uNNNN` copies as-is | +| Windows Terminal | `"input": "\u001b\r"` | `\uNNNN` copies as-is | +| iTerm2 | `0x1b 0x0d` ("Send Hex Code") | Split on spaces; each `0xHH` → `\u00HH` (e.g. `0x1b 0x0d` → `"\u001b\r"`) | + +#### Discovering sequences from scratch + +When you have no existing config to copy from, two approaches work: + +**Capture from your native terminal** + +`cat` shows ESC as `^[` but CR is invisible — it just moves the cursor. Use +`od -c` instead, which prints named escape characters: + +```sh +cat | od -c +# press the key combo, then Ctrl+D +``` + +Output for Shift+Enter (`\u001b\r`): + +``` +0000000 033 \r +``` + +`033` is octal for ESC → `\u001b`. `\r` is CR → `\r`. For hex, `xxd` works +the same way: `1b 0d` → `\u001b\r`. + +**Look it up in a reference** + +There is no interactive lookup tool for legacy sequences — they are not +standardized. The closest reference is the xterm modified keys table at +[invisible-island.net/xterm/modified-keys.html](https://invisible-island.net/xterm/modified-keys.html), +which documents the CSI sequences xterm sends for modifier+key combinations. +For the ESC-prefix pattern specifically (`\u001b` + unmodified key), the rule +is simple enough to derive directly: ESC followed by whatever the unmodified +key sends (`\r` for Enter, `\t` for Tab, and so on). + +If the captured sequence still does nothing, the app may expect a different +convention. Check the app's documentation or source for what it registers as +its key handler. + ### Kitty Keyboard Protocol KKP sequences are structured and derivable from a formula. Use them only when @@ -136,5 +183,5 @@ send time. | Feature | Description | ADR | Done? | |---------|-------------|-----|-------| -| Configurable bindings | `keyboardBindings` array in `~/.config/webtty/config.json`; capture-phase `keydown` handler sends `chars` to PTY; defaults to `[]` | [ADR 018](../adrs/018.client.keyboard-bindings.md) | ✅ | -| Legacy encoding recommendation | `"\u001b\r"` recommended over KKP sequences for Shift+Enter and similar combos; works across nested terminal chains | [ADR 019](../adrs/019.config.keyboard-binding-chars.md) | ✅ | +| Configurable bindings | `keyboardBindings` array in `~/.config/webtty/config.json`; capture-phase `keydown` handler sends `chars` to PTY; defaults to `[]` | [ADR 018](../adrs/018.keyboard.md) | ✅ | +| Legacy encoding recommendation | `"\u001b\r"` recommended over KKP sequences for Shift+Enter and similar combos; works across nested terminal chains | [ADR 019](../adrs/019.keyboard.md) | ✅ | From 848895806d6e6961c99a50465b390c422c08a6d7 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 29 Mar 2026 10:39:41 -0400 Subject: [PATCH 04/19] feat: add 'webtty chars' CLI command for capturing key combos and their chars values --- docs/adrs/018.keyboard.md | 1 + docs/specs/keyboard.md | 41 +++++++++++++++---------------------- src/cli/commands.test.ts | 31 ++++++++++++++++++++++++++++ src/cli/commands.ts | 43 +++++++++++++++++++++++++++++++++++++++ src/cli/index.ts | 15 +++++++++++++- 5 files changed, 105 insertions(+), 26 deletions(-) diff --git a/docs/adrs/018.keyboard.md b/docs/adrs/018.keyboard.md index dae95f6..d541b54 100644 --- a/docs/adrs/018.keyboard.md +++ b/docs/adrs/018.keyboard.md @@ -174,3 +174,4 @@ Support `action:` targets (e.g. `csi:A`, `esc:d`, `ignore`) in addition to `char - ghostty-web's default handling for any intercepted key combo is fully suppressed — no double-send. - Keys with no matching binding are unaffected — ghostty-web handles them as before. - `keyboardBindings` ships empty (`[]`); users opt in explicitly. No built-in defaults to conflict with. +- `webtty chars` is provided as a companion CLI command: it puts the terminal in raw mode and prints the JSON `chars` value for each key combo pressed, ready to copy-paste into `keyboardBindings`. This removes the need for external tools (`od -c`, `xxd`) to discover sequence values. diff --git a/docs/specs/keyboard.md b/docs/specs/keyboard.md index 752c61c..fe1380e 100644 --- a/docs/specs/keyboard.md +++ b/docs/specs/keyboard.md @@ -82,40 +82,30 @@ Common examples: #### Discovering sequences from scratch -When you have no existing config to copy from, two approaches work: +Run `webtty chars` — it puts the terminal in raw mode and prints the `chars` +value ready to copy-paste for each key combo you press: -**Capture from your native terminal** +```sh +webtty chars +# Press any key combo to see its chars value. q to quit. + + "\u001b\r" ← pressed Shift+Enter + "\u001b[13;5u" ← pressed Ctrl+Enter +``` -`cat` shows ESC as `^[` but CR is invisible — it just moves the cursor. Use -`od -c` instead, which prints named escape characters: +If you do not have webtty installed, `od -c` is the fallback — it shows named +escape characters so CR is visible as `\r` rather than an invisible cursor +movement: ```sh cat | od -c # press the key combo, then Ctrl+D +# 0000000 033 \r (033 = octal ESC → \u001b) ``` -Output for Shift+Enter (`\u001b\r`): - -``` -0000000 033 \r -``` - -`033` is octal for ESC → `\u001b`. `\r` is CR → `\r`. For hex, `xxd` works -the same way: `1b 0d` → `\u001b\r`. - -**Look it up in a reference** - -There is no interactive lookup tool for legacy sequences — they are not -standardized. The closest reference is the xterm modified keys table at -[invisible-island.net/xterm/modified-keys.html](https://invisible-island.net/xterm/modified-keys.html), -which documents the CSI sequences xterm sends for modifier+key combinations. -For the ESC-prefix pattern specifically (`\u001b` + unmodified key), the rule -is simple enough to derive directly: ESC followed by whatever the unmodified -key sends (`\r` for Enter, `\t` for Tab, and so on). - If the captured sequence still does nothing, the app may expect a different -convention. Check the app's documentation or source for what it registers as -its key handler. +convention. Check the app's documentation or source for what sequence it +registers as its key handler. ### Kitty Keyboard Protocol @@ -185,3 +175,4 @@ send time. |---------|-------------|-----|-------| | Configurable bindings | `keyboardBindings` array in `~/.config/webtty/config.json`; capture-phase `keydown` handler sends `chars` to PTY; defaults to `[]` | [ADR 018](../adrs/018.keyboard.md) | ✅ | | Legacy encoding recommendation | `"\u001b\r"` recommended over KKP sequences for Shift+Enter and similar combos; works across nested terminal chains | [ADR 019](../adrs/019.keyboard.md) | ✅ | +| `webtty chars` | CLI command: puts terminal in raw mode, prints the `chars` value for each key combo pressed; Ctrl+C to exit | [ADR 018](../adrs/018.keyboard.md) | ✅ | diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index e669db2..08ec70a 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -9,6 +9,7 @@ import { waitForServerDown, waitForServerReady, } from '../utils.test'; +import { bytesToChars } from './commands'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CLI_ENTRY = path.resolve(__dirname, 'index.ts'); @@ -179,6 +180,30 @@ describe('cli — session management', () => { }); }); +describe('bytesToChars', () => { + test('ESC CR → legacy shift+enter encoding', () => { + expect(bytesToChars(Buffer.from([0x1b, 0x0d]))).toBe('"\\u001b\\r"'); + }); + + test('ESC [ 1 3 ; 2 u → KKP shift+enter', () => { + expect(bytesToChars(Buffer.from([0x1b, 0x5b, 0x31, 0x33, 0x3b, 0x32, 0x75]))).toBe( + '"\\u001b[13;2u"', + ); + }); + + test('printable ASCII passes through', () => { + expect(bytesToChars(Buffer.from('hello'))).toBe('"hello"'); + }); + + test('tab → \\t', () => { + expect(bytesToChars(Buffer.from([0x09]))).toBe('"\\t"'); + }); + + test('non-ASCII control byte → \\uXXXX', () => { + expect(bytesToChars(Buffer.from([0x00]))).toBe('"\\u0000"'); + }); +}); + describe('cli — no-arg, help, config', () => { let port: number; let baseUrl: string; @@ -210,6 +235,12 @@ describe('cli — no-arg, help, config', () => { expect(stdout).toContain('/s/main'); }); + test('chars exits with error when not a TTY', async () => { + const { stderr, exitCode } = await runCli(port, 'chars'); + expect(exitCode).toBe(1); + expect(stderr).toContain('requires an interactive terminal'); + }); + test('help prints usage', async () => { const { stdout, exitCode } = await runCli(port, 'help'); expect(exitCode).toBe(0); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 36ff7ba..a5cd402 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -173,3 +173,46 @@ export function cmdConfig(): void { process.env.VISUAL ?? process.env.EDITOR ?? (process.platform === 'win32' ? 'notepad' : 'vi'); childProcess.spawnSync(editor, [configPath], { stdio: 'inherit' }); } + +export function bytesToChars(buf: Buffer): string { + let out = ''; + for (const b of buf) { + if (b === 0x1b) out += '\\u001b'; + else if (b === 0x0d) out += '\\r'; + else if (b === 0x09) out += '\\t'; + else if (b === 0x0a) out += '\\n'; + else if (b >= 0x20 && b < 0x7f) out += String.fromCharCode(b); + else out += `\\u${b.toString(16).padStart(4, '0')}`; + } + return `"${out}"`; +} + +export function cmdChars(): void { + if (!process.stdin.isTTY) { + console.error('webtty chars: requires an interactive terminal'); + process.exit(1); + } + + process.stdin.setRawMode(true); + process.stdin.resume(); + console.log('Press any key combo to see its chars value. q to quit.\n'); + + let buf = Buffer.alloc(0); + let timer: ReturnType | null = null; + + const flush = () => { + if (buf.length === 0) return; + console.log(` ${bytesToChars(buf)}\n`); + buf = Buffer.alloc(0); + }; + + process.stdin.on('data', (chunk: Buffer) => { + if (chunk.length === 1 && chunk[0] === 0x71) { + process.stdin.setRawMode(false); + process.exit(0); + } + buf = Buffer.concat([buf, chunk]); + if (timer) clearTimeout(timer); + timer = setTimeout(flush, 50); + }); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 9591c29..745b6a5 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,4 +1,13 @@ -import { cmdConfig, cmdGo, cmdList, cmdRemove, cmdRename, cmdStart, cmdStop } from './commands'; +import { + cmdChars, + cmdConfig, + cmdGo, + cmdList, + cmdRemove, + cmdRename, + cmdStart, + cmdStop, +} from './commands'; const GO_ALIASES = new Set(['go', 'a', 'run', 'attach', 'open']); @@ -23,6 +32,7 @@ function printHelp(): void { row('stop', 'Stop the webtty server'), row('start', 'Start the webtty server'), row('config', 'Open the config file in $VISUAL, $EDITOR, or a default editor'), + row('chars', 'Capture a key combo and print its chars value for keyboardBindings'), row('help', 'Show this help message'), ].join('\n'), ); @@ -58,6 +68,9 @@ if (!cmd) { case 'config': cmdConfig(); break; + case 'chars': + cmdChars(); + break; case 'help': case '--help': case '-h': From b4be1b2da6307159e8d14197d31b8deaf49d901b Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 29 Mar 2026 11:39:07 -0400 Subject: [PATCH 05/19] Refactor keyboard sequence handling and documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed ADR 019: Config — Keyboard sequence compatibility in nested terminal chains and replaced it with ADR 019: Config — Keyboard sequence compatibility in nested terminal chains. - Updated references in the documentation to reflect the new ADR structure. - Introduced a new ADR 018: Client — Configurable keyboard bindings to define a flexible key binding system for TUI applications. - Added support for configurable keyboard bindings in the client, allowing users to define custom key-to-sequence mappings. - Enhanced the `bytesToDisplay` function to provide a clearer representation of received byte sequences. - Updated the `webtty chars` command to display both the received byte representation and the corresponding `chars` value. - Adjusted the configuration documentation to recommend legacy encoding for Shift+Enter and similar key combinations in nested terminal setups. --- ...yboard.md => 018.keyboard.key-bindings.md} | 2 +- ...ard.md => 019.keyboard.sequence-compat.md} | 2 +- docs/specs/client.md | 2 +- docs/specs/config.md | 2 +- docs/specs/keyboard.md | 18 ++++++----- src/cli/commands.test.ts | 30 ++++++++++++++++++- src/cli/commands.ts | 25 +++++++++++++++- 7 files changed, 68 insertions(+), 13 deletions(-) rename docs/adrs/{018.keyboard.md => 018.keyboard.key-bindings.md} (98%) rename docs/adrs/{019.keyboard.md => 019.keyboard.sequence-compat.md} (99%) diff --git a/docs/adrs/018.keyboard.md b/docs/adrs/018.keyboard.key-bindings.md similarity index 98% rename from docs/adrs/018.keyboard.md rename to docs/adrs/018.keyboard.key-bindings.md index d541b54..b3b97e0 100644 --- a/docs/adrs/018.keyboard.md +++ b/docs/adrs/018.keyboard.key-bindings.md @@ -174,4 +174,4 @@ Support `action:` targets (e.g. `csi:A`, `esc:d`, `ignore`) in addition to `char - ghostty-web's default handling for any intercepted key combo is fully suppressed — no double-send. - Keys with no matching binding are unaffected — ghostty-web handles them as before. - `keyboardBindings` ships empty (`[]`); users opt in explicitly. No built-in defaults to conflict with. -- `webtty chars` is provided as a companion CLI command: it puts the terminal in raw mode and prints the JSON `chars` value for each key combo pressed, ready to copy-paste into `keyboardBindings`. This removes the need for external tools (`od -c`, `xxd`) to discover sequence values. +- `webtty chars` is provided as a companion CLI command: it puts the terminal in raw mode and prints the JSON `chars` value for each key combo pressed, ready to copy-paste into `keyboardBindings`. The output shows received bytes (e.g. `ESC CR`) alongside the JSON value. This removes the need for external tools (`od -c`, `xxd`) to discover sequence values. diff --git a/docs/adrs/019.keyboard.md b/docs/adrs/019.keyboard.sequence-compat.md similarity index 99% rename from docs/adrs/019.keyboard.md rename to docs/adrs/019.keyboard.sequence-compat.md index 9b29ed3..33bda8a 100644 --- a/docs/adrs/019.keyboard.md +++ b/docs/adrs/019.keyboard.sequence-compat.md @@ -236,5 +236,5 @@ infrastructure — the meaning lives in `13;2u`. ## Related Decisions -- [ADR 018 — Configurable keyboard bindings](018.keyboard.md): +- [ADR 018 — Configurable keyboard bindings](018.keyboard.key-bindings.md): introduced `keyboardBindings` and originally recommended `"\u001b[13;2u"`. diff --git a/docs/specs/client.md b/docs/specs/client.md index a6dd1a4..d277109 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -148,4 +148,4 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Cursor style | `cursorStyle` / `cursorStyleBlink` defaults; DECSCUSR from PTY overrides at runtime via client-side intercept | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | | Non-text paste | Ctrl+V with no `text/plain` in clipboard forwards `\x16` to PTY; TUI apps read non-text content via their native OS clipboard API | [ADR 014](../adrs/014.client.image-paste.md) | ✅ | | Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), wheel events are forwarded as SGR mouse sequences (`\x1b[<64/65;col;rowM`) instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md) | ✅ | -| Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.keyboard.md) | ✅ | +| Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.keyboard.key-bindings.md) | ✅ | diff --git a/docs/specs/config.md b/docs/specs/config.md index 49d4c25..9fc9246 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -210,4 +210,4 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter | Server logs | `logs: true` appends server stdout/stderr to `~/.config/webtty/server.log` | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Cursor style | `cursorStyle` sets the default cursor shape; DECSCUSR sequences from apps override at runtime | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | | Mouse scroll speed | `mouseScrollSpeed` scales SGR events per wheel tick for apps with mouse tracking; default `1` | [ADR 017](../adrs/017.client.mouse-scroll.md) | ✅ | -| Keyboard bindings | `keyboardBindings` — configurable key-to-sequence mappings sent to PTY; defaults to `[]` | [ADR 018](../adrs/018.keyboard.md), [keyboard spec](keyboard.md) | ✅ | +| Keyboard bindings | `keyboardBindings` — configurable key-to-sequence mappings sent to PTY; defaults to `[]` | [ADR 018](../adrs/018.keyboard.key-bindings.md), [keyboard spec](keyboard.md) | ✅ | diff --git a/docs/specs/keyboard.md b/docs/specs/keyboard.md index fe1380e..29fea92 100644 --- a/docs/specs/keyboard.md +++ b/docs/specs/keyboard.md @@ -54,7 +54,7 @@ unconditionally. KKP sequences are only reliable when the app negotiates directly with webtty. In nested terminal setups (e.g. running a TUI app inside vim `:terminal`, tmux, or screen), the intermediate emulator does not forward KKP capability negotiation, so the app never enters KKP mode and the sequence -is silently ignored. See [ADR 019](../adrs/019.keyboard.md) +is silently ignored. See [ADR 019](../adrs/019.keyboard.sequence-compat.md) for the full analysis. ### Legacy encoding @@ -88,9 +88,13 @@ value ready to copy-paste for each key combo you press: ```sh webtty chars # Press any key combo to see its chars value. q to quit. - - "\u001b\r" ← pressed Shift+Enter - "\u001b[13;5u" ← pressed Ctrl+Enter +# +# received → chars +# ----------------- +# +# ESC CR → "\u001b\r" +# \x04 → "\u0004" +# S → "S" ``` If you do not have webtty installed, `od -c` is the fallback — it shows named @@ -173,6 +177,6 @@ send time. | Feature | Description | ADR | Done? | |---------|-------------|-----|-------| -| Configurable bindings | `keyboardBindings` array in `~/.config/webtty/config.json`; capture-phase `keydown` handler sends `chars` to PTY; defaults to `[]` | [ADR 018](../adrs/018.keyboard.md) | ✅ | -| Legacy encoding recommendation | `"\u001b\r"` recommended over KKP sequences for Shift+Enter and similar combos; works across nested terminal chains | [ADR 019](../adrs/019.keyboard.md) | ✅ | -| `webtty chars` | CLI command: puts terminal in raw mode, prints the `chars` value for each key combo pressed; Ctrl+C to exit | [ADR 018](../adrs/018.keyboard.md) | ✅ | +| Configurable bindings | `keyboardBindings` array in `~/.config/webtty/config.json`; capture-phase `keydown` handler sends `chars` to PTY; defaults to `[]` | [ADR 018](../adrs/018.keyboard.key-bindings.md) | ✅ | +| Legacy encoding recommendation | `"\u001b\r"` recommended over KKP sequences for Shift+Enter and similar combos; works across nested terminal chains | [ADR 019](../adrs/019.keyboard.sequence-compat.md) | ✅ | +| `webtty chars` | CLI command: puts terminal in raw mode, prints the `chars` value for each key combo pressed; q to quit | [ADR 018](../adrs/018.keyboard.key-bindings.md) | ✅ | diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index 08ec70a..1b3da68 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -9,7 +9,7 @@ import { waitForServerDown, waitForServerReady, } from '../utils.test'; -import { bytesToChars } from './commands'; +import { bytesToChars, bytesToDisplay } from './commands'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CLI_ENTRY = path.resolve(__dirname, 'index.ts'); @@ -180,6 +180,34 @@ describe('cli — session management', () => { }); }); +describe('bytesToDisplay', () => { + test('ESC CR → legacy shift+enter', () => { + expect(bytesToDisplay(Buffer.from([0x1b, 0x0d]))).toBe('ESC CR'); + }); + + test('ESC [ 1 3 ; 2 u → KKP shift+enter', () => { + expect(bytesToDisplay(Buffer.from([0x1b, 0x5b, 0x31, 0x33, 0x3b, 0x32, 0x75]))).toBe( + 'ESC [ 1 3 ; 2 u', + ); + }); + + test('tab → TAB', () => { + expect(bytesToDisplay(Buffer.from([0x09]))).toBe('TAB'); + }); + + test('space → SPC', () => { + expect(bytesToDisplay(Buffer.from([0x20]))).toBe('SPC'); + }); + + test('del → DEL', () => { + expect(bytesToDisplay(Buffer.from([0x7f]))).toBe('DEL'); + }); + + test('unknown control byte → \\xHH', () => { + expect(bytesToDisplay(Buffer.from([0x00]))).toBe('\\x00'); + }); +}); + describe('bytesToChars', () => { test('ESC CR → legacy shift+enter encoding', () => { expect(bytesToChars(Buffer.from([0x1b, 0x0d]))).toBe('"\\u001b\\r"'); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index a5cd402..6d90efd 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -187,28 +187,51 @@ export function bytesToChars(buf: Buffer): string { return `"${out}"`; } +export function bytesToDisplay(buf: Buffer): string { + return Array.from(buf) + .map((b) => { + if (b === 0x1b) return 'ESC'; + if (b === 0x0d) return 'CR'; + if (b === 0x09) return 'TAB'; + if (b === 0x0a) return 'LF'; + if (b === 0x20) return 'SPC'; + if (b === 0x7f) return 'DEL'; + if (b > 0x20 && b < 0x7f) return String.fromCharCode(b); + return `\\x${b.toString(16).padStart(2, '0')}`; + }) + .join(' '); +} + export function cmdChars(): void { if (!process.stdin.isTTY) { console.error('webtty chars: requires an interactive terminal'); process.exit(1); } + const dim = '\x1b[2m'; + const bold = '\x1b[1m'; + const reset = '\x1b[0m'; + process.stdin.setRawMode(true); process.stdin.resume(); console.log('Press any key combo to see its chars value. q to quit.\n'); + console.log(` ${dim}received${reset} → ${bold}chars${reset}`); + console.log(' ' + '─'.repeat(17)); let buf = Buffer.alloc(0); let timer: ReturnType | null = null; const flush = () => { if (buf.length === 0) return; - console.log(` ${bytesToChars(buf)}\n`); + const display = bytesToDisplay(buf).padEnd(8); + console.log(` ${dim}${display}${reset} → ${bold}${bytesToChars(buf)}${reset}`); buf = Buffer.alloc(0); }; process.stdin.on('data', (chunk: Buffer) => { if (chunk.length === 1 && chunk[0] === 0x71) { process.stdin.setRawMode(false); + console.log(' ' + '─'.repeat(17) + '\n'); process.exit(0); } buf = Buffer.concat([buf, chunk]); From 7881193e626bfd4c195d062efbdd9468ce7a2b43 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 29 Mar 2026 15:11:18 -0400 Subject: [PATCH 06/19] feat: implement configurable keyboard bindings and add webtty key command - Introduced `keyboardBindings` in `~/.config/webtty/config.json` to allow users to define custom key mappings for terminal applications. - Added support for legacy encoding and Kitty Keyboard Protocol (KKP) for key sequences. - Implemented `webtty key` command to capture key combos and display their corresponding `chars` values in JSON format. - Updated documentation to reflect the new key binding features and usage examples. - Added ADRs 018, 019, and 020 to detail the design decisions and compatibility considerations for key bindings. --- ....md => 018.key-bindings.config-support.md} | 13 +- docs/adrs/019.key-bindings.sequence-compat.md | 153 +++++++++++ docs/adrs/019.keyboard.sequence-compat.md | 240 ------------------ docs/adrs/020.cli.key.md | 62 +++++ docs/specs/client.md | 4 +- docs/specs/config.md | 4 +- docs/specs/{keyboard.md => key-bindings.md} | 80 ++---- src/cli/commands.test.ts | 4 +- src/cli/commands.ts | 4 +- src/cli/index.ts | 8 +- 10 files changed, 255 insertions(+), 317 deletions(-) rename docs/adrs/{018.keyboard.key-bindings.md => 018.key-bindings.config-support.md} (86%) create mode 100644 docs/adrs/019.key-bindings.sequence-compat.md delete mode 100644 docs/adrs/019.keyboard.sequence-compat.md create mode 100644 docs/adrs/020.cli.key.md rename docs/specs/{keyboard.md => key-bindings.md} (67%) diff --git a/docs/adrs/018.keyboard.key-bindings.md b/docs/adrs/018.key-bindings.config-support.md similarity index 86% rename from docs/adrs/018.keyboard.key-bindings.md rename to docs/adrs/018.key-bindings.config-support.md index b3b97e0..4bd5675 100644 --- a/docs/adrs/018.keyboard.key-bindings.md +++ b/docs/adrs/018.key-bindings.config-support.md @@ -1,6 +1,6 @@ -# ADR 018: Client — Configurable keyboard bindings +# ADR 018: Key Bindings — Config support -**SPEC:** [Keyboard Bindings](../specs/keyboard.md) +**SPEC:** [Key Bindings](../specs/key-bindings.md) **Status:** Accepted **Date:** 2026-03-28 @@ -85,7 +85,7 @@ Examples: `["shift"]`, `["ctrl", "shift"]`, `["alt"]`. Omit the field or pass `[ **`chars`** — a plain JSON string sent verbatim to the PTY. `JSON.parse` resolves all standard escapes (`\uXXXX`, `\r`, `\n`, `\t`) at config load time. The client sends the resulting string with a single `ws.send(binding.chars)` — no transformation, no lookup, no regex. This is the minimum possible implementation cost. -The recommended sequence for Shift+Enter is `"\u001b[13;2u"` — the [kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) encoding for `Enter` (keycode 13) with Shift (modifier value 2). Most modern TUI apps (opencode, Helix, etc.) understand this format. The sequence is a plain JSON string; `JSON.parse` resolves `\u001b` to ESC (byte 0x1B) and the remaining characters `[13;2u` are printable ASCII. `ws.send(binding.chars)` sends the result with zero transformation. +The recommended sequence for Shift+Enter is `"\u001b\r"` (legacy encoding) — see [ADR 019](019.key-bindings.sequence-compat.md) for the full rationale. The sequence is a plain JSON string; `JSON.parse` resolves `\u001b` to ESC (byte 0x1B) and `\r` to CR. `ws.send(binding.chars)` sends the result with zero transformation. `\x1b` (hex escape) is **not valid JSON** — `JSON.parse` throws on it. `\u001b` is the correct JSON form, making config load the only processing step needed. @@ -104,7 +104,7 @@ The `mods` array follows the same principle: four string literals (`"shift"`, `" | Config format | JSON array | INI lines | TOML array | JSON array | | Key names | lowercase (`"enter"`) | lowercase (`enter`) | PascalCase (`Return`) | lowercase (`enter`) | | Output field name | `chars` | `text:` prefix | `chars` | `input` | -| Output value | JSON string (`"\u001b[13;2u"`) | Zig literal (`\x1b\r`) | TOML string (`"\u001B\r"`) | JSON string (`"\u001b\r"`) | +| Output value | JSON string (`"\u001b\r"`) | Zig literal (`\x1b\r`) | TOML string (`"\u001B\r"`) | JSON string (`"\u001b\r"`) | | Modifier format | string array | plus-separated string | pipe-separated string | plus-separated string | The `chars` field name matches Alacritty directly. The value format matches Windows Terminal (both are JSON). Key names match Ghostty and Windows Terminal. The only intentional divergence is `mods` as a string array instead of a formatted string — this eliminates the only parsing that would otherwise be required. @@ -169,9 +169,8 @@ Support `action:` targets (e.g. `csi:A`, `esc:d`, `ignore`) in addition to `char ## Consequences -- Shift+Enter, Ctrl+Enter, Shift+Tab, and any other modifier+key combo work correctly in TUI apps that use the kitty keyboard protocol (opencode, Helix, and most modern TUI apps). -- Users configure bindings via `~/.config/webtty/config.json` using kitty keyboard protocol sequences — the same format modern TUI frameworks expect. +- Shift+Enter, Ctrl+Enter, Shift+Tab, and any other modifier+key combo work correctly in TUI apps that expect custom escape sequences. +- Users configure bindings via `~/.config/webtty/config.json`. The recommended sequence for Shift+Enter is `"\u001b\r"` (legacy encoding) — see [ADR 019](019.key-bindings.sequence-compat.md). - ghostty-web's default handling for any intercepted key combo is fully suppressed — no double-send. - Keys with no matching binding are unaffected — ghostty-web handles them as before. - `keyboardBindings` ships empty (`[]`); users opt in explicitly. No built-in defaults to conflict with. -- `webtty chars` is provided as a companion CLI command: it puts the terminal in raw mode and prints the JSON `chars` value for each key combo pressed, ready to copy-paste into `keyboardBindings`. The output shows received bytes (e.g. `ESC CR`) alongside the JSON value. This removes the need for external tools (`od -c`, `xxd`) to discover sequence values. diff --git a/docs/adrs/019.key-bindings.sequence-compat.md b/docs/adrs/019.key-bindings.sequence-compat.md new file mode 100644 index 0000000..1c68921 --- /dev/null +++ b/docs/adrs/019.key-bindings.sequence-compat.md @@ -0,0 +1,153 @@ +# ADR 019: Config — Keyboard sequence compatibility in nested terminal chains + +**SPEC:** [Key Bindings](../specs/key-bindings.md) +**Status:** Accepted +**Date:** 2026-03-29 + +--- + +## Context + +ADR 018 introduced `keyboardBindings` and recommended `"\u001b[13;2u"` as the `chars` value for Shift+Enter — the Kitty Keyboard Protocol (KKP) encoding. That recommendation holds for the common case of a single terminal layer. It breaks silently in nested terminal environments for reasons that are architectural, not incidental. + +### The terminal chain architecture problem + +A "terminal chain" is any setup where a terminal emulator sits between the outer terminal and the target application — for example, vim's `:terminal`, tmux, GNU screen, or any shell running inside another shell. Each link in the chain is an independent terminal emulator with its own capability model. + +**Keyboard protocol negotiation is point-to-point, not end-to-end.** + +When an app starts, it queries its immediate terminal for capability support: `\u001b[?u` for KKP, or equivalent for other protocols. The terminal it is talking to is the process on the other end of its PTY — which in a nested setup is the intermediate emulator, not the outermost terminal. The outer terminal (webtty, Alacritty, etc.) is not in that negotiation at all. + +For KKP to work across a nested chain, every intermediate emulator would need to implement **protocol forwarding**: detect the inner app's capability query, proxy it up through all layers to the true outer terminal, collect the response, and relay it back down. It would then need to forward all KKP-encoded input from the outer terminal to the inner app transparently, without consuming or re-encoding it. + +This is a hard problem. It requires every link in the chain to actively participate. In practice, intermediate emulators (vim `:terminal`, tmux, screen) present their own terminal model to inner processes. They do not transparently expose the outer terminal's capabilities, and most do not implement KKP forwarding at all. + +**The consequence:** KKP is only reliable between directly adjacent processes. In any chain longer than one hop, KKP support depends entirely on whether every intermediate emulator implements forwarding — a property the outermost terminal cannot observe or control. + +**Legacy encoding does not have this problem.** Legacy escape codes require no negotiation. Every terminal emulator in the chain forwards input bytes to its inner PTY unconditionally. The sequence arrives at the target application regardless of how many layers it passed through or which capabilities any of them advertise. + +The tradeoff is that legacy encoding carries no formal protocol: meaning is agreed upon by convention between terminal and app, not guaranteed by a negotiated handshake. Legacy escape codes are old and widely supported, but there is no in-band capability flag that confirms the app will interpret them correctly. + +### The two sequences for Shift+Enter + +| Sequence | Encoding | How activated | +|---|---|---| +| `\u001b[13;2u` | `ESC [ 13 ; 2 u` | KKP — requires negotiation between adjacent terminal and app | +| `\u001b\r` | `ESC CR` | Legacy encoding — no negotiation required | + +Both are understood by opencode, Helix, and other modern TUI apps as Shift+Enter. They differ in whether they require protocol agreement between adjacent layers. + +### Our use case + +In the direct setup (single hop): + +``` +browser → webtty ←→ opencode +``` + +opencode negotiates KKP directly with webtty's PTY environment, enters KKP mode, and `\u001b[13;2u` is understood. `\u001b\r` also works here — opencode supports both KKP and legacy encoding. + +When a user runs opencode inside vim's `:terminal` (a common workflow): + +``` +browser → webtty ←→ vim :terminal ←→ opencode +``` + +opencode now negotiates KKP with **vim's terminal emulator**. Vim `:terminal` does not implement KKP. It neither responds to `\u001b[?u` affirmatively nor forwards the query up to webtty. opencode never enters KKP mode. When webtty sends `\u001b[13;2u`, opencode does not recognise it as Shift+Enter. The keypress is silently lost. + +The legacy sequence (`\u001b\r`) passes through the same chain cleanly: vim `:terminal` forwards it to its inner PTY, and opencode receives it regardless of whether KKP was negotiated. + +The same breakage with `\u001b[13;2u` reproduces in VS Code's integrated terminal hosting a vim `:terminal` session, confirming it is a property of the chain structure, not of webtty specifically. + +Alacritty's default Shift+Enter binding uses legacy encoding for this reason: + +```toml +[[keyboard.bindings]] +key = "Return" +mods = "Shift" +chars = "\u001B\r" +``` + +Users running `alacritty → vim :terminal → opencode` report Shift+Enter working correctly — the legacy sequence survives all layers. + +### Compatibility matrix + +| Sequence | direct (1 hop) | via vim :term (2 hops) | alacritty direct | +|---|---|---|---| +| `\u001b[13;2u` | ✅ KKP negotiated | ❌ vim :term does not forward KKP | ✅ | +| `\u001b\r` | ✅ | ✅ | ✅ | + +--- + +## Decision + +The recommended `chars` value for Shift+Enter in `keyboardBindings` is `"\u001b\r"`, not `"\u001b[13;2u"`. + +`"\u001b[13;2u"` is not deprecated. It still works in direct single-hop setups. Users who are certain their workflow never involves an intermediate terminal may prefer it. It should not be the primary recommendation because nested terminal usage is common and the failure is silent. + +The `config.md` spec will be updated: `"\u001b\r"` becomes the primary example; `"\u001b[13;2u"` is noted as valid for single-hop setups only. + +--- + +## Considered Options + +### Option A: Keep `"\u001b[13;2u"` as the recommendation + +Correct for the direct case. Silently broken whenever an intermediate terminal emulator is in the chain — a common setup. Users get no Shift+Enter and no error message. + +**Rejected** — silent failure in a common workflow is worse than a less "modern" default. + +### Option B: Recommend `"\u001b\r"`, note `"\u001b[13;2u"` as an alternative (chosen) + +`"\u001b\r"` works across all tested scenarios. The cost is that it uses legacy encoding rather than a negotiated protocol. Both sequences are understood by every app that supports Shift+Enter at all, so the cost is theoretical for current apps. + +### Option C: Detect nested environments and switch sequences automatically + +Not feasible. webtty is the outermost layer; it sends bytes into a PTY and has no visibility into the process tree on the other side. The number of terminal layers between webtty and the target app is not observable. + +--- + +## Consequences + +- `config.md` will be updated: `"\u001b\r"` replaces `"\u001b[13;2u"` as the primary binding example for Shift+Enter. +- Existing users with `"\u001b[13;2u"` who use webtty directly are unaffected. +- Users running TUI apps inside vim `:terminal` (or any other non-KKP-forwarding intermediate terminal) should switch to `"\u001b\r"`. + +--- + +## Q&A + +**Q: What if an app in the chain only supports KKP and not legacy encoding?** + +Then Shift+Enter is broken in nested terminal setups and no `chars` value that webtty sends can fix it. webtty is the outermost terminal; it has no mechanism to reach an inner app directly over the intermediate emulator's head. The fix must be in the intermediate emulator: it needs to implement KKP protocol forwarding so that the inner app's negotiation reaches the outer terminal. + +In practice this scenario does not arise today. No widely-used TUI framework (bubbletea, ratatui, textual) drops legacy encoding support, because KKP is still not universal and doing so would break compatibility with the majority of terminals. Apps negotiate KKP when available and fall back to legacy encoding when not. A future app that deliberately drops legacy support would be making an explicit compatibility tradeoff. + +**Q: Can this problem ever be fully solved at the webtty layer?** + +No. The problem is structural: capability negotiation is point-to-point and intermediate emulators present their own terminal model. webtty can only control what it sends into the PTY. It cannot know or influence how many emulator layers sit between it and the target app, or whether those layers implement protocol forwarding. + +The complete solution requires the intermediate emulators to participate — either by implementing KKP forwarding (as kitty can be configured to do) or by using legacy encoding, which requires no negotiation and passes through all layers by default. + +**Q: Does tmux or screen have the same problem?** + +Yes. tmux and GNU screen are terminal multiplexers that act as intermediate emulators. Neither implements KKP forwarding by default. Apps running inside a tmux or screen session will not enter KKP mode regardless of whether the outer terminal supports it. Legacy encoding passes through both without issue for the same reason it passes through vim `:terminal`. + +**Q: KKP sequences like `\u001b[13;2u` still start with `\u001b` — why does KKP need ESC if it encodes everything in `13;2u`?** + +`\u001b[` together is CSI — Control Sequence Introducer — a two-byte prefix inherited from ECMA-48 (1976) that tells the terminal parser "switch from character mode into control sequence mode." The `[` on its own would just be a literal `[`; ESC is what signals the parser to treat what follows as a structured control sequence rather than printable text. KKP only defines the payload — the parameter format (`keycode;modifier`) and the final byte (`u`). It is built on top of the existing CSI framework, not a replacement for it. + +ESC plays a different role in each encoding: + +| Sequence | Role of ESC | +|---|---| +| `\u001b\r` (legacy) | Prefix modifier — "the next character is modified" | +| `\u001b[13;2u` (KKP) | CSI introducer — "what follows is a structured control sequence" | + +In legacy encoding ESC carries the meaning. In KKP, ESC is framing infrastructure — the meaning lives in `13;2u`. + +--- + +## Related Decisions + +- [ADR 018 — Configurable keyboard bindings](018.key-bindings.config-support.md): introduced `keyboardBindings` and originally recommended `"\u001b[13;2u"`. diff --git a/docs/adrs/019.keyboard.sequence-compat.md b/docs/adrs/019.keyboard.sequence-compat.md deleted file mode 100644 index 33bda8a..0000000 --- a/docs/adrs/019.keyboard.sequence-compat.md +++ /dev/null @@ -1,240 +0,0 @@ -# ADR 019: Config — Keyboard sequence compatibility in nested terminal chains - -**SPEC:** [Keyboard Bindings](../specs/keyboard.md) -**Status:** Accepted -**Date:** 2026-03-29 - ---- - -## Context - -ADR 018 introduced `keyboardBindings` and recommended `"\u001b[13;2u"` as the -`chars` value for Shift+Enter — the Kitty Keyboard Protocol (KKP) encoding. -That recommendation holds for the common case of a single terminal layer. It -breaks silently in nested terminal environments for reasons that are -architectural, not incidental. - -### The terminal chain architecture problem - -A "terminal chain" is any setup where a terminal emulator sits between the -outer terminal and the target application — for example, vim's `:terminal`, -tmux, GNU screen, or any shell running inside another shell. Each link in the -chain is an independent terminal emulator with its own capability model. - -**Keyboard protocol negotiation is point-to-point, not end-to-end.** - -When an app starts, it queries its immediate terminal for capability support: -`\u001b[?u` for KKP, or equivalent for other protocols. The terminal it is -talking to is the process on the other end of its PTY — which in a nested -setup is the intermediate emulator, not the outermost terminal. The outer -terminal (webtty, Alacritty, etc.) is not in that negotiation at all. - -For KKP to work across a nested chain, every intermediate emulator would need -to implement **protocol forwarding**: detect the inner app's capability query, -proxy it up through all layers to the true outer terminal, collect the -response, and relay it back down. It would then need to forward all KKP-encoded -input from the outer terminal to the inner app transparently, without -consuming or re-encoding it. - -This is a hard problem. It requires every link in the chain to actively -participate. In practice, intermediate emulators (vim `:terminal`, tmux, -screen) present their own terminal model to inner processes. They do not -transparently expose the outer terminal's capabilities, and most do not -implement KKP forwarding at all. - -**The consequence:** KKP is only reliable between directly adjacent processes. -In any chain longer than one hop, KKP support depends entirely on whether -every intermediate emulator implements forwarding — a property the outermost -terminal cannot observe or control. - -**Legacy encoding does not have this problem.** Legacy escape codes require no -negotiation. Every terminal emulator in the chain forwards input bytes to its -inner PTY unconditionally. The sequence arrives at the target application -regardless of how many layers it passed through or which capabilities any of -them advertise. - -The tradeoff is that legacy encoding carries no formal protocol: meaning is -agreed upon by convention between terminal and app, not guaranteed by a -negotiated handshake. Legacy escape codes are old and widely supported, but -there is no in-band capability flag that confirms the app will interpret them -correctly. - -### The two sequences for Shift+Enter - -| Sequence | Encoding | How activated | -|---|---|---| -| `\u001b[13;2u` | `ESC [ 13 ; 2 u` | KKP — requires negotiation between adjacent terminal and app | -| `\u001b\r` | `ESC CR` | Legacy encoding — no negotiation required | - -Both are understood by opencode, Helix, and other modern TUI apps as -Shift+Enter. They differ in whether they require protocol agreement between -adjacent layers. - -### Our use case - -In the direct setup (single hop): - -``` -browser → webtty ←→ opencode -``` - -opencode negotiates KKP directly with webtty's PTY environment, enters KKP -mode, and `\u001b[13;2u` is understood. `\u001b\r` also works here — opencode -supports both KKP and legacy encoding. - -When a user runs opencode inside vim's `:terminal` (a common workflow): - -``` -browser → webtty ←→ vim :terminal ←→ opencode -``` - -opencode now negotiates KKP with **vim's terminal emulator**. Vim `:terminal` -does not implement KKP. It neither responds to `\u001b[?u` affirmatively nor -forwards the query up to webtty. opencode never enters KKP mode. When webtty -sends `\u001b[13;2u`, opencode does not recognise it as Shift+Enter. The -keypress is silently lost. - -The legacy sequence (`\u001b\r`) passes through the same chain cleanly: -vim `:terminal` forwards it to its inner PTY, and opencode receives it -regardless of whether KKP was negotiated. - -The same breakage with `\u001b[13;2u` reproduces in VS Code's integrated -terminal hosting a vim `:terminal` session, confirming it is a property of -the chain structure, not of webtty specifically. - -Alacritty's default Shift+Enter binding uses legacy encoding for this reason: - -```toml -[[keyboard.bindings]] -key = "Return" -mods = "Shift" -chars = "\u001B\r" -``` - -Users running `alacritty → vim :terminal → opencode` report Shift+Enter -working correctly — the legacy sequence survives all layers. - -### Compatibility matrix - -| Sequence | direct (1 hop) | via vim :term (2 hops) | alacritty direct | -|---|---|---|---| -| `\u001b[13;2u` | ✅ KKP negotiated | ❌ vim :term does not forward KKP | ✅ | -| `\u001b\r` | ✅ | ✅ | ✅ | - ---- - -## Decision - -The recommended `chars` value for Shift+Enter in `keyboardBindings` is -`"\u001b\r"`, not `"\u001b[13;2u"`. - -`"\u001b[13;2u"` is not deprecated. It still works in direct single-hop -setups. Users who are certain their workflow never involves an intermediate -terminal may prefer it. It should not be the primary recommendation because -nested terminal usage is common and the failure is silent. - -The `config.md` spec will be updated: `"\u001b\r"` becomes the primary -example; `"\u001b[13;2u"` is noted as valid for single-hop setups only. - ---- - -## Considered Options - -### Option A: Keep `"\u001b[13;2u"` as the recommendation - -Correct for the direct case. Silently broken whenever an intermediate terminal -emulator is in the chain — a common setup. Users get no Shift+Enter and no -error message. - -**Rejected** — silent failure in a common workflow is worse than a less -"modern" default. - -### Option B: Recommend `"\u001b\r"`, note `"\u001b[13;2u"` as an alternative (chosen) - -`"\u001b\r"` works across all tested scenarios. The cost is that it uses -legacy encoding rather than a negotiated protocol. Both sequences are -understood by every app that supports Shift+Enter at all, so the cost is -theoretical for current apps. - -### Option C: Detect nested environments and switch sequences automatically - -Not feasible. webtty is the outermost layer; it sends bytes into a PTY and -has no visibility into the process tree on the other side. The number of -terminal layers between webtty and the target app is not observable. - ---- - -## Consequences - -- `config.md` will be updated: `"\u001b\r"` replaces `"\u001b[13;2u"` as the - primary binding example for Shift+Enter. -- Existing users with `"\u001b[13;2u"` who use webtty directly are unaffected. -- Users running TUI apps inside vim `:terminal` (or any other non-KKP-forwarding - intermediate terminal) should switch to `"\u001b\r"`. - ---- - -## Q&A - -**Q: What if an app in the chain only supports KKP and not legacy encoding?** - -Then Shift+Enter is broken in nested terminal setups and no `chars` value that -webtty sends can fix it. webtty is the outermost terminal; it has no mechanism -to reach an inner app directly over the intermediate emulator's head. The fix -must be in the intermediate emulator: it needs to implement KKP protocol -forwarding so that the inner app's negotiation reaches the outer terminal. - -In practice this scenario does not arise today. No widely-used TUI framework -(bubbletea, ratatui, textual) drops legacy encoding support, because KKP is -still not universal and doing so would break compatibility with the majority of -terminals. Apps negotiate KKP when available and fall back to legacy encoding -when not. A future app that deliberately drops legacy support would be making -an explicit compatibility tradeoff. - -**Q: Can this problem ever be fully solved at the webtty layer?** - -No. The problem is structural: capability negotiation is point-to-point and -intermediate emulators present their own terminal model. webtty can only -control what it sends into the PTY. It cannot know or influence how many -emulator layers sit between it and the target app, or whether those layers -implement protocol forwarding. - -The complete solution requires the intermediate emulators to participate — -either by implementing KKP forwarding (as kitty can be configured to do) or -by using legacy encoding, which requires no negotiation and passes through -all layers by default. - -**Q: Does tmux or screen have the same problem?** - -Yes. tmux and GNU screen are terminal multiplexers that act as intermediate -emulators. Neither implements KKP forwarding by default. Apps running inside -a tmux or screen session will not enter KKP mode regardless of whether the -outer terminal supports it. Legacy encoding passes through both without issue -for the same reason it passes through vim `:terminal`. - -**Q: KKP sequences like `\u001b[13;2u` still start with `\u001b` — why does KKP need ESC if it encodes everything in `13;2u`?** - -`\u001b[` together is CSI — Control Sequence Introducer — a two-byte prefix -inherited from ECMA-48 (1976) that tells the terminal parser "switch from -character mode into control sequence mode." The `[` on its own would just be -a literal `[`; ESC is what signals the parser to treat what follows as a -structured control sequence rather than printable text. KKP only defines the -payload — the parameter format (`keycode;modifier`) and the final byte (`u`). -It is built on top of the existing CSI framework, not a replacement for it. - -ESC plays a different role in each encoding: - -| Sequence | Role of ESC | -|---|---| -| `\u001b\r` (legacy) | Prefix modifier — "the next character is modified" | -| `\u001b[13;2u` (KKP) | CSI introducer — "what follows is a structured control sequence" | - -In legacy encoding ESC carries the meaning. In KKP, ESC is framing -infrastructure — the meaning lives in `13;2u`. - ---- - -## Related Decisions - -- [ADR 018 — Configurable keyboard bindings](018.keyboard.key-bindings.md): - introduced `keyboardBindings` and originally recommended `"\u001b[13;2u"`. diff --git a/docs/adrs/020.cli.key.md b/docs/adrs/020.cli.key.md new file mode 100644 index 0000000..f5e1485 --- /dev/null +++ b/docs/adrs/020.cli.key.md @@ -0,0 +1,62 @@ +# ADR 020: CLI — `webtty key` command + +**SPEC:** [Key Bindings](../specs/key-bindings.md) +**Status:** Accepted +**Date:** 2026-03-29 + +--- + +## Context + +`keyboardBindings` requires users to know the exact JSON `chars` value for each key combo they want to map. Finding that value is not obvious. + +The intuitive approach — `cat` — fails silently: ESC appears as `^[` but CR is invisible because it triggers a carriage return, moving the cursor rather than printing anything. Users pressing Shift+Enter see `^[` and a blank line with no indication that a second byte was received. + +Tools like `od -c` or `xxd` work but require knowledge of octal/hex encoding and how to translate the output to a JSON string (`033` → `\u001b`, `0d` → `\r`). This is an unnecessary barrier for a configuration task. + +The `chars` values users need are also not available in any online lookup table — legacy sequences are convention-based and not standardised, so there is no reference to query. + +## Decision + +Add a `webtty key` CLI command that puts the terminal in raw mode and prints the JSON `chars` value for each key combo pressed, ready to copy-paste into `keyboardBindings`. Output shows the received bytes alongside the JSON value so users understand what was captured: + +``` + received → chars + ───────────────── + ESC CR → "\u001b\r" + \x04 → "\u0004" +``` + +The command loops until `q` is pressed, which also prints a closing `─` line. + +### Implementation + +- `bytesToDisplay(buf)` — formats raw bytes as human-readable names (`ESC`, `CR`, `TAB`, `SPC`, `DEL`, printable ASCII as-is, unknown as `\xHH`). +- `bytesToChars(buf)` — formats raw bytes as a JSON `chars` string (`\u001b`, `\r`, `\t`, `\n`, printable ASCII as-is, unknown as `\uXXXX`). +- `cmdKey()` — sets `process.stdin.setRawMode(true)`, accumulates input bytes with a 50ms idle timeout to collect full multi-byte sequences, flushes via both formatters, exits on `q` (`0x71`). +- `q` is used as the quit key rather than Ctrl+C so users can freely capture Ctrl+C (`"\u0003"`) as a binding. + +Both formatter functions are exported for unit testing independently of the TTY requirement. + +## Considered Options + +### Option A: Document `od -c` / `xxd` only + +Requires users to know octal/hex encoding and manually translate to JSON. Unnecessary friction for a common setup task. + +**Rejected** — the translation step is error-prone and undiscoverable. + +### Option B: `webtty key` command (chosen) + +~40 LOC. Output is copy-paste ready. No external tools required. Formatter functions are pure and fully testable. + +## Consequences + +- Users can discover the `chars` value for any key combo by running `webtty key` and pressing the key — no external tools, no manual encoding. +- `bytesToDisplay` and `bytesToChars` are exported pure functions with unit test coverage. +- `q` exits the command; Ctrl+C and all other combos are captured and printed normally. + +## Related Decisions + +- [ADR 018 — Configurable keyboard bindings](018.key-bindings.config-support.md): introduced `keyboardBindings` and the `chars` field that this command helps populate. +- [ADR 019 — Keyboard sequence compatibility](019.key-bindings.sequence-compat.md): explains why legacy encoding (`"\u001b\r"`) is preferred; `webtty key` captures exactly what the terminal sends, which for most terminals is the legacy sequence. diff --git a/docs/specs/client.md b/docs/specs/client.md index d277109..0f22390 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -110,7 +110,7 @@ A capture-phase `keydown` listener on the terminal container fires before ghostt **`chars` encoding:** The client sends `binding.chars` verbatim. Standard JSON escapes (`\uXXXX`, `\r`, `\n`, `\t`) are resolved by `JSON.parse` at config load — no further processing occurs. -See [keyboard spec](keyboard.md) for the binding object schema and examples. +See [key-bindings spec](key-bindings.md) for the binding object schema and examples. ## Copy Behavior @@ -148,4 +148,4 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Cursor style | `cursorStyle` / `cursorStyleBlink` defaults; DECSCUSR from PTY overrides at runtime via client-side intercept | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | | Non-text paste | Ctrl+V with no `text/plain` in clipboard forwards `\x16` to PTY; TUI apps read non-text content via their native OS clipboard API | [ADR 014](../adrs/014.client.image-paste.md) | ✅ | | Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), wheel events are forwarded as SGR mouse sequences (`\x1b[<64/65;col;rowM`) instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md) | ✅ | -| Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.keyboard.key-bindings.md) | ✅ | +| Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.key-bindings.config-support.md) | ✅ | diff --git a/docs/specs/config.md b/docs/specs/config.md index 9fc9246..81ebf10 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -120,7 +120,7 @@ All keys are optional — omit any key to use the default value. | `fontSize` | number | `13` | Font size in px | | `fontFamily` | string | `"Menlo, Consolas, 'DejaVu Sans Mono', monospace"` | CSS font-family stack | | `theme` | object | Campbell | Terminal color palette — see theme keys below | -| `keyboardBindings` | array | `[]` | Custom key-to-sequence bindings sent to the PTY. See [keyboard spec](keyboard.md) for schema and examples. | +| `keyboardBindings` | array | `[]` | Custom key-to-sequence bindings sent to the PTY. See [key-bindings spec](key-bindings.md) for schema and examples. | ### Theme keys @@ -210,4 +210,4 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter | Server logs | `logs: true` appends server stdout/stderr to `~/.config/webtty/server.log` | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Cursor style | `cursorStyle` sets the default cursor shape; DECSCUSR sequences from apps override at runtime | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | | Mouse scroll speed | `mouseScrollSpeed` scales SGR events per wheel tick for apps with mouse tracking; default `1` | [ADR 017](../adrs/017.client.mouse-scroll.md) | ✅ | -| Keyboard bindings | `keyboardBindings` — configurable key-to-sequence mappings sent to PTY; defaults to `[]` | [ADR 018](../adrs/018.keyboard.key-bindings.md), [keyboard spec](keyboard.md) | ✅ | +| Keyboard bindings | `keyboardBindings` — configurable key-to-sequence mappings sent to PTY; defaults to `[]` | [ADR 018](../adrs/018.key-bindings.config-support.md), [key-bindings spec](key-bindings.md) | ✅ | diff --git a/docs/specs/keyboard.md b/docs/specs/key-bindings.md similarity index 67% rename from docs/specs/keyboard.md rename to docs/specs/key-bindings.md index 29fea92..2fe67cf 100644 --- a/docs/specs/keyboard.md +++ b/docs/specs/key-bindings.md @@ -1,4 +1,4 @@ -# SPEC: Keyboard Bindings +# SPEC: Key Bindings **Author:** jesse23 **Last Updated:** 2026-03-29 @@ -7,21 +7,13 @@ ## Description -Browser `KeyboardEvent` objects carry no terminal escape sequence knowledge. -When a user presses Shift+Enter in webtty, ghostty-web receives a `keydown` -with `key="Enter"` and `shiftKey=true` — and sends `\r` to the PTY, identical -to plain Enter. TUI apps that distinguish modifier+key combos (e.g. opencode -expecting Shift+Enter as a "new line" action) never receive the sequence they -expect. +Browser `KeyboardEvent` objects carry no terminal escape sequence knowledge. When a user presses Shift+Enter in webtty, ghostty-web receives a `keydown` with `key="Enter"` and `shiftKey=true` — and sends `\r` to the PTY, identical to plain Enter. TUI apps that distinguish modifier+key combos (e.g. opencode expecting Shift+Enter as a "new line" action) never receive the sequence they expect. -`keyboardBindings` solves this with a config-driven mapping layer. A -capture-phase `keydown` listener intercepts matching combos before ghostty-web -sees them and sends the configured `chars` directly to the PTY. +`keyboardBindings` solves this with a config-driven mapping layer. A capture-phase `keydown` listener intercepts matching combos before ghostty-web sees them and sends the configured `chars` directly to the PTY. ## Binding schema -`keyboardBindings` is an array of binding objects in -`~/.config/webtty/config.json`. Each entry has the following fields: +`keyboardBindings` is an array of binding objects in `~/.config/webtty/config.json`. Each entry has the following fields: | Field | Type | Required | Description | |-------|------|----------|-------------| @@ -29,8 +21,7 @@ sees them and sends the configured `chars` directly to the PTY. | `mods` | string[] | no | Array of modifier names. Accepted values: `"shift"`, `"ctrl"`, `"alt"`, `"meta"`. Unknown values are silently filtered out at config load. Order does not matter — `["ctrl", "shift"]` and `["shift", "ctrl"]` are equivalent. Omit or `[]` for no modifiers. | | `chars` | string | yes | Byte sequence sent verbatim to the PTY. Must be a valid JSON string — use `\uXXXX` for non-printable bytes (e.g. `"\u001b"` for ESC). `\x` hex notation is **not valid JSON** and will cause a parse error. Standard escapes `\r`, `\n`, `\t` work as expected. All escapes are resolved by `JSON.parse` at config load; the string is sent as-is with no further processing. | -`keyboardBindings` defaults to `[]`. No bindings ship with webtty — users opt -in by adding entries in `~/.config/webtty/config.json`. +`keyboardBindings` defaults to `[]`. No bindings ship with webtty — users opt in by adding entries in `~/.config/webtty/config.json`. User entries are **merged with defaults by `(key, mods)` identity**: @@ -40,28 +31,18 @@ User entries are **merged with defaults by `(key, mods)` identity**: ## Defining `chars` -The `chars` value is the byte sequence the target TUI app expects for that -key combo. Two encoding approaches exist: +The `chars` value is the byte sequence the target TUI app expects for that key combo. Two encoding approaches exist: | Approach | Example (Shift+Enter) | Compatibility | |---|---|---| | Legacy encoding | `"\u001b\r"` | Works across all terminal chains | | [Kitty Keyboard Protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) (KKP) | `"\u001b[13;2u"` | Works in direct single-hop setups only | -**Legacy encoding is recommended for general use.** Legacy escape codes require -no capability negotiation — they pass through every terminal layer -unconditionally. KKP sequences are only reliable when the app negotiates -directly with webtty. In nested terminal setups (e.g. running a TUI app inside -vim `:terminal`, tmux, or screen), the intermediate emulator does not forward -KKP capability negotiation, so the app never enters KKP mode and the sequence -is silently ignored. See [ADR 019](../adrs/019.keyboard.sequence-compat.md) -for the full analysis. +**Legacy encoding is recommended for general use.** Legacy escape codes require no capability negotiation — they pass through every terminal layer unconditionally. KKP sequences are only reliable when the app negotiates directly with webtty. In nested terminal setups (e.g. running a TUI app inside vim `:terminal`, tmux, or screen), the intermediate emulator does not forward KKP capability negotiation, so the app never enters KKP mode and the sequence is silently ignored. See [ADR 019](../adrs/019.key-bindings.sequence-compat.md) for the full analysis. ### Legacy encoding -Legacy escape codes are convention-based: ESC followed by the unmodified key -bytes. There is no in-band handshake — meaning is agreed by convention between -the terminal and the app. +Legacy escape codes are convention-based: ESC followed by the unmodified key bytes. There is no in-band handshake — meaning is agreed by convention between the terminal and the app. Common examples: @@ -82,24 +63,20 @@ Common examples: #### Discovering sequences from scratch -Run `webtty chars` — it puts the terminal in raw mode and prints the `chars` -value ready to copy-paste for each key combo you press: +Run `webtty key` — it puts the terminal in raw mode and prints the `chars` value ready to copy-paste for each key combo you press: ```sh -webtty chars +webtty key # Press any key combo to see its chars value. q to quit. # # received → chars # ----------------- -# # ESC CR → "\u001b\r" # \x04 → "\u0004" # S → "S" ``` -If you do not have webtty installed, `od -c` is the fallback — it shows named -escape characters so CR is visible as `\r` rather than an invisible cursor -movement: +If you do not have webtty installed, `od -c` is the fallback — it shows named escape characters so CR is visible as `\r` rather than an invisible cursor movement: ```sh cat | od -c @@ -107,22 +84,17 @@ cat | od -c # 0000000 033 \r (033 = octal ESC → \u001b) ``` -If the captured sequence still does nothing, the app may expect a different -convention. Check the app's documentation or source for what sequence it -registers as its key handler. +If the captured sequence still does nothing, the app may expect a different convention. Check the app's documentation or source for what sequence it registers as its key handler. ### Kitty Keyboard Protocol -KKP sequences are structured and derivable from a formula. Use them only when -you are certain the app runs directly against webtty with no intermediate -terminal emulator. +KKP sequences are structured and derivable from a formula. Use them only when you are certain the app runs directly against webtty with no intermediate terminal emulator. ``` \u001b [ {keycode} ; {modifier} u ``` -Modifier value = `1` + sum of active modifiers (Shift `1`, Alt `2`, Ctrl `4`, -Meta `8`): +Modifier value = `1` + sum of active modifiers (Shift `1`, Alt `2`, Ctrl `4`, Meta `8`): | Modifiers | Modifier value | Shift+Enter example | |---|---|---| @@ -154,29 +126,21 @@ Full keycode table: [kitty keyboard protocol — functional key definitions](htt ## Client implementation -A capture-phase `keydown` listener on the terminal container fires before -ghostty-web's canvas handlers and intercepts matching bindings: +A capture-phase `keydown` listener on the terminal container fires before ghostty-web's canvas handlers and intercepts matching bindings: 1. Walk `config.keyboardBindings`. -2. Normalize `event.key` to lowercase and compare against each binding's - `key` + `mods`. -3. On match: call `e.preventDefault()` + `e.stopPropagation()` to suppress - ghostty-web's default handling, then send `binding.chars` verbatim over - WebSocket to the PTY. +2. Normalize `event.key` to lowercase and compare against each binding's `key` + `mods`. +3. On match: call `e.preventDefault()` + `e.stopPropagation()` to suppress ghostty-web's default handling, then send `binding.chars` verbatim over WebSocket to the PTY. 4. No match: return immediately — ghostty-web handles as normal. -`stopPropagation` (not `stopImmediatePropagation`) is sufficient: it prevents -the event from reaching the canvas so ghostty-web never fires its default -handling. +`stopPropagation` (not `stopImmediatePropagation`) is sufficient: it prevents the event from reaching the canvas so ghostty-web never fires its default handling. -`chars` is sent verbatim. Standard JSON escapes (`\uXXXX`, `\r`, `\n`, `\t`) -are resolved by `JSON.parse` at config load — no further processing occurs at -send time. +`chars` is sent verbatim. Standard JSON escapes (`\uXXXX`, `\r`, `\n`, `\t`) are resolved by `JSON.parse` at config load — no further processing occurs at send time. ## Features | Feature | Description | ADR | Done? | |---------|-------------|-----|-------| -| Configurable bindings | `keyboardBindings` array in `~/.config/webtty/config.json`; capture-phase `keydown` handler sends `chars` to PTY; defaults to `[]` | [ADR 018](../adrs/018.keyboard.key-bindings.md) | ✅ | -| Legacy encoding recommendation | `"\u001b\r"` recommended over KKP sequences for Shift+Enter and similar combos; works across nested terminal chains | [ADR 019](../adrs/019.keyboard.sequence-compat.md) | ✅ | -| `webtty chars` | CLI command: puts terminal in raw mode, prints the `chars` value for each key combo pressed; q to quit | [ADR 018](../adrs/018.keyboard.key-bindings.md) | ✅ | +| Configurable bindings | `keyboardBindings` array in `~/.config/webtty/config.json`; capture-phase `keydown` handler sends `chars` to PTY; defaults to `[]` | [ADR 018](../adrs/018.key-bindings.config-support.md) | ✅ | +| Legacy encoding recommendation | `"\u001b\r"` recommended over KKP sequences for Shift+Enter and similar combos; works across nested terminal chains | [ADR 019](../adrs/019.key-bindings.sequence-compat.md) | ✅ | +| `webtty key` | CLI command: puts terminal in raw mode, prints the `chars` value for each key combo pressed; q to quit | [ADR 020](../adrs/020.cli.key.md) | ✅ | diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index 1b3da68..cd34075 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -263,8 +263,8 @@ describe('cli — no-arg, help, config', () => { expect(stdout).toContain('/s/main'); }); - test('chars exits with error when not a TTY', async () => { - const { stderr, exitCode } = await runCli(port, 'chars'); + test('key exits with error when not a TTY', async () => { + const { stderr, exitCode } = await runCli(port, 'key'); expect(exitCode).toBe(1); expect(stderr).toContain('requires an interactive terminal'); }); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 6d90efd..512fa34 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -202,9 +202,9 @@ export function bytesToDisplay(buf: Buffer): string { .join(' '); } -export function cmdChars(): void { +export function cmdKey(): void { if (!process.stdin.isTTY) { - console.error('webtty chars: requires an interactive terminal'); + console.error('webtty key: requires an interactive terminal'); process.exit(1); } diff --git a/src/cli/index.ts b/src/cli/index.ts index 745b6a5..d2f9dd0 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,5 +1,5 @@ import { - cmdChars, + cmdKey, cmdConfig, cmdGo, cmdList, @@ -32,7 +32,7 @@ function printHelp(): void { row('stop', 'Stop the webtty server'), row('start', 'Start the webtty server'), row('config', 'Open the config file in $VISUAL, $EDITOR, or a default editor'), - row('chars', 'Capture a key combo and print its chars value for keyboardBindings'), + row('key', 'Capture a key combo and print its chars value for keyboardBindings'), row('help', 'Show this help message'), ].join('\n'), ); @@ -68,8 +68,8 @@ if (!cmd) { case 'config': cmdConfig(); break; - case 'chars': - cmdChars(); + case 'key': + cmdKey(); break; case 'help': case '--help': From 5713a5c76840543986ea18856d0f1556dc4c1ee4 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 29 Mar 2026 15:14:04 -0400 Subject: [PATCH 07/19] fix: update Shift+Tab encoding description and correct hardcode option for Shift+Enter --- docs/adrs/018.key-bindings.config-support.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adrs/018.key-bindings.config-support.md b/docs/adrs/018.key-bindings.config-support.md index 4bd5675..4cae9bb 100644 --- a/docs/adrs/018.key-bindings.config-support.md +++ b/docs/adrs/018.key-bindings.config-support.md @@ -35,7 +35,7 @@ Other common gaps sharing the same root cause: - `Ctrl+Enter` — apps that use kitty keyboard protocol expect `\u001b[13;5u` - `Alt+Enter` — fullscreen toggle or app-specific action -- `Shift+Tab` — apps that use kitty keyboard protocol expect `\u001b[9;2u` +- `Shift+Tab` — legacy encoding `\u001b[Z` (standard xterm reverse-tab sequence) Hardcoding Shift+Enter would invite a parade of follow-up issues. A general binding mechanism closes the entire class. @@ -153,7 +153,7 @@ container.addEventListener('keydown', (e: KeyboardEvent) => { ## Considered Options -### Option A: Hardcode Shift+Enter → `\x1b[13;2u` +### Option A: Hardcode Shift+Enter → `\u001b\r` ~5 lines in `index.ts`. Fixes the immediate opencode issue. From a636546c8aead3002ab16dbf036652cb62d43e16 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 29 Mar 2026 15:44:25 -0400 Subject: [PATCH 08/19] chore: remove author information from SPEC documentation files --- .../create-live-spec/assets/spec-template.md | 1 - docs/specs/cli.md | 1 - docs/specs/client.md | 1 - docs/specs/config.md | 1 - docs/specs/key-bindings.md | 41 ++++++++----------- docs/specs/webtty.md | 1 - 6 files changed, 16 insertions(+), 30 deletions(-) diff --git a/docs/skills/create-live-spec/assets/spec-template.md b/docs/skills/create-live-spec/assets/spec-template.md index 82aa94b..0ef8d0a 100644 --- a/docs/skills/create-live-spec/assets/spec-template.md +++ b/docs/skills/create-live-spec/assets/spec-template.md @@ -5,7 +5,6 @@ Use this exact format when generating a new SPEC. Fill placeholders from the use ```markdown # SPEC: {Full Title} -**Author:** {author or "Team"} **Last Updated:** {YYYY-MM-DD} --- diff --git a/docs/specs/cli.md b/docs/specs/cli.md index 4edab65..f28bd3a 100644 --- a/docs/specs/cli.md +++ b/docs/specs/cli.md @@ -1,6 +1,5 @@ # SPEC: CLI -**Author:** jesse23 **Last Updated:** 2026-03-24 (amended: help formatting, ls filter, restart removed, at/mv commands, isServerRunning validation, stop-on-last-rm) --- diff --git a/docs/specs/client.md b/docs/specs/client.md index 0f22390..5f3f088 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -1,6 +1,5 @@ # SPEC: Client -**Author:** jesse23 **Last Updated:** 2026-03-27 --- diff --git a/docs/specs/config.md b/docs/specs/config.md index 81ebf10..7a43152 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -1,6 +1,5 @@ # SPEC: Config -**Author:** jesse23 **Last Updated:** 2026-03-27 --- diff --git a/docs/specs/key-bindings.md b/docs/specs/key-bindings.md index 2fe67cf..0c8e88d 100644 --- a/docs/specs/key-bindings.md +++ b/docs/specs/key-bindings.md @@ -1,6 +1,5 @@ # SPEC: Key Bindings -**Author:** jesse23 **Last Updated:** 2026-03-29 --- @@ -51,17 +50,7 @@ Common examples: | Shift+Enter | `"\u001b\r"` | | Alt+Enter | `"\u001b\r"` (same as Shift+Enter in many apps — check app docs) | -#### Porting from another terminal - -| App | Shift+Enter example | How to convert to `chars` | -|---|---|---| -| Alacritty | `chars = "\u001B\r"` | `\uNNNN` copies as-is (case-insensitive); `\xHH` → `\u00HH` (pad to 4 digits) | -| Ghostty | `keybind = shift+enter=text:\x1b\r` | `\xHH` → `\u00HH`; `\r`, `\n`, `\t` copy as-is | -| VS Code | `"args": { "text": "\u001b\r" }` | `\uNNNN` copies as-is | -| Windows Terminal | `"input": "\u001b\r"` | `\uNNNN` copies as-is | -| iTerm2 | `0x1b 0x0d` ("Send Hex Code") | Split on spaces; each `0xHH` → `\u00HH` (e.g. `0x1b 0x0d` → `"\u001b\r"`) | - -#### Discovering sequences from scratch +#### Discovering sequences Run `webtty key` — it puts the terminal in raw mode and prints the `chars` value ready to copy-paste for each key combo you press: @@ -76,15 +65,17 @@ webtty key # S → "S" ``` -If you do not have webtty installed, `od -c` is the fallback — it shows named escape characters so CR is visible as `\r` rather than an invisible cursor movement: +If the captured sequence still does nothing, the app may expect a different convention. Check the app's documentation or source for what sequence it registers as its key handler. -```sh -cat | od -c -# press the key combo, then Ctrl+D -# 0000000 033 \r (033 = octal ESC → \u001b) -``` +#### Porting from another terminal -If the captured sequence still does nothing, the app may expect a different convention. Check the app's documentation or source for what sequence it registers as its key handler. +| App | Shift+Enter example | How to convert to `chars` | +|---|---|---| +| Alacritty | `chars = "\u001B\r"` | `\uNNNN` copies as-is (case-insensitive); `\xHH` → `\u00HH` (pad to 4 digits) | +| Ghostty | `keybind = shift+enter=text:\x1b\r` | `\xHH` → `\u00HH`; `\r`, `\n`, `\t` copy as-is | +| VS Code Terminal | `"args": { "text": "\u001b\r" }` | `\uNNNN` copies as-is | +| Windows Terminal | `"input": "\u001b\r"` | `\uNNNN` copies as-is | +| iTerm2 | `0x1b 0x0d` ("Send Hex Code") | Split on spaces; each `0xHH` → `\u00HH` (e.g. `0x1b 0x0d` → `"\u001b\r"`) | ### Kitty Keyboard Protocol @@ -117,12 +108,12 @@ Full keycode table: [kitty keyboard protocol — functional key definitions](htt ## Binding examples -| Intent | `key` | `mods` | `chars` | -|---|---|---|---| -| Shift+Enter → new line (opencode, Helix, etc.) | `"enter"` | `["shift"]` | `"\u001b\r"` | -| Ctrl+Enter → same | `"enter"` | `["ctrl"]` | `"\u001b[13;5u"` | -| Shift+Tab → backtab | `"tab"` | `["shift"]` | `"\u001b[9;2u"` | -| Suppress a key (consume without sending) | `"enter"` | `["shift"]` | `""` | +| Use case | `key` | `mods` | Legacy `chars` | KKP `chars` | Reason | +|---|---|---|---|---|---| +| Shift+Enter | `"enter"` | `["shift"]` | `"\u001b\r"` | `"\u001b[13;2u"` | Most terminal emulators don't distinguish Shift+Enter from Enter by default; explicit binding required for apps like opencode (see [#1505](https://github.com/anomalyco/opencode/issues/1505)) | +| Ctrl+Enter | `"enter"` | `["ctrl"]` | not supported (Ctrl+key legacy maps only A–Z to control chars `0x01`–`0x1A`; Enter has no equivalent) | `"\u001b[13;5u"` | Distinguish "run command" from "new line" in multi-line prompts; no legacy encoding exists for Ctrl+Enter | +| Ctrl+w | `"w"` | `["ctrl"]` | `"\u0017"` | `"\u0017"` | Browser intercepts Ctrl+W to close the tab; this binding suppresses that and forwards the keystroke to the PTY (e.g. vim window command) | +| Suppress F5 | `"f5"` | `[]` | `""` | `""` | Browser intercepts F5 to reload the page; empty `chars` consumes the key without sending anything to the PTY | ## Client implementation diff --git a/docs/specs/webtty.md b/docs/specs/webtty.md index 5272a22..1b021b0 100644 --- a/docs/specs/webtty.md +++ b/docs/specs/webtty.md @@ -1,6 +1,5 @@ # SPEC: webtty -**Author:** jesse23 **Last Updated:** 2026-03-24 --- From b21374d027ab79b094953aa9d61e3ba9f147e2f6 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 29 Mar 2026 15:55:16 -0400 Subject: [PATCH 09/19] feat: add multiplexer section with terminal tools and their compatibility --- docs/awesome-web.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/awesome-web.md b/docs/awesome-web.md index 2d00273..dbecf62 100644 --- a/docs/awesome-web.md +++ b/docs/awesome-web.md @@ -97,6 +97,18 @@ Here's every known approach and how they compare: | **[GoTTY](https://github.com/yudai/gotty)** | ❌ | ❌ | Lightweight Go tool, abandoned since 2017 | | **[Zellij](https://zellij.dev)** (web mode) | ✅ | ❌ | Full multiplexer with web mode, Linux/macOS only | +### Multiplexer + +A multiplexer lets you split a terminal into panes, manage named sessions, and detach/reattach without losing state. + +| Name | Windows | macOS/Linux | Notes | +|------|---------|-------------|-------| +| **[vim](https://www.vim.org) / [Neovim](https://neovim.io)** | ✅ | ✅ | Editor-based approach — `:terminal` and pane splits give you multiple shells inside the editor; not a dedicated multiplexer but works everywhere natively | +| **[Zellij](https://zellij.dev)** | ✅ | ✅ | First major multiplexer with native Windows support (v0.44.0, March 2026); modern Rust-based, layouts, plugins, and session management | +| **[tmux](https://github.com/tmux/tmux)** | ❌ WSL only | ✅ | The gold standard on macOS/Linux; no native Windows support | +| **[psmux](https://github.com/psmux/psmux)** | ✅ only | ❌ | Native tmux-compatible multiplexer for Windows Terminal, PowerShell, and cmd.exe; zero dependencies, Rust-based | +| **[GNU Screen](https://www.gnu.org/software/screen/)** | ❌ WSL only | ✅ | Legacy but stable; predates tmux; mostly used on remote servers where tmux isn't available | + ### Terminal Software Recommendations Good pieces for a solid terminal workflow: From e444df11b4f738718bf05c970ef6e14fe3cba04d Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 29 Mar 2026 15:59:58 -0400 Subject: [PATCH 10/19] fix: update Zellij description for web mode and enhance multiplexer details --- docs/awesome-web.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/awesome-web.md b/docs/awesome-web.md index dbecf62..5157f06 100644 --- a/docs/awesome-web.md +++ b/docs/awesome-web.md @@ -92,10 +92,10 @@ Here's every known approach and how they compare: | Tool | Sessions | Windows | Notes | |------|----------|---------|-------| | **[webtty](https://github.com/jesse23/webtty)** (current repo) | ✅ | ✅ | Lightweight, session-aware, cross-platform | +| **[Zellij](https://zellij.dev)** (web mode) | ✅ | ✅ | Full multiplexer with web mode, cross-platform since v0.44.0 (March 2026) | | **[VibeTunnel](https://github.com/amantus-ai/vibetunnel)** | ✅ | ❌ | macOS/Linux, built for AI agent monitoring, native menu bar app + `vt` command wrapper | | **[ttyd](https://github.com/tsl0922/ttyd)** | ❌ | ✅ | One shell per URL; session terminates when the connection drops | | **[GoTTY](https://github.com/yudai/gotty)** | ❌ | ❌ | Lightweight Go tool, abandoned since 2017 | -| **[Zellij](https://zellij.dev)** (web mode) | ✅ | ❌ | Full multiplexer with web mode, Linux/macOS only | ### Multiplexer @@ -104,7 +104,7 @@ A multiplexer lets you split a terminal into panes, manage named sessions, and d | Name | Windows | macOS/Linux | Notes | |------|---------|-------------|-------| | **[vim](https://www.vim.org) / [Neovim](https://neovim.io)** | ✅ | ✅ | Editor-based approach — `:terminal` and pane splits give you multiple shells inside the editor; not a dedicated multiplexer but works everywhere natively | -| **[Zellij](https://zellij.dev)** | ✅ | ✅ | First major multiplexer with native Windows support (v0.44.0, March 2026); modern Rust-based, layouts, plugins, and session management | +| **[Zellij](https://zellij.dev)** | ✅ | ✅ | Full multiplexer with native Windows support (v0.44.0, March 2026); built-in web server for browser-based session access and read-only sharing; layouts, plugins, session management | | **[tmux](https://github.com/tmux/tmux)** | ❌ WSL only | ✅ | The gold standard on macOS/Linux; no native Windows support | | **[psmux](https://github.com/psmux/psmux)** | ✅ only | ❌ | Native tmux-compatible multiplexer for Windows Terminal, PowerShell, and cmd.exe; zero dependencies, Rust-based | | **[GNU Screen](https://www.gnu.org/software/screen/)** | ❌ WSL only | ✅ | Legacy but stable; predates tmux; mostly used on remote servers where tmux isn't available | From bf4daeaa940d663f40b3d93a98410cafe11e4f30 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 29 Mar 2026 16:03:56 -0400 Subject: [PATCH 11/19] fix: resolve biome lint errors in key command Co-authored-by: Sisyphus --- src/cli/commands.ts | 4 ++-- src/cli/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 512fa34..e381b0a 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -216,7 +216,7 @@ export function cmdKey(): void { process.stdin.resume(); console.log('Press any key combo to see its chars value. q to quit.\n'); console.log(` ${dim}received${reset} → ${bold}chars${reset}`); - console.log(' ' + '─'.repeat(17)); + console.log(` ${'─'.repeat(17)}`); let buf = Buffer.alloc(0); let timer: ReturnType | null = null; @@ -231,7 +231,7 @@ export function cmdKey(): void { process.stdin.on('data', (chunk: Buffer) => { if (chunk.length === 1 && chunk[0] === 0x71) { process.stdin.setRawMode(false); - console.log(' ' + '─'.repeat(17) + '\n'); + console.log(` ${'─'.repeat(17)}\n`); process.exit(0); } buf = Buffer.concat([buf, chunk]); diff --git a/src/cli/index.ts b/src/cli/index.ts index d2f9dd0..56002ef 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,7 +1,7 @@ import { - cmdKey, cmdConfig, cmdGo, + cmdKey, cmdList, cmdRemove, cmdRename, From 0e937c6aafba435a7b98f76f98036e341e7afe9b Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 29 Mar 2026 16:11:21 -0400 Subject: [PATCH 12/19] refactor: extract key formatter functions to key-format.ts for coverage Co-authored-by: Sisyphus --- src/cli/commands.test.ts | 2 +- src/cli/commands.ts | 31 +++---------------------------- src/cli/key-format.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 29 deletions(-) create mode 100644 src/cli/key-format.ts diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index cd34075..7cb3868 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -9,7 +9,7 @@ import { waitForServerDown, waitForServerReady, } from '../utils.test'; -import { bytesToChars, bytesToDisplay } from './commands'; +import { bytesToChars, bytesToDisplay } from './key-format'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CLI_ENTRY = path.resolve(__dirname, 'index.ts'); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index e381b0a..0e67cec 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { configDir } from '../config'; import { BASE_URL, isServerRunning, openBrowser, startServer, stopServer } from './http'; +import { bytesToChars, bytesToDisplay } from './key-format'; /** * Opens (or creates) session `id`, starts the server if needed, and opens the URL in the browser. @@ -174,40 +175,13 @@ export function cmdConfig(): void { childProcess.spawnSync(editor, [configPath], { stdio: 'inherit' }); } -export function bytesToChars(buf: Buffer): string { - let out = ''; - for (const b of buf) { - if (b === 0x1b) out += '\\u001b'; - else if (b === 0x0d) out += '\\r'; - else if (b === 0x09) out += '\\t'; - else if (b === 0x0a) out += '\\n'; - else if (b >= 0x20 && b < 0x7f) out += String.fromCharCode(b); - else out += `\\u${b.toString(16).padStart(4, '0')}`; - } - return `"${out}"`; -} - -export function bytesToDisplay(buf: Buffer): string { - return Array.from(buf) - .map((b) => { - if (b === 0x1b) return 'ESC'; - if (b === 0x0d) return 'CR'; - if (b === 0x09) return 'TAB'; - if (b === 0x0a) return 'LF'; - if (b === 0x20) return 'SPC'; - if (b === 0x7f) return 'DEL'; - if (b > 0x20 && b < 0x7f) return String.fromCharCode(b); - return `\\x${b.toString(16).padStart(2, '0')}`; - }) - .join(' '); -} - export function cmdKey(): void { if (!process.stdin.isTTY) { console.error('webtty key: requires an interactive terminal'); process.exit(1); } + /* v8 ignore start */ const dim = '\x1b[2m'; const bold = '\x1b[1m'; const reset = '\x1b[0m'; @@ -238,4 +212,5 @@ export function cmdKey(): void { if (timer) clearTimeout(timer); timer = setTimeout(flush, 50); }); + /* v8 ignore end */ } diff --git a/src/cli/key-format.ts b/src/cli/key-format.ts new file mode 100644 index 0000000..c4c01f5 --- /dev/null +++ b/src/cli/key-format.ts @@ -0,0 +1,27 @@ +export function bytesToChars(buf: Buffer): string { + let out = ''; + for (const b of buf) { + if (b === 0x1b) out += '\\u001b'; + else if (b === 0x0d) out += '\\r'; + else if (b === 0x09) out += '\\t'; + else if (b === 0x0a) out += '\\n'; + else if (b >= 0x20 && b < 0x7f) out += String.fromCharCode(b); + else out += `\\u${b.toString(16).padStart(4, '0')}`; + } + return `"${out}"`; +} + +export function bytesToDisplay(buf: Buffer): string { + return Array.from(buf) + .map((b) => { + if (b === 0x1b) return 'ESC'; + if (b === 0x0d) return 'CR'; + if (b === 0x09) return 'TAB'; + if (b === 0x0a) return 'LF'; + if (b === 0x20) return 'SPC'; + if (b === 0x7f) return 'DEL'; + if (b > 0x20 && b < 0x7f) return String.fromCharCode(b); + return `\\x${b.toString(16).padStart(2, '0')}`; + }) + .join(' '); +} From 999847968e67ead869fceb3ae22e4c0d743325d4 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 29 Mar 2026 20:34:15 -0400 Subject: [PATCH 13/19] test: achieve 100% coverage for commands.ts with direct unit tests Co-authored-by: Sisyphus --- src/cli/commands.test.ts | 360 ++++++++++++++++++++++++++++++++++++++- src/cli/commands.ts | 31 +++- 2 files changed, 386 insertions(+), 5 deletions(-) diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index 7cb3868..c142037 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -1,5 +1,7 @@ -import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { afterAll, beforeAll, describe, expect, mock, spyOn, test } from 'bun:test'; +import * as childProcessModule from 'node:child_process'; import { type ChildProcess, spawn } from 'node:child_process'; +import * as fsModule from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { @@ -9,7 +11,15 @@ import { waitForServerDown, waitForServerReady, } from '../utils.test'; -import { bytesToChars, bytesToDisplay } from './key-format'; +import { bytesToChars, bytesToDisplay } from './commands'; + +mock.module('./http', () => ({ + BASE_URL: 'http://localhost:2346', + isServerRunning: mock(async () => false), + startServer: mock(async () => {}), + stopServer: mock(async () => true), + openBrowser: mock(() => {}), +})); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CLI_ENTRY = path.resolve(__dirname, 'index.ts'); @@ -299,3 +309,349 @@ describe('cli — no-arg, help, config', () => { expect(stdout.trim()).toContain(expectedPath); }); }); + +describe('cli — unit (mocked http)', () => { + let http: typeof import('./http'); + let cmds: typeof import('./commands'); + + beforeAll(async () => { + http = await import('./http'); + cmds = await import('./commands'); + }); + + test('cmdStop when running stops server', async () => { + (http.isServerRunning as ReturnType).mockResolvedValueOnce(true); + (http.stopServer as ReturnType).mockResolvedValueOnce(true); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdStop(); + expect(log).toHaveBeenCalledWith('webtty stopped'); + log.mockRestore(); + }); + + test('cmdStop when stop fails exits with error', async () => { + (http.isServerRunning as ReturnType).mockResolvedValueOnce(true); + (http.stopServer as ReturnType).mockResolvedValueOnce(false); + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdStop()).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith('webtty stop failed'); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdStop when not running logs not running', async () => { + (http.isServerRunning as ReturnType).mockResolvedValueOnce(false); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdStop(); + expect(log).toHaveBeenCalledWith('webtty is not running'); + log.mockRestore(); + }); + + test('cmdStart when not running starts server', async () => { + (http.isServerRunning as ReturnType).mockResolvedValueOnce(false); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdStart(); + expect(http.startServer).toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith('webtty started'); + log.mockRestore(); + }); + + test('cmdStart when already running logs already running', async () => { + (http.isServerRunning as ReturnType).mockResolvedValueOnce(true); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdStart(); + expect(log).toHaveBeenCalledWith('webtty is already running'); + log.mockRestore(); + }); + + test('cmdList when not running exits with error', async () => { + global.fetch = mock(async () => { + throw new Error('ECONNREFUSED'); + }) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdList()).rejects.toThrow('exit'); + expect(log).toHaveBeenCalledWith('webtty is not running'); + log.mockRestore(); + exit.mockRestore(); + }); + + test('cmdList with sessions prints table', async () => { + const sessions = [{ id: 'main', connected: true, createdAt: 1700000000000 }]; + global.fetch = mock( + async () => new Response(JSON.stringify(sessions)), + ) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdList(); + expect(log).toHaveBeenCalledWith(expect.stringContaining('main')); + log.mockRestore(); + }); + + test('cmdList with no sessions prints no sessions', async () => { + global.fetch = mock(async () => new Response(JSON.stringify([]))) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdList(); + expect(log).toHaveBeenCalledWith('no sessions'); + log.mockRestore(); + }); + + test('cmdRemove without id exits with error', async () => { + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRemove()).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith(expect.stringContaining('requires a session id')); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRemove with valid id removes session', async () => { + global.fetch = mock( + async () => + new Response(null, { + status: 204, + headers: { 'x-sessions-remaining': '1' }, + }), + ) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdRemove('my-session'); + expect(log).toHaveBeenCalledWith('removed my-session'); + log.mockRestore(); + }); + + test('cmdRemove last session also stops server', async () => { + global.fetch = mock( + async () => + new Response(null, { + status: 204, + headers: { 'x-sessions-remaining': '0' }, + }), + ) as unknown as typeof fetch; + (http.stopServer as ReturnType).mockResolvedValueOnce(true); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdRemove('last'); + expect(log).toHaveBeenCalledWith(expect.stringContaining('webtty stopped')); + log.mockRestore(); + }); + + test('cmdRemove non-existent session exits with error', async () => { + global.fetch = mock(async () => new Response(null, { status: 404 })) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRemove('ghost')).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith(expect.stringContaining('not found')); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRemove fetch failure exits with error', async () => { + global.fetch = mock(async () => new Response(null, { status: 500 })) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRemove('bad')).rejects.toThrow('exit'); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRename without args exits with error', async () => { + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRename()).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith(expect.stringContaining('requires two arguments')); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRename success logs renamed', async () => { + global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdRename('old', 'new'); + expect(log).toHaveBeenCalledWith(expect.stringContaining('old')); + log.mockRestore(); + }); + + test('cmdRename not found exits with error', async () => { + global.fetch = mock(async () => new Response(null, { status: 404 })) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRename('x', 'y')).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith(expect.stringContaining('not found')); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRename fetch error exits with error', async () => { + global.fetch = mock( + async () => new Response(JSON.stringify({ error: 'conflict' }), { status: 409 }), + ) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRename('x', 'y')).rejects.toThrow('exit'); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdGo when server not running starts it', async () => { + (http.isServerRunning as ReturnType).mockResolvedValueOnce(false); + global.fetch = mock(async (url: string) => { + if (url.includes('/api/sessions/main')) return new Response(null, { status: 404 }); + return new Response(JSON.stringify({ id: 'main' }), { status: 200 }); + }) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdGo('main'); + expect(http.startServer).toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/main')); + log.mockRestore(); + }); + + test('cmdGo when session exists opens it', async () => { + (http.isServerRunning as ReturnType).mockResolvedValueOnce(true); + global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdGo('main'); + expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/main')); + log.mockRestore(); + }); + + test('cmdGo session creation failure exits with error', async () => { + (http.isServerRunning as ReturnType).mockResolvedValueOnce(true); + global.fetch = mock(async (url: string) => { + if (url.includes('/api/sessions/fail')) return new Response(null, { status: 404 }); + return new Response(JSON.stringify({ error: 'bad' }), { status: 500 }); + }) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdGo('fail')).rejects.toThrow('exit'); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdList when not running (fetch throws) exits', async () => { + global.fetch = mock(async () => { + throw new Error('conn'); + }) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdList(undefined)).rejects.toThrow('exit'); + log.mockRestore(); + exit.mockRestore(); + }); + + test('cmdList with filter shows matching sessions', async () => { + const sessions = [ + { id: 'main', connected: true, createdAt: 1700000000000 }, + { id: 'other', connected: false, createdAt: 1700000000000 }, + ]; + global.fetch = mock( + async () => new Response(JSON.stringify(sessions)), + ) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdList('main'); + expect(log).toHaveBeenCalledWith(expect.stringContaining('main')); + log.mockRestore(); + }); + + test('cmdRemove when not running exits with error', async () => { + global.fetch = mock(async () => { + throw new Error('ECONNREFUSED'); + }) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRemove('any')).rejects.toThrow('exit'); + expect(log).toHaveBeenCalledWith('webtty is not running'); + log.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRename when not running exits with error', async () => { + global.fetch = mock(async () => { + throw new Error('ECONNREFUSED'); + }) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRename('x', 'y')).rejects.toThrow('exit'); + expect(log).toHaveBeenCalledWith('webtty is not running'); + log.mockRestore(); + exit.mockRestore(); + }); + + test('cmdConfig opens editor (file exists)', () => { + const mkdirSpy = spyOn(fsModule, 'mkdirSync').mockImplementation(() => undefined); + const existsSpy = spyOn(fsModule, 'existsSync').mockReturnValue(true); + const spawnSpy = spyOn(childProcessModule, 'spawnSync').mockReturnValue( + {} as ReturnType, + ); + cmds.cmdConfig(); + expect(spawnSpy).toHaveBeenCalled(); + mkdirSpy.mockRestore(); + existsSpy.mockRestore(); + spawnSpy.mockRestore(); + }); + + test('cmdConfig creates file when absent', () => { + const origHome = process.env.HOME; + process.env.HOME = `/tmp/webtty-cfg-absent-${Date.now()}`; + const mkdirSpy = spyOn(fsModule, 'mkdirSync').mockImplementation(() => undefined); + const spawnSpy = spyOn(childProcessModule, 'spawnSync').mockReturnValue( + {} as ReturnType, + ); + cmds.cmdConfig(); + process.env.HOME = origHome; + mkdirSpy.mockRestore(); + spawnSpy.mockRestore(); + }); + + test('cmdKey exits with error when not a TTY', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => undefined as never); + (process.stdin as NodeJS.ReadStream & { setRawMode: unknown }).setRawMode = mock( + () => process.stdin, + ); + const resume = spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin); + const onSpy = spyOn(process.stdin, 'on').mockImplementation(() => process.stdin); + const log = spyOn(console, 'log').mockImplementation(() => {}); + cmds.cmdKey(); + expect(err).toHaveBeenCalledWith('webtty key: requires an interactive terminal'); + expect(exit).toHaveBeenCalledWith(1); + + const dataHandler = ( + onSpy as unknown as { mock: { calls: Array<[string, (c: Buffer) => void]> } } + ).mock.calls.find((c) => c[0] === 'data')?.[1]; + + dataHandler?.(Buffer.from([0x61])); + await new Promise((r) => setTimeout(r, 60)); + dataHandler?.(Buffer.from([0x71])); + + (process.stdin as unknown as Record).setRawMode = undefined; + Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true }); + err.mockRestore(); + exit.mockRestore(); + resume.mockRestore(); + onSpy.mockRestore(); + log.mockRestore(); + }); +}); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 0e67cec..e381b0a 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -3,7 +3,6 @@ import fs from 'node:fs'; import path from 'node:path'; import { configDir } from '../config'; import { BASE_URL, isServerRunning, openBrowser, startServer, stopServer } from './http'; -import { bytesToChars, bytesToDisplay } from './key-format'; /** * Opens (or creates) session `id`, starts the server if needed, and opens the URL in the browser. @@ -175,13 +174,40 @@ export function cmdConfig(): void { childProcess.spawnSync(editor, [configPath], { stdio: 'inherit' }); } +export function bytesToChars(buf: Buffer): string { + let out = ''; + for (const b of buf) { + if (b === 0x1b) out += '\\u001b'; + else if (b === 0x0d) out += '\\r'; + else if (b === 0x09) out += '\\t'; + else if (b === 0x0a) out += '\\n'; + else if (b >= 0x20 && b < 0x7f) out += String.fromCharCode(b); + else out += `\\u${b.toString(16).padStart(4, '0')}`; + } + return `"${out}"`; +} + +export function bytesToDisplay(buf: Buffer): string { + return Array.from(buf) + .map((b) => { + if (b === 0x1b) return 'ESC'; + if (b === 0x0d) return 'CR'; + if (b === 0x09) return 'TAB'; + if (b === 0x0a) return 'LF'; + if (b === 0x20) return 'SPC'; + if (b === 0x7f) return 'DEL'; + if (b > 0x20 && b < 0x7f) return String.fromCharCode(b); + return `\\x${b.toString(16).padStart(2, '0')}`; + }) + .join(' '); +} + export function cmdKey(): void { if (!process.stdin.isTTY) { console.error('webtty key: requires an interactive terminal'); process.exit(1); } - /* v8 ignore start */ const dim = '\x1b[2m'; const bold = '\x1b[1m'; const reset = '\x1b[0m'; @@ -212,5 +238,4 @@ export function cmdKey(): void { if (timer) clearTimeout(timer); timer = setTimeout(flush, 50); }); - /* v8 ignore end */ } From ad8b10a8e6bf026b05155b1ab4460cf57468177e Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 29 Mar 2026 20:36:05 -0400 Subject: [PATCH 14/19] refactor: remove unused key formatting functions from key-format.ts --- src/cli/key-format.ts | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 src/cli/key-format.ts diff --git a/src/cli/key-format.ts b/src/cli/key-format.ts deleted file mode 100644 index c4c01f5..0000000 --- a/src/cli/key-format.ts +++ /dev/null @@ -1,27 +0,0 @@ -export function bytesToChars(buf: Buffer): string { - let out = ''; - for (const b of buf) { - if (b === 0x1b) out += '\\u001b'; - else if (b === 0x0d) out += '\\r'; - else if (b === 0x09) out += '\\t'; - else if (b === 0x0a) out += '\\n'; - else if (b >= 0x20 && b < 0x7f) out += String.fromCharCode(b); - else out += `\\u${b.toString(16).padStart(4, '0')}`; - } - return `"${out}"`; -} - -export function bytesToDisplay(buf: Buffer): string { - return Array.from(buf) - .map((b) => { - if (b === 0x1b) return 'ESC'; - if (b === 0x0d) return 'CR'; - if (b === 0x09) return 'TAB'; - if (b === 0x0a) return 'LF'; - if (b === 0x20) return 'SPC'; - if (b === 0x7f) return 'DEL'; - if (b > 0x20 && b < 0x7f) return String.fromCharCode(b); - return `\\x${b.toString(16).padStart(2, '0')}`; - }) - .join(' '); -} From 8d4f1c2eb1dfc94dbd87962a4c9fc791589ef864 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 29 Mar 2026 21:22:03 -0400 Subject: [PATCH 15/19] fix: restore http module mock after commands tests to prevent bleed Co-authored-by: Sisyphus --- src/cli/commands.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index c142037..fe907e4 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -29,6 +29,7 @@ const tmpHome = makeTmpHome('cli-test'); afterAll(() => { cleanupTmpHome(tmpHome); + mock.restore(); }); async function runCli( From 343d62437c4701ca59b61730a6fec8b2e77b9978 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 29 Mar 2026 21:30:55 -0400 Subject: [PATCH 16/19] fix: replace mock.module with spyOn to prevent cross-file module registry pollution Co-authored-by: Sisyphus --- src/cli/commands.test.ts | 53 ++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index fe907e4..a02dd13 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -12,14 +12,7 @@ import { waitForServerReady, } from '../utils.test'; import { bytesToChars, bytesToDisplay } from './commands'; - -mock.module('./http', () => ({ - BASE_URL: 'http://localhost:2346', - isServerRunning: mock(async () => false), - startServer: mock(async () => {}), - stopServer: mock(async () => true), - openBrowser: mock(() => {}), -})); +import * as httpModule from './http'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CLI_ENTRY = path.resolve(__dirname, 'index.ts'); @@ -29,7 +22,6 @@ const tmpHome = makeTmpHome('cli-test'); afterAll(() => { cleanupTmpHome(tmpHome); - mock.restore(); }); async function runCli( @@ -312,58 +304,65 @@ describe('cli — no-arg, help, config', () => { }); describe('cli — unit (mocked http)', () => { - let http: typeof import('./http'); let cmds: typeof import('./commands'); beforeAll(async () => { - http = await import('./http'); cmds = await import('./commands'); }); test('cmdStop when running stops server', async () => { - (http.isServerRunning as ReturnType).mockResolvedValueOnce(true); - (http.stopServer as ReturnType).mockResolvedValueOnce(true); + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + const stop = spyOn(httpModule, 'stopServer').mockResolvedValueOnce(true); const log = spyOn(console, 'log').mockImplementation(() => {}); await cmds.cmdStop(); expect(log).toHaveBeenCalledWith('webtty stopped'); + isRunning.mockRestore(); + stop.mockRestore(); log.mockRestore(); }); test('cmdStop when stop fails exits with error', async () => { - (http.isServerRunning as ReturnType).mockResolvedValueOnce(true); - (http.stopServer as ReturnType).mockResolvedValueOnce(false); + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + const stop = spyOn(httpModule, 'stopServer').mockResolvedValueOnce(false); const err = spyOn(console, 'error').mockImplementation(() => {}); const exit = spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); await expect(cmds.cmdStop()).rejects.toThrow('exit'); expect(err).toHaveBeenCalledWith('webtty stop failed'); + isRunning.mockRestore(); + stop.mockRestore(); err.mockRestore(); exit.mockRestore(); }); test('cmdStop when not running logs not running', async () => { - (http.isServerRunning as ReturnType).mockResolvedValueOnce(false); + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); const log = spyOn(console, 'log').mockImplementation(() => {}); await cmds.cmdStop(); expect(log).toHaveBeenCalledWith('webtty is not running'); + isRunning.mockRestore(); log.mockRestore(); }); test('cmdStart when not running starts server', async () => { - (http.isServerRunning as ReturnType).mockResolvedValueOnce(false); + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); + const start = spyOn(httpModule, 'startServer').mockResolvedValueOnce(undefined); const log = spyOn(console, 'log').mockImplementation(() => {}); await cmds.cmdStart(); - expect(http.startServer).toHaveBeenCalled(); + expect(start).toHaveBeenCalled(); expect(log).toHaveBeenCalledWith('webtty started'); + isRunning.mockRestore(); + start.mockRestore(); log.mockRestore(); }); test('cmdStart when already running logs already running', async () => { - (http.isServerRunning as ReturnType).mockResolvedValueOnce(true); + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); const log = spyOn(console, 'log').mockImplementation(() => {}); await cmds.cmdStart(); expect(log).toHaveBeenCalledWith('webtty is already running'); + isRunning.mockRestore(); log.mockRestore(); }); @@ -433,10 +432,11 @@ describe('cli — unit (mocked http)', () => { headers: { 'x-sessions-remaining': '0' }, }), ) as unknown as typeof fetch; - (http.stopServer as ReturnType).mockResolvedValueOnce(true); + const stop = spyOn(httpModule, 'stopServer').mockResolvedValueOnce(true); const log = spyOn(console, 'log').mockImplementation(() => {}); await cmds.cmdRemove('last'); expect(log).toHaveBeenCalledWith(expect.stringContaining('webtty stopped')); + stop.mockRestore(); log.mockRestore(); }); @@ -508,29 +508,33 @@ describe('cli — unit (mocked http)', () => { }); test('cmdGo when server not running starts it', async () => { - (http.isServerRunning as ReturnType).mockResolvedValueOnce(false); + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); + const start = spyOn(httpModule, 'startServer').mockResolvedValueOnce(undefined); global.fetch = mock(async (url: string) => { if (url.includes('/api/sessions/main')) return new Response(null, { status: 404 }); return new Response(JSON.stringify({ id: 'main' }), { status: 200 }); }) as unknown as typeof fetch; const log = spyOn(console, 'log').mockImplementation(() => {}); await cmds.cmdGo('main'); - expect(http.startServer).toHaveBeenCalled(); + expect(start).toHaveBeenCalled(); expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/main')); + isRunning.mockRestore(); + start.mockRestore(); log.mockRestore(); }); test('cmdGo when session exists opens it', async () => { - (http.isServerRunning as ReturnType).mockResolvedValueOnce(true); + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; const log = spyOn(console, 'log').mockImplementation(() => {}); await cmds.cmdGo('main'); expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/main')); + isRunning.mockRestore(); log.mockRestore(); }); test('cmdGo session creation failure exits with error', async () => { - (http.isServerRunning as ReturnType).mockResolvedValueOnce(true); + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); global.fetch = mock(async (url: string) => { if (url.includes('/api/sessions/fail')) return new Response(null, { status: 404 }); return new Response(JSON.stringify({ error: 'bad' }), { status: 500 }); @@ -540,6 +544,7 @@ describe('cli — unit (mocked http)', () => { throw new Error('exit'); }); await expect(cmds.cmdGo('fail')).rejects.toThrow('exit'); + isRunning.mockRestore(); err.mockRestore(); exit.mockRestore(); }); From df51fc2e0b8ff0671557473a14f0776b79c908d9 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 29 Mar 2026 21:37:06 -0400 Subject: [PATCH 17/19] fix: restore global.fetch after unit tests to prevent bleed into server tests Co-authored-by: Sisyphus --- src/cli/commands.test.ts | 684 ++++++++++++++++++++------------------- 1 file changed, 345 insertions(+), 339 deletions(-) diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index a02dd13..dcd5185 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -305,359 +305,365 @@ describe('cli — no-arg, help, config', () => { describe('cli — unit (mocked http)', () => { let cmds: typeof import('./commands'); + let origFetch: typeof fetch; beforeAll(async () => { + origFetch = global.fetch; cmds = await import('./commands'); }); - test('cmdStop when running stops server', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - const stop = spyOn(httpModule, 'stopServer').mockResolvedValueOnce(true); - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdStop(); - expect(log).toHaveBeenCalledWith('webtty stopped'); - isRunning.mockRestore(); - stop.mockRestore(); - log.mockRestore(); - }); + afterAll(() => { + global.fetch = origFetch; + }); + +test('cmdStop when running stops server', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + const stop = spyOn(httpModule, 'stopServer').mockResolvedValueOnce(true); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdStop(); + expect(log).toHaveBeenCalledWith('webtty stopped'); + isRunning.mockRestore(); + stop.mockRestore(); + log.mockRestore(); +}); - test('cmdStop when stop fails exits with error', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - const stop = spyOn(httpModule, 'stopServer').mockResolvedValueOnce(false); - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdStop()).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith('webtty stop failed'); - isRunning.mockRestore(); - stop.mockRestore(); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdStop when not running logs not running', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdStop(); - expect(log).toHaveBeenCalledWith('webtty is not running'); - isRunning.mockRestore(); - log.mockRestore(); - }); - - test('cmdStart when not running starts server', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); - const start = spyOn(httpModule, 'startServer').mockResolvedValueOnce(undefined); - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdStart(); - expect(start).toHaveBeenCalled(); - expect(log).toHaveBeenCalledWith('webtty started'); - isRunning.mockRestore(); - start.mockRestore(); - log.mockRestore(); - }); - - test('cmdStart when already running logs already running', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdStart(); - expect(log).toHaveBeenCalledWith('webtty is already running'); - isRunning.mockRestore(); - log.mockRestore(); - }); - - test('cmdList when not running exits with error', async () => { - global.fetch = mock(async () => { - throw new Error('ECONNREFUSED'); - }) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdList()).rejects.toThrow('exit'); - expect(log).toHaveBeenCalledWith('webtty is not running'); - log.mockRestore(); - exit.mockRestore(); - }); - - test('cmdList with sessions prints table', async () => { - const sessions = [{ id: 'main', connected: true, createdAt: 1700000000000 }]; - global.fetch = mock( - async () => new Response(JSON.stringify(sessions)), - ) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdList(); - expect(log).toHaveBeenCalledWith(expect.stringContaining('main')); - log.mockRestore(); - }); - - test('cmdList with no sessions prints no sessions', async () => { - global.fetch = mock(async () => new Response(JSON.stringify([]))) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdList(); - expect(log).toHaveBeenCalledWith('no sessions'); - log.mockRestore(); - }); - - test('cmdRemove without id exits with error', async () => { - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRemove()).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith(expect.stringContaining('requires a session id')); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdRemove with valid id removes session', async () => { - global.fetch = mock( - async () => - new Response(null, { - status: 204, - headers: { 'x-sessions-remaining': '1' }, - }), - ) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdRemove('my-session'); - expect(log).toHaveBeenCalledWith('removed my-session'); - log.mockRestore(); - }); - - test('cmdRemove last session also stops server', async () => { - global.fetch = mock( - async () => - new Response(null, { - status: 204, - headers: { 'x-sessions-remaining': '0' }, - }), - ) as unknown as typeof fetch; - const stop = spyOn(httpModule, 'stopServer').mockResolvedValueOnce(true); - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdRemove('last'); - expect(log).toHaveBeenCalledWith(expect.stringContaining('webtty stopped')); - stop.mockRestore(); - log.mockRestore(); - }); - - test('cmdRemove non-existent session exits with error', async () => { - global.fetch = mock(async () => new Response(null, { status: 404 })) as unknown as typeof fetch; - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRemove('ghost')).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith(expect.stringContaining('not found')); - err.mockRestore(); - exit.mockRestore(); - }); +test('cmdStop when stop fails exits with error', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + const stop = spyOn(httpModule, 'stopServer').mockResolvedValueOnce(false); + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdStop()).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith('webtty stop failed'); + isRunning.mockRestore(); + stop.mockRestore(); + err.mockRestore(); + exit.mockRestore(); +}); - test('cmdRemove fetch failure exits with error', async () => { - global.fetch = mock(async () => new Response(null, { status: 500 })) as unknown as typeof fetch; - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRemove('bad')).rejects.toThrow('exit'); - err.mockRestore(); - exit.mockRestore(); +test('cmdStop when not running logs not running', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdStop(); + expect(log).toHaveBeenCalledWith('webtty is not running'); + isRunning.mockRestore(); + log.mockRestore(); +}); + +test('cmdStart when not running starts server', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); + const start = spyOn(httpModule, 'startServer').mockResolvedValueOnce(undefined); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdStart(); + expect(start).toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith('webtty started'); + isRunning.mockRestore(); + start.mockRestore(); + log.mockRestore(); +}); + +test('cmdStart when already running logs already running', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdStart(); + expect(log).toHaveBeenCalledWith('webtty is already running'); + isRunning.mockRestore(); + log.mockRestore(); +}); + +test('cmdList when not running exits with error', async () => { + global.fetch = mock(async () => { + throw new Error('ECONNREFUSED'); + }) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdList()).rejects.toThrow('exit'); + expect(log).toHaveBeenCalledWith('webtty is not running'); + log.mockRestore(); + exit.mockRestore(); +}); + +test('cmdList with sessions prints table', async () => { + const sessions = [{ id: 'main', connected: true, createdAt: 1700000000000 }]; + global.fetch = mock( + async () => new Response(JSON.stringify(sessions)), + ) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdList(); + expect(log).toHaveBeenCalledWith(expect.stringContaining('main')); + log.mockRestore(); +}); + +test('cmdList with no sessions prints no sessions', async () => { + global.fetch = mock(async () => new Response(JSON.stringify([]))) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdList(); + expect(log).toHaveBeenCalledWith('no sessions'); + log.mockRestore(); +}); + +test('cmdRemove without id exits with error', async () => { + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); }); + await expect(cmds.cmdRemove()).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith(expect.stringContaining('requires a session id')); + err.mockRestore(); + exit.mockRestore(); +}); - test('cmdRename without args exits with error', async () => { - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRename()).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith(expect.stringContaining('requires two arguments')); - err.mockRestore(); - exit.mockRestore(); +test('cmdRemove with valid id removes session', async () => { + global.fetch = mock( + async () => + new Response(null, { + status: 204, + headers: { 'x-sessions-remaining': '1' }, + }), + ) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdRemove('my-session'); + expect(log).toHaveBeenCalledWith('removed my-session'); + log.mockRestore(); +}); + +test('cmdRemove last session also stops server', async () => { + global.fetch = mock( + async () => + new Response(null, { + status: 204, + headers: { 'x-sessions-remaining': '0' }, + }), + ) as unknown as typeof fetch; + const stop = spyOn(httpModule, 'stopServer').mockResolvedValueOnce(true); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdRemove('last'); + expect(log).toHaveBeenCalledWith(expect.stringContaining('webtty stopped')); + stop.mockRestore(); + log.mockRestore(); +}); + +test('cmdRemove non-existent session exits with error', async () => { + global.fetch = mock(async () => new Response(null, { status: 404 })) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); }); + await expect(cmds.cmdRemove('ghost')).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith(expect.stringContaining('not found')); + err.mockRestore(); + exit.mockRestore(); +}); - test('cmdRename success logs renamed', async () => { - global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdRename('old', 'new'); - expect(log).toHaveBeenCalledWith(expect.stringContaining('old')); - log.mockRestore(); +test('cmdRemove fetch failure exits with error', async () => { + global.fetch = mock(async () => new Response(null, { status: 500 })) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); }); + await expect(cmds.cmdRemove('bad')).rejects.toThrow('exit'); + err.mockRestore(); + exit.mockRestore(); +}); - test('cmdRename not found exits with error', async () => { - global.fetch = mock(async () => new Response(null, { status: 404 })) as unknown as typeof fetch; - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRename('x', 'y')).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith(expect.stringContaining('not found')); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdRename fetch error exits with error', async () => { - global.fetch = mock( - async () => new Response(JSON.stringify({ error: 'conflict' }), { status: 409 }), - ) as unknown as typeof fetch; - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRename('x', 'y')).rejects.toThrow('exit'); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdGo when server not running starts it', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); - const start = spyOn(httpModule, 'startServer').mockResolvedValueOnce(undefined); - global.fetch = mock(async (url: string) => { - if (url.includes('/api/sessions/main')) return new Response(null, { status: 404 }); - return new Response(JSON.stringify({ id: 'main' }), { status: 200 }); - }) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdGo('main'); - expect(start).toHaveBeenCalled(); - expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/main')); - isRunning.mockRestore(); - start.mockRestore(); - log.mockRestore(); - }); - - test('cmdGo when session exists opens it', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdGo('main'); - expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/main')); - isRunning.mockRestore(); - log.mockRestore(); - }); - - test('cmdGo session creation failure exits with error', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - global.fetch = mock(async (url: string) => { - if (url.includes('/api/sessions/fail')) return new Response(null, { status: 404 }); - return new Response(JSON.stringify({ error: 'bad' }), { status: 500 }); - }) as unknown as typeof fetch; - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdGo('fail')).rejects.toThrow('exit'); - isRunning.mockRestore(); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdList when not running (fetch throws) exits', async () => { - global.fetch = mock(async () => { - throw new Error('conn'); - }) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdList(undefined)).rejects.toThrow('exit'); - log.mockRestore(); - exit.mockRestore(); - }); - - test('cmdList with filter shows matching sessions', async () => { - const sessions = [ - { id: 'main', connected: true, createdAt: 1700000000000 }, - { id: 'other', connected: false, createdAt: 1700000000000 }, - ]; - global.fetch = mock( - async () => new Response(JSON.stringify(sessions)), - ) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdList('main'); - expect(log).toHaveBeenCalledWith(expect.stringContaining('main')); - log.mockRestore(); - }); - - test('cmdRemove when not running exits with error', async () => { - global.fetch = mock(async () => { - throw new Error('ECONNREFUSED'); - }) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRemove('any')).rejects.toThrow('exit'); - expect(log).toHaveBeenCalledWith('webtty is not running'); - log.mockRestore(); - exit.mockRestore(); - }); - - test('cmdRename when not running exits with error', async () => { - global.fetch = mock(async () => { - throw new Error('ECONNREFUSED'); - }) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRename('x', 'y')).rejects.toThrow('exit'); - expect(log).toHaveBeenCalledWith('webtty is not running'); - log.mockRestore(); - exit.mockRestore(); +test('cmdRename without args exits with error', async () => { + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); }); + await expect(cmds.cmdRename()).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith(expect.stringContaining('requires two arguments')); + err.mockRestore(); + exit.mockRestore(); +}); - test('cmdConfig opens editor (file exists)', () => { - const mkdirSpy = spyOn(fsModule, 'mkdirSync').mockImplementation(() => undefined); - const existsSpy = spyOn(fsModule, 'existsSync').mockReturnValue(true); - const spawnSpy = spyOn(childProcessModule, 'spawnSync').mockReturnValue( - {} as ReturnType, - ); - cmds.cmdConfig(); - expect(spawnSpy).toHaveBeenCalled(); - mkdirSpy.mockRestore(); - existsSpy.mockRestore(); - spawnSpy.mockRestore(); - }); - - test('cmdConfig creates file when absent', () => { - const origHome = process.env.HOME; - process.env.HOME = `/tmp/webtty-cfg-absent-${Date.now()}`; - const mkdirSpy = spyOn(fsModule, 'mkdirSync').mockImplementation(() => undefined); - const spawnSpy = spyOn(childProcessModule, 'spawnSync').mockReturnValue( - {} as ReturnType, - ); - cmds.cmdConfig(); - process.env.HOME = origHome; - mkdirSpy.mockRestore(); - spawnSpy.mockRestore(); - }); - - test('cmdKey exits with error when not a TTY', async () => { - Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => undefined as never); - (process.stdin as NodeJS.ReadStream & { setRawMode: unknown }).setRawMode = mock( - () => process.stdin, - ); - const resume = spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin); - const onSpy = spyOn(process.stdin, 'on').mockImplementation(() => process.stdin); - const log = spyOn(console, 'log').mockImplementation(() => {}); - cmds.cmdKey(); - expect(err).toHaveBeenCalledWith('webtty key: requires an interactive terminal'); - expect(exit).toHaveBeenCalledWith(1); - - const dataHandler = ( - onSpy as unknown as { mock: { calls: Array<[string, (c: Buffer) => void]> } } - ).mock.calls.find((c) => c[0] === 'data')?.[1]; - - dataHandler?.(Buffer.from([0x61])); - await new Promise((r) => setTimeout(r, 60)); - dataHandler?.(Buffer.from([0x71])); - - (process.stdin as unknown as Record).setRawMode = undefined; - Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true }); - err.mockRestore(); - exit.mockRestore(); - resume.mockRestore(); - onSpy.mockRestore(); - log.mockRestore(); +test('cmdRename success logs renamed', async () => { + global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdRename('old', 'new'); + expect(log).toHaveBeenCalledWith(expect.stringContaining('old')); + log.mockRestore(); +}); + +test('cmdRename not found exits with error', async () => { + global.fetch = mock(async () => new Response(null, { status: 404 })) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); }); + await expect(cmds.cmdRename('x', 'y')).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith(expect.stringContaining('not found')); + err.mockRestore(); + exit.mockRestore(); +}); + +test('cmdRename fetch error exits with error', async () => { + global.fetch = mock( + async () => new Response(JSON.stringify({ error: 'conflict' }), { status: 409 }), + ) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRename('x', 'y')).rejects.toThrow('exit'); + err.mockRestore(); + exit.mockRestore(); +}); + +test('cmdGo when server not running starts it', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); + const start = spyOn(httpModule, 'startServer').mockResolvedValueOnce(undefined); + global.fetch = mock(async (url: string) => { + if (url.includes('/api/sessions/main')) return new Response(null, { status: 404 }); + return new Response(JSON.stringify({ id: 'main' }), { status: 200 }); + }) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdGo('main'); + expect(start).toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/main')); + isRunning.mockRestore(); + start.mockRestore(); + log.mockRestore(); +}); + +test('cmdGo when session exists opens it', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdGo('main'); + expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/main')); + isRunning.mockRestore(); + log.mockRestore(); +}); + +test('cmdGo session creation failure exits with error', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + global.fetch = mock(async (url: string) => { + if (url.includes('/api/sessions/fail')) return new Response(null, { status: 404 }); + return new Response(JSON.stringify({ error: 'bad' }), { status: 500 }); + }) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdGo('fail')).rejects.toThrow('exit'); + isRunning.mockRestore(); + err.mockRestore(); + exit.mockRestore(); +}); + +test('cmdList when not running (fetch throws) exits', async () => { + global.fetch = mock(async () => { + throw new Error('conn'); + }) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdList(undefined)).rejects.toThrow('exit'); + log.mockRestore(); + exit.mockRestore(); +}); + +test('cmdList with filter shows matching sessions', async () => { + const sessions = [ + { id: 'main', connected: true, createdAt: 1700000000000 }, + { id: 'other', connected: false, createdAt: 1700000000000 }, + ]; + global.fetch = mock( + async () => new Response(JSON.stringify(sessions)), + ) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdList('main'); + expect(log).toHaveBeenCalledWith(expect.stringContaining('main')); + log.mockRestore(); +}); + +test('cmdRemove when not running exits with error', async () => { + global.fetch = mock(async () => { + throw new Error('ECONNREFUSED'); + }) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRemove('any')).rejects.toThrow('exit'); + expect(log).toHaveBeenCalledWith('webtty is not running'); + log.mockRestore(); + exit.mockRestore(); +}); + +test('cmdRename when not running exits with error', async () => { + global.fetch = mock(async () => { + throw new Error('ECONNREFUSED'); + }) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRename('x', 'y')).rejects.toThrow('exit'); + expect(log).toHaveBeenCalledWith('webtty is not running'); + log.mockRestore(); + exit.mockRestore(); +}); + +test('cmdConfig opens editor (file exists)', () => { + const mkdirSpy = spyOn(fsModule, 'mkdirSync').mockImplementation(() => undefined); + const existsSpy = spyOn(fsModule, 'existsSync').mockReturnValue(true); + const spawnSpy = spyOn(childProcessModule, 'spawnSync').mockReturnValue( + {} as ReturnType, + ); + cmds.cmdConfig(); + expect(spawnSpy).toHaveBeenCalled(); + mkdirSpy.mockRestore(); + existsSpy.mockRestore(); + spawnSpy.mockRestore(); +}); + +test('cmdConfig creates file when absent', () => { + const origHome = process.env.HOME; + process.env.HOME = `/tmp/webtty-cfg-absent-${Date.now()}`; + const mkdirSpy = spyOn(fsModule, 'mkdirSync').mockImplementation(() => undefined); + const spawnSpy = spyOn(childProcessModule, 'spawnSync').mockReturnValue( + {} as ReturnType, + ); + cmds.cmdConfig(); + process.env.HOME = origHome; + mkdirSpy.mockRestore(); + spawnSpy.mockRestore(); +}); + +test('cmdKey exits with error when not a TTY', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => undefined as never); + (process.stdin as NodeJS.ReadStream & { setRawMode: unknown }).setRawMode = mock( + () => process.stdin, + ); + const resume = spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin); + const onSpy = spyOn(process.stdin, 'on').mockImplementation(() => process.stdin); + const log = spyOn(console, 'log').mockImplementation(() => {}); + cmds.cmdKey(); + expect(err).toHaveBeenCalledWith('webtty key: requires an interactive terminal'); + expect(exit).toHaveBeenCalledWith(1); + + const dataHandler = ( + onSpy as unknown as { mock: { calls: Array<[string, (c: Buffer) => void]> } } + ).mock.calls.find((c) => c[0] === 'data')?.[1]; + + dataHandler?.(Buffer.from([0x61])); + await new Promise((r) => setTimeout(r, 60)); + dataHandler?.(Buffer.from([0x71])); + + (process.stdin as unknown as Record).setRawMode = undefined; + Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true }); + err.mockRestore(); + exit.mockRestore(); + resume.mockRestore(); + onSpy.mockRestore(); + log.mockRestore(); +}); }); From 9f5843f2cda304f3be547fae1d5789c0c1ed6e83 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 29 Mar 2026 21:43:38 -0400 Subject: [PATCH 18/19] fix: reformat unit test indentation after describe restructure Co-authored-by: Sisyphus --- src/cli/commands.test.ts | 680 +++++++++++++++++++-------------------- 1 file changed, 340 insertions(+), 340 deletions(-) diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index dcd5185..093f01d 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -316,354 +316,354 @@ describe('cli — unit (mocked http)', () => { global.fetch = origFetch; }); -test('cmdStop when running stops server', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - const stop = spyOn(httpModule, 'stopServer').mockResolvedValueOnce(true); - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdStop(); - expect(log).toHaveBeenCalledWith('webtty stopped'); - isRunning.mockRestore(); - stop.mockRestore(); - log.mockRestore(); -}); - -test('cmdStop when stop fails exits with error', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - const stop = spyOn(httpModule, 'stopServer').mockResolvedValueOnce(false); - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdStop()).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith('webtty stop failed'); - isRunning.mockRestore(); - stop.mockRestore(); - err.mockRestore(); - exit.mockRestore(); -}); - -test('cmdStop when not running logs not running', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdStop(); - expect(log).toHaveBeenCalledWith('webtty is not running'); - isRunning.mockRestore(); - log.mockRestore(); -}); - -test('cmdStart when not running starts server', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); - const start = spyOn(httpModule, 'startServer').mockResolvedValueOnce(undefined); - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdStart(); - expect(start).toHaveBeenCalled(); - expect(log).toHaveBeenCalledWith('webtty started'); - isRunning.mockRestore(); - start.mockRestore(); - log.mockRestore(); -}); - -test('cmdStart when already running logs already running', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdStart(); - expect(log).toHaveBeenCalledWith('webtty is already running'); - isRunning.mockRestore(); - log.mockRestore(); -}); - -test('cmdList when not running exits with error', async () => { - global.fetch = mock(async () => { - throw new Error('ECONNREFUSED'); - }) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdList()).rejects.toThrow('exit'); - expect(log).toHaveBeenCalledWith('webtty is not running'); - log.mockRestore(); - exit.mockRestore(); -}); - -test('cmdList with sessions prints table', async () => { - const sessions = [{ id: 'main', connected: true, createdAt: 1700000000000 }]; - global.fetch = mock( - async () => new Response(JSON.stringify(sessions)), - ) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdList(); - expect(log).toHaveBeenCalledWith(expect.stringContaining('main')); - log.mockRestore(); -}); - -test('cmdList with no sessions prints no sessions', async () => { - global.fetch = mock(async () => new Response(JSON.stringify([]))) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdList(); - expect(log).toHaveBeenCalledWith('no sessions'); - log.mockRestore(); -}); - -test('cmdRemove without id exits with error', async () => { - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); + test('cmdStop when running stops server', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + const stop = spyOn(httpModule, 'stopServer').mockResolvedValueOnce(true); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdStop(); + expect(log).toHaveBeenCalledWith('webtty stopped'); + isRunning.mockRestore(); + stop.mockRestore(); + log.mockRestore(); + }); + + test('cmdStop when stop fails exits with error', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + const stop = spyOn(httpModule, 'stopServer').mockResolvedValueOnce(false); + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdStop()).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith('webtty stop failed'); + isRunning.mockRestore(); + stop.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdStop when not running logs not running', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdStop(); + expect(log).toHaveBeenCalledWith('webtty is not running'); + isRunning.mockRestore(); + log.mockRestore(); + }); + + test('cmdStart when not running starts server', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); + const start = spyOn(httpModule, 'startServer').mockResolvedValueOnce(undefined); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdStart(); + expect(start).toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith('webtty started'); + isRunning.mockRestore(); + start.mockRestore(); + log.mockRestore(); + }); + + test('cmdStart when already running logs already running', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdStart(); + expect(log).toHaveBeenCalledWith('webtty is already running'); + isRunning.mockRestore(); + log.mockRestore(); + }); + + test('cmdList when not running exits with error', async () => { + global.fetch = mock(async () => { + throw new Error('ECONNREFUSED'); + }) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdList()).rejects.toThrow('exit'); + expect(log).toHaveBeenCalledWith('webtty is not running'); + log.mockRestore(); + exit.mockRestore(); + }); + + test('cmdList with sessions prints table', async () => { + const sessions = [{ id: 'main', connected: true, createdAt: 1700000000000 }]; + global.fetch = mock( + async () => new Response(JSON.stringify(sessions)), + ) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdList(); + expect(log).toHaveBeenCalledWith(expect.stringContaining('main')); + log.mockRestore(); + }); + + test('cmdList with no sessions prints no sessions', async () => { + global.fetch = mock(async () => new Response(JSON.stringify([]))) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdList(); + expect(log).toHaveBeenCalledWith('no sessions'); + log.mockRestore(); + }); + + test('cmdRemove without id exits with error', async () => { + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRemove()).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith(expect.stringContaining('requires a session id')); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRemove with valid id removes session', async () => { + global.fetch = mock( + async () => + new Response(null, { + status: 204, + headers: { 'x-sessions-remaining': '1' }, + }), + ) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdRemove('my-session'); + expect(log).toHaveBeenCalledWith('removed my-session'); + log.mockRestore(); + }); + + test('cmdRemove last session also stops server', async () => { + global.fetch = mock( + async () => + new Response(null, { + status: 204, + headers: { 'x-sessions-remaining': '0' }, + }), + ) as unknown as typeof fetch; + const stop = spyOn(httpModule, 'stopServer').mockResolvedValueOnce(true); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdRemove('last'); + expect(log).toHaveBeenCalledWith(expect.stringContaining('webtty stopped')); + stop.mockRestore(); + log.mockRestore(); + }); + + test('cmdRemove non-existent session exits with error', async () => { + global.fetch = mock(async () => new Response(null, { status: 404 })) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRemove('ghost')).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith(expect.stringContaining('not found')); + err.mockRestore(); + exit.mockRestore(); }); - await expect(cmds.cmdRemove()).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith(expect.stringContaining('requires a session id')); - err.mockRestore(); - exit.mockRestore(); -}); - -test('cmdRemove with valid id removes session', async () => { - global.fetch = mock( - async () => - new Response(null, { - status: 204, - headers: { 'x-sessions-remaining': '1' }, - }), - ) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdRemove('my-session'); - expect(log).toHaveBeenCalledWith('removed my-session'); - log.mockRestore(); -}); - -test('cmdRemove last session also stops server', async () => { - global.fetch = mock( - async () => - new Response(null, { - status: 204, - headers: { 'x-sessions-remaining': '0' }, - }), - ) as unknown as typeof fetch; - const stop = spyOn(httpModule, 'stopServer').mockResolvedValueOnce(true); - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdRemove('last'); - expect(log).toHaveBeenCalledWith(expect.stringContaining('webtty stopped')); - stop.mockRestore(); - log.mockRestore(); -}); -test('cmdRemove non-existent session exits with error', async () => { - global.fetch = mock(async () => new Response(null, { status: 404 })) as unknown as typeof fetch; - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); + test('cmdRemove fetch failure exits with error', async () => { + global.fetch = mock(async () => new Response(null, { status: 500 })) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRemove('bad')).rejects.toThrow('exit'); + err.mockRestore(); + exit.mockRestore(); }); - await expect(cmds.cmdRemove('ghost')).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith(expect.stringContaining('not found')); - err.mockRestore(); - exit.mockRestore(); -}); -test('cmdRemove fetch failure exits with error', async () => { - global.fetch = mock(async () => new Response(null, { status: 500 })) as unknown as typeof fetch; - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); + test('cmdRename without args exits with error', async () => { + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRename()).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith(expect.stringContaining('requires two arguments')); + err.mockRestore(); + exit.mockRestore(); }); - await expect(cmds.cmdRemove('bad')).rejects.toThrow('exit'); - err.mockRestore(); - exit.mockRestore(); -}); -test('cmdRename without args exits with error', async () => { - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); + test('cmdRename success logs renamed', async () => { + global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdRename('old', 'new'); + expect(log).toHaveBeenCalledWith(expect.stringContaining('old')); + log.mockRestore(); }); - await expect(cmds.cmdRename()).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith(expect.stringContaining('requires two arguments')); - err.mockRestore(); - exit.mockRestore(); -}); - -test('cmdRename success logs renamed', async () => { - global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdRename('old', 'new'); - expect(log).toHaveBeenCalledWith(expect.stringContaining('old')); - log.mockRestore(); -}); -test('cmdRename not found exits with error', async () => { - global.fetch = mock(async () => new Response(null, { status: 404 })) as unknown as typeof fetch; - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); + test('cmdRename not found exits with error', async () => { + global.fetch = mock(async () => new Response(null, { status: 404 })) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRename('x', 'y')).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith(expect.stringContaining('not found')); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRename fetch error exits with error', async () => { + global.fetch = mock( + async () => new Response(JSON.stringify({ error: 'conflict' }), { status: 409 }), + ) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRename('x', 'y')).rejects.toThrow('exit'); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdGo when server not running starts it', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); + const start = spyOn(httpModule, 'startServer').mockResolvedValueOnce(undefined); + global.fetch = mock(async (url: string) => { + if (url.includes('/api/sessions/main')) return new Response(null, { status: 404 }); + return new Response(JSON.stringify({ id: 'main' }), { status: 200 }); + }) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdGo('main'); + expect(start).toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/main')); + isRunning.mockRestore(); + start.mockRestore(); + log.mockRestore(); + }); + + test('cmdGo when session exists opens it', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdGo('main'); + expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/main')); + isRunning.mockRestore(); + log.mockRestore(); + }); + + test('cmdGo session creation failure exits with error', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + global.fetch = mock(async (url: string) => { + if (url.includes('/api/sessions/fail')) return new Response(null, { status: 404 }); + return new Response(JSON.stringify({ error: 'bad' }), { status: 500 }); + }) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdGo('fail')).rejects.toThrow('exit'); + isRunning.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdList when not running (fetch throws) exits', async () => { + global.fetch = mock(async () => { + throw new Error('conn'); + }) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdList(undefined)).rejects.toThrow('exit'); + log.mockRestore(); + exit.mockRestore(); + }); + + test('cmdList with filter shows matching sessions', async () => { + const sessions = [ + { id: 'main', connected: true, createdAt: 1700000000000 }, + { id: 'other', connected: false, createdAt: 1700000000000 }, + ]; + global.fetch = mock( + async () => new Response(JSON.stringify(sessions)), + ) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdList('main'); + expect(log).toHaveBeenCalledWith(expect.stringContaining('main')); + log.mockRestore(); + }); + + test('cmdRemove when not running exits with error', async () => { + global.fetch = mock(async () => { + throw new Error('ECONNREFUSED'); + }) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRemove('any')).rejects.toThrow('exit'); + expect(log).toHaveBeenCalledWith('webtty is not running'); + log.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRename when not running exits with error', async () => { + global.fetch = mock(async () => { + throw new Error('ECONNREFUSED'); + }) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRename('x', 'y')).rejects.toThrow('exit'); + expect(log).toHaveBeenCalledWith('webtty is not running'); + log.mockRestore(); + exit.mockRestore(); }); - await expect(cmds.cmdRename('x', 'y')).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith(expect.stringContaining('not found')); - err.mockRestore(); - exit.mockRestore(); -}); - -test('cmdRename fetch error exits with error', async () => { - global.fetch = mock( - async () => new Response(JSON.stringify({ error: 'conflict' }), { status: 409 }), - ) as unknown as typeof fetch; - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRename('x', 'y')).rejects.toThrow('exit'); - err.mockRestore(); - exit.mockRestore(); -}); - -test('cmdGo when server not running starts it', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); - const start = spyOn(httpModule, 'startServer').mockResolvedValueOnce(undefined); - global.fetch = mock(async (url: string) => { - if (url.includes('/api/sessions/main')) return new Response(null, { status: 404 }); - return new Response(JSON.stringify({ id: 'main' }), { status: 200 }); - }) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdGo('main'); - expect(start).toHaveBeenCalled(); - expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/main')); - isRunning.mockRestore(); - start.mockRestore(); - log.mockRestore(); -}); - -test('cmdGo when session exists opens it', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdGo('main'); - expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/main')); - isRunning.mockRestore(); - log.mockRestore(); -}); - -test('cmdGo session creation failure exits with error', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - global.fetch = mock(async (url: string) => { - if (url.includes('/api/sessions/fail')) return new Response(null, { status: 404 }); - return new Response(JSON.stringify({ error: 'bad' }), { status: 500 }); - }) as unknown as typeof fetch; - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdGo('fail')).rejects.toThrow('exit'); - isRunning.mockRestore(); - err.mockRestore(); - exit.mockRestore(); -}); - -test('cmdList when not running (fetch throws) exits', async () => { - global.fetch = mock(async () => { - throw new Error('conn'); - }) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdList(undefined)).rejects.toThrow('exit'); - log.mockRestore(); - exit.mockRestore(); -}); - -test('cmdList with filter shows matching sessions', async () => { - const sessions = [ - { id: 'main', connected: true, createdAt: 1700000000000 }, - { id: 'other', connected: false, createdAt: 1700000000000 }, - ]; - global.fetch = mock( - async () => new Response(JSON.stringify(sessions)), - ) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdList('main'); - expect(log).toHaveBeenCalledWith(expect.stringContaining('main')); - log.mockRestore(); -}); - -test('cmdRemove when not running exits with error', async () => { - global.fetch = mock(async () => { - throw new Error('ECONNREFUSED'); - }) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRemove('any')).rejects.toThrow('exit'); - expect(log).toHaveBeenCalledWith('webtty is not running'); - log.mockRestore(); - exit.mockRestore(); -}); - -test('cmdRename when not running exits with error', async () => { - global.fetch = mock(async () => { - throw new Error('ECONNREFUSED'); - }) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRename('x', 'y')).rejects.toThrow('exit'); - expect(log).toHaveBeenCalledWith('webtty is not running'); - log.mockRestore(); - exit.mockRestore(); -}); - -test('cmdConfig opens editor (file exists)', () => { - const mkdirSpy = spyOn(fsModule, 'mkdirSync').mockImplementation(() => undefined); - const existsSpy = spyOn(fsModule, 'existsSync').mockReturnValue(true); - const spawnSpy = spyOn(childProcessModule, 'spawnSync').mockReturnValue( - {} as ReturnType, - ); - cmds.cmdConfig(); - expect(spawnSpy).toHaveBeenCalled(); - mkdirSpy.mockRestore(); - existsSpy.mockRestore(); - spawnSpy.mockRestore(); -}); - -test('cmdConfig creates file when absent', () => { - const origHome = process.env.HOME; - process.env.HOME = `/tmp/webtty-cfg-absent-${Date.now()}`; - const mkdirSpy = spyOn(fsModule, 'mkdirSync').mockImplementation(() => undefined); - const spawnSpy = spyOn(childProcessModule, 'spawnSync').mockReturnValue( - {} as ReturnType, - ); - cmds.cmdConfig(); - process.env.HOME = origHome; - mkdirSpy.mockRestore(); - spawnSpy.mockRestore(); -}); -test('cmdKey exits with error when not a TTY', async () => { - Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => undefined as never); - (process.stdin as NodeJS.ReadStream & { setRawMode: unknown }).setRawMode = mock( - () => process.stdin, - ); - const resume = spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin); - const onSpy = spyOn(process.stdin, 'on').mockImplementation(() => process.stdin); - const log = spyOn(console, 'log').mockImplementation(() => {}); - cmds.cmdKey(); - expect(err).toHaveBeenCalledWith('webtty key: requires an interactive terminal'); - expect(exit).toHaveBeenCalledWith(1); - - const dataHandler = ( - onSpy as unknown as { mock: { calls: Array<[string, (c: Buffer) => void]> } } - ).mock.calls.find((c) => c[0] === 'data')?.[1]; - - dataHandler?.(Buffer.from([0x61])); - await new Promise((r) => setTimeout(r, 60)); - dataHandler?.(Buffer.from([0x71])); - - (process.stdin as unknown as Record).setRawMode = undefined; - Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true }); - err.mockRestore(); - exit.mockRestore(); - resume.mockRestore(); - onSpy.mockRestore(); - log.mockRestore(); -}); + test('cmdConfig opens editor (file exists)', () => { + const mkdirSpy = spyOn(fsModule, 'mkdirSync').mockImplementation(() => undefined); + const existsSpy = spyOn(fsModule, 'existsSync').mockReturnValue(true); + const spawnSpy = spyOn(childProcessModule, 'spawnSync').mockReturnValue( + {} as ReturnType, + ); + cmds.cmdConfig(); + expect(spawnSpy).toHaveBeenCalled(); + mkdirSpy.mockRestore(); + existsSpy.mockRestore(); + spawnSpy.mockRestore(); + }); + + test('cmdConfig creates file when absent', () => { + const origHome = process.env.HOME; + process.env.HOME = `/tmp/webtty-cfg-absent-${Date.now()}`; + const mkdirSpy = spyOn(fsModule, 'mkdirSync').mockImplementation(() => undefined); + const spawnSpy = spyOn(childProcessModule, 'spawnSync').mockReturnValue( + {} as ReturnType, + ); + cmds.cmdConfig(); + process.env.HOME = origHome; + mkdirSpy.mockRestore(); + spawnSpy.mockRestore(); + }); + + test('cmdKey exits with error when not a TTY', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => undefined as never); + (process.stdin as NodeJS.ReadStream & { setRawMode: unknown }).setRawMode = mock( + () => process.stdin, + ); + const resume = spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin); + const onSpy = spyOn(process.stdin, 'on').mockImplementation(() => process.stdin); + const log = spyOn(console, 'log').mockImplementation(() => {}); + cmds.cmdKey(); + expect(err).toHaveBeenCalledWith('webtty key: requires an interactive terminal'); + expect(exit).toHaveBeenCalledWith(1); + + const dataHandler = ( + onSpy as unknown as { mock: { calls: Array<[string, (c: Buffer) => void]> } } + ).mock.calls.find((c) => c[0] === 'data')?.[1]; + + dataHandler?.(Buffer.from([0x61])); + await new Promise((r) => setTimeout(r, 60)); + dataHandler?.(Buffer.from([0x71])); + + (process.stdin as unknown as Record).setRawMode = undefined; + Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true }); + err.mockRestore(); + exit.mockRestore(); + resume.mockRestore(); + onSpy.mockRestore(); + log.mockRestore(); + }); }); From e2e3f320ca4013d9d32a955d07e72bc040bf3180 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 29 Mar 2026 21:57:02 -0400 Subject: [PATCH 19/19] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20escape=20quote/backslash=20in=20bytesToChars,=20add=20termin?= =?UTF-8?q?al=20cleanup=20on=20exit/signals,=20defensive=20return=20after?= =?UTF-8?q?=20process.exit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sisyphus --- src/cli/commands.test.ts | 37 +++++++++++++++++++++++++++++++++---- src/cli/commands.ts | 15 +++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index 093f01d..ecc4840 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -233,6 +233,14 @@ describe('bytesToChars', () => { test('non-ASCII control byte → \\uXXXX', () => { expect(bytesToChars(Buffer.from([0x00]))).toBe('"\\u0000"'); }); + + test('double quote → \\" (valid JSON escape)', () => { + expect(bytesToChars(Buffer.from([0x22]))).toBe('"\\""'); + }); + + test('backslash → \\\\ (valid JSON escape)', () => { + expect(bytesToChars(Buffer.from([0x5c]))).toBe('"\\\\"'); + }); }); describe('cli — no-arg, help, config', () => { @@ -636,9 +644,21 @@ describe('cli — unit (mocked http)', () => { spawnSpy.mockRestore(); }); - test('cmdKey exits with error when not a TTY', async () => { + test('cmdKey exits with error when not a TTY', () => { Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + expect(() => cmds.cmdKey()).toThrow('exit'); + expect(err).toHaveBeenCalledWith('webtty key: requires an interactive terminal'); + Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true }); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdKey TTY mode captures and formats key presses', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); const exit = spyOn(process, 'exit').mockImplementation(() => undefined as never); (process.stdin as NodeJS.ReadStream & { setRawMode: unknown }).setRawMode = mock( () => process.stdin, @@ -646,9 +666,18 @@ describe('cli — unit (mocked http)', () => { const resume = spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin); const onSpy = spyOn(process.stdin, 'on').mockImplementation(() => process.stdin); const log = spyOn(console, 'log').mockImplementation(() => {}); + + let capturedExitHandler: (() => void) | undefined; + const onceSpy = spyOn(process, 'once').mockImplementation( + (event: string | symbol, handler: (...args: unknown[]) => void) => { + if (event === 'exit') capturedExitHandler = handler as () => void; + return process; + }, + ); + cmds.cmdKey(); - expect(err).toHaveBeenCalledWith('webtty key: requires an interactive terminal'); - expect(exit).toHaveBeenCalledWith(1); + + capturedExitHandler?.(); const dataHandler = ( onSpy as unknown as { mock: { calls: Array<[string, (c: Buffer) => void]> } } @@ -660,10 +689,10 @@ describe('cli — unit (mocked http)', () => { (process.stdin as unknown as Record).setRawMode = undefined; Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true }); - err.mockRestore(); exit.mockRestore(); resume.mockRestore(); onSpy.mockRestore(); + onceSpy.mockRestore(); log.mockRestore(); }); }); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index e381b0a..9cf5bff 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -181,6 +181,8 @@ export function bytesToChars(buf: Buffer): string { else if (b === 0x0d) out += '\\r'; else if (b === 0x09) out += '\\t'; else if (b === 0x0a) out += '\\n'; + else if (b === 0x22) out += '\\"'; + else if (b === 0x5c) out += '\\\\'; else if (b >= 0x20 && b < 0x7f) out += String.fromCharCode(b); else out += `\\u${b.toString(16).padStart(4, '0')}`; } @@ -206,12 +208,22 @@ export function cmdKey(): void { if (!process.stdin.isTTY) { console.error('webtty key: requires an interactive terminal'); process.exit(1); + return; } const dim = '\x1b[2m'; const bold = '\x1b[1m'; const reset = '\x1b[0m'; + const restoreTerminal = () => { + try { + process.stdin.setRawMode(false); + } catch {} + }; + process.once('exit', restoreTerminal); + process.once('SIGINT', restoreTerminal); + process.once('SIGTERM', restoreTerminal); + process.stdin.setRawMode(true); process.stdin.resume(); console.log('Press any key combo to see its chars value. q to quit.\n'); @@ -231,6 +243,9 @@ export function cmdKey(): void { process.stdin.on('data', (chunk: Buffer) => { if (chunk.length === 1 && chunk[0] === 0x71) { process.stdin.setRawMode(false); + process.removeListener('exit', restoreTerminal); + process.removeListener('SIGINT', restoreTerminal); + process.removeListener('SIGTERM', restoreTerminal); console.log(` ${'─'.repeat(17)}\n`); process.exit(0); }