Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8ad6be2
feat: add ADR 019 for keyboard sequence compatibility in nested termi…
Mar 29, 2026
f2c435e
feat: update keyboard binding specifications and add new keyboard spe…
Mar 29, 2026
4008f3f
Refactor keyboard binding documentation and update recommendations
Mar 29, 2026
8488958
feat: add 'webtty chars' CLI command for capturing key combos and the…
Mar 29, 2026
b4be1b2
Refactor keyboard sequence handling and documentation
Mar 29, 2026
7881193
feat: implement configurable keyboard bindings and add webtty key com…
Mar 29, 2026
5713a5c
fix: update Shift+Tab encoding description and correct hardcode optio…
Mar 29, 2026
a636546
chore: remove author information from SPEC documentation files
Mar 29, 2026
b21374d
feat: add multiplexer section with terminal tools and their compatibi…
Mar 29, 2026
e444df1
fix: update Zellij description for web mode and enhance multiplexer d…
Mar 29, 2026
bf4daea
fix: resolve biome lint errors in key command
Mar 29, 2026
0e937c6
refactor: extract key formatter functions to key-format.ts for coverage
Mar 29, 2026
9998479
test: achieve 100% coverage for commands.ts with direct unit tests
Mar 30, 2026
ad8b10a
refactor: remove unused key formatting functions from key-format.ts
Mar 30, 2026
8d4f1c2
fix: restore http module mock after commands tests to prevent bleed
Mar 30, 2026
343d624
fix: replace mock.module with spyOn to prevent cross-file module regi…
Mar 30, 2026
df51fc2
fix: restore global.fetch after unit tests to prevent bleed into serv…
Mar 30, 2026
9f5843f
fix: reformat unit test indentation after describe restructure
Mar 30, 2026
e2e3f32
fix: address PR review — escape quote/backslash in bytesToChars, add …
Mar 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand All @@ -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.
Expand Down Expand Up @@ -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.

Expand All @@ -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.
153 changes: 153 additions & 0 deletions docs/adrs/019.key-bindings.sequence-compat.md
Original file line number Diff line number Diff line change
@@ -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"`.
62 changes: 62 additions & 0 deletions docs/adrs/020.cli.key.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading