diff --git a/docs/adrs/018.client.keyboard-bindings.md b/docs/adrs/018.key-bindings.config-support.md similarity index 88% rename from docs/adrs/018.client.keyboard-bindings.md rename to docs/adrs/018.key-bindings.config-support.md index ab70e08..4cae9bb 100644 --- a/docs/adrs/018.client.keyboard-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:** [client](../specs/client.md), [config](../specs/config.md) +**SPEC:** [Key Bindings](../specs/key-bindings.md) **Status:** Accepted **Date:** 2026-03-28 @@ -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. @@ -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. @@ -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. @@ -169,8 +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. 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/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/awesome-web.md b/docs/awesome-web.md index 2d00273..5157f06 100644 --- a/docs/awesome-web.md +++ b/docs/awesome-web.md @@ -92,10 +92,22 @@ 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 + +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)** | ✅ | ✅ | 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 | ### Terminal Software Recommendations 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 c0f6de1..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 --- @@ -110,7 +109,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 [key-bindings spec](key-bindings.md) for the binding object schema and examples. ## Copy Behavior @@ -148,4 +147,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.key-bindings.config-support.md) | ✅ | diff --git a/docs/specs/config.md b/docs/specs/config.md index e6a5f5d..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 --- @@ -120,7 +119,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 [key-bindings spec](key-bindings.md) for schema and examples. | ### Theme keys @@ -149,65 +148,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 +169,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 +209,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.key-bindings.config-support.md), [key-bindings spec](key-bindings.md) | ✅ | diff --git a/docs/specs/key-bindings.md b/docs/specs/key-bindings.md new file mode 100644 index 0000000..0c8e88d --- /dev/null +++ b/docs/specs/key-bindings.md @@ -0,0 +1,137 @@ +# SPEC: Key Bindings + +**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.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. + +Common examples: + +| Key combo | `chars` | +|---|---| +| Shift+Enter | `"\u001b\r"` | +| Alt+Enter | `"\u001b\r"` (same as Shift+Enter in many apps — check app docs) | + +#### 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: + +```sh +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 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. + +#### 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 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 + +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 + +| 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 + +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.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/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 --- diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index e669db2..ecc4840 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,6 +11,8 @@ import { waitForServerDown, waitForServerReady, } from '../utils.test'; +import { bytesToChars, bytesToDisplay } from './commands'; +import * as httpModule from './http'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CLI_ENTRY = path.resolve(__dirname, 'index.ts'); @@ -179,6 +183,66 @@ 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"'); + }); + + 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"'); + }); + + 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', () => { let port: number; let baseUrl: string; @@ -210,6 +274,12 @@ describe('cli — no-arg, help, config', () => { expect(stdout).toContain('/s/main'); }); + 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'); + }); + test('help prints usage', async () => { const { stdout, exitCode } = await runCli(port, 'help'); expect(exitCode).toBe(0); @@ -240,3 +310,389 @@ describe('cli — no-arg, help, config', () => { expect(stdout.trim()).toContain(expectedPath); }); }); + +describe('cli — unit (mocked http)', () => { + let cmds: typeof import('./commands'); + let origFetch: typeof fetch; + + beforeAll(async () => { + origFetch = global.fetch; + cmds = await import('./commands'); + }); + + 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('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 () => { + 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', () => { + 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, + ); + 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(); + + capturedExitHandler?.(); + + 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 }); + exit.mockRestore(); + resume.mockRestore(); + onSpy.mockRestore(); + onceSpy.mockRestore(); + log.mockRestore(); + }); +}); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 36ff7ba..9cf5bff 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -173,3 +173,84 @@ 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 === 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')}`; + } + 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); + 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'); + 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; + 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); + process.removeListener('exit', restoreTerminal); + process.removeListener('SIGINT', restoreTerminal); + process.removeListener('SIGTERM', restoreTerminal); + console.log(` ${'─'.repeat(17)}\n`); + 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..56002ef 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 { + cmdConfig, + cmdGo, + cmdKey, + 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('key', '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 'key': + cmdKey(); + break; case 'help': case '--help': case '-h':