Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,23 @@ on:
jobs:
lint:
runs-on: ubuntu-latest
container: oven/bun:1.3.10-debian
container: oven/bun:1.3.11-alpine
steps:
- uses: actions/checkout@v4
- run: bun install --frozen-lockfile
- run: bun run lint

build:
runs-on: ubuntu-latest
container: oven/bun:1.3.10-debian
container: oven/bun:1.3.11-alpine
steps:
- uses: actions/checkout@v4
- run: bun install --frozen-lockfile
- run: bun run build

test:
runs-on: ubuntu-latest
container: oven/bun:1.3.10-debian
container: oven/bun:1.3.11-alpine
steps:
- uses: actions/checkout@v4
- run: bun install --frozen-lockfile
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ on:
jobs:
release:
runs-on: ubuntu-latest
container: oven/bun:1.3.10-debian
container: oven/bun:1.3.11-alpine
permissions:
contents: write
steps:
- run: apt-get update && apt-get install -y --no-install-recommends git ca-certificates
- run: apk add --no-cache git ca-certificates
- uses: actions/checkout@v4
with:
fetch-depth: 0
Expand Down
176 changes: 176 additions & 0 deletions docs/adrs/018.client.keyboard-bindings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# ADR 018: Client — Configurable keyboard bindings

**SPEC:** [client](../specs/client.md), [config](../specs/config.md)
**Status:** Accepted
**Date:** 2026-03-28

---

## Context

### The problem

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 "new line" from "submit" (e.g. opencode) never receive the `\x1b\r` (ESC CR) sequence they expect.

Native terminals solve this with explicit custom key bindings. The user's Alacritty config shows the exact mapping:

```toml
[[keyboard.bindings]]
key = "Return"
mods = "Shift"
chars = "\u001B\r"
```

Ghostty uses an equivalent INI form:

```ini
keybind = shift+enter=text:\x1b\r
```

webtty has no equivalent. The gap is structural: the browser terminal layer has no config-driven key mapping, so any modifier+key combo that requires a non-default escape sequence silently breaks.

### Why Shift+Enter is not the only case

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`

Hardcoding Shift+Enter would invite a parade of follow-up issues. A general binding mechanism closes the entire class.

### Terminal ecosystem survey

| Terminal | Config format | Key names | Mods | Output field |
|---|---|---|---|---|
| Ghostty | INI `keybind = mods+key=text:\x1b\r` | W3C lowercase (`enter`, `arrow_up`) | plus-separated (`shift+ctrl`) | `text:` prefix |
| Alacritty | TOML `[[keyboard.bindings]]` | PascalCase (`Return`, `ArrowUp`) | pipe-separated (`Control\|Shift`) | `chars = "..."` |
| Windows Terminal | JSON `keybindings` array | lowercase (`enter`) | plus-separated (`ctrl+shift`) | `"input": "..."` |
| xterm.js | custom handler API (`attachCustomKeyEventHandler`) | `KeyboardEvent.key` (`Enter`) | `event.shiftKey` etc. | n/a |

**Convergences across Ghostty, Windows Terminal, and xterm.js:**
- Lowercase key names
- A `chars`/`input`/`text:` field for the raw byte sequence

webtty aligns with these conventions. For `mods`, all surveyed terminals use string-based formats (plus- or pipe-separated); webtty uses a **string array** instead — no separator to parse, straightforward set comparison in the implementation.

---

## Decision

Add a `keyboardBindings` array to `~/.config/webtty/config.json`. The client intercepts matching `keydown` events before ghostty-web sees them and sends the configured `chars` directly to the PTY over WebSocket.

### Config schema

```typescript
interface KeyboardBinding {
key: string; // case-insensitive KeyboardEvent.key name; see below
mods?: string[]; // optional array of modifier names; see below
chars: string; // byte sequence sent verbatim to the PTY
}
```

**`key`** — case-insensitive. Matched against `event.key.toLowerCase()`. Supported names:

| Category | Values |
|---|---|
| Control | `enter`, `escape`, `tab`, `backspace`, `delete`, `space` |
| Navigation | `arrowup`, `arrowdown`, `arrowleft`, `arrowright`, `home`, `end`, `pageup`, `pagedown` |
| Function | `f1` – `f12` |
| Printable | `a`–`z`, `0`–`9`, `` ` ``, `-`, `=`, `[`, `]`, `;`, `'`, `,`, `.`, `/`, `\` |

**`mods`** — an array of modifier name strings. Accepted values: `"shift"`, `"ctrl"`, `"alt"`, `"meta"`. Using an array avoids any string parsing — the implementation does a straightforward set comparison against the active modifier flags from the `KeyboardEvent`. Order is irrelevant; unknown values are silently ignored.

Examples: `["shift"]`, `["ctrl", "shift"]`, `["alt"]`. Omit the field or pass `[]` for no modifiers.

**`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.

`\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.

### Design rationale

Two priorities, in order:

**Priority 1 — minimum engineering effort.** The entire client-side cost of `chars` is `ws.send(binding.chars)`. There is nothing else: no escape expansion, no sequence lookup, no format negotiation. `JSON.parse` is the only "processing" that happens, and it runs once at config load for free as part of normal JSON deserialization.

The `mods` array follows the same principle: four string literals (`"shift"`, `"ctrl"`, `"alt"`, `"meta"`) map directly to four `KeyboardEvent` boolean properties (`shiftKey`, `ctrlKey`, `altKey`, `metaKey`). The match check is a set comparison — six lines of code, no parsing.

**Priority 2 — align with industry practice.** The schema converges on conventions shared across popular terminals:

| Design choice | webtty | Ghostty | Alacritty | Windows Terminal |
|---|---|---|---|---|
| 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"`) |
| 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.

### Override semantics

Built-in defaults and user-supplied bindings are **merged 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": ""`.

`keyboardBindings` defaults to `[]` — no bindings ship with webtty. Users add their own in `~/.config/webtty/config.json`.

### Client implementation

A capture-phase `keydown` listener on the terminal container fires before ghostty-web's canvas handlers:

```typescript
container.addEventListener('keydown', (e: KeyboardEvent) => {
const binding = findBinding(config.keyboardBindings, e);
if (!binding) return;
e.preventDefault();
e.stopPropagation();
if (binding.chars && ws.readyState === WebSocket.OPEN) {
ws.send(binding.chars);
}
}, { capture: true });
```

`findBinding` lowercases `e.key`, builds the active mods set from `e.shiftKey` / `e.ctrlKey` / `e.altKey` / `e.metaKey`, and returns the first binding whose `key` matches and whose `mods` array (as a set) matches exactly.

`stopPropagation` (not `stopImmediatePropagation`) is sufficient: it prevents the event from reaching the canvas, so ghostty-web never fires its default handling.

> ghostty-web exposes `attachCustomWheelEventHandler` for wheel events (ADR 017). A symmetric `attachCustomKeyEventHandler` would be the cleaner interception point, but ghostty-web does not expose this API for keyboard events. The DOM listener is used instead — if ghostty-web adds the API later it can be swapped in with no behaviour change.

### Server / config.ts changes

1. Add `KeyboardBinding` interface and `keyboardBindings` field to `Config`.
2. Add `DEFAULT_KEYBOARD_BINDINGS` constant.
3. `loadConfig()` merges user bindings with defaults by `(key, mods)` identity — analogous to how `theme` is merged.
4. Add `keyboardBindings` to the `/api/config` response in the server.
5. Validation: unknown fields in a binding entry are silently ignored (forward-compat). Unknown strings in the `mods` array are silently ignored.

---

## Considered Options

### Option A: Hardcode Shift+Enter → `\x1b[13;2u`

~5 lines in `index.ts`. Fixes the immediate opencode issue.

**Rejected** — Ctrl+Enter, Alt+Enter, and other combos have the same root cause. Hardcoding one case accumulates hidden tech debt and gives users no control.

### Option B: Full Ghostty-style binding system with actions

Support `action:` targets (e.g. `csi:A`, `esc:d`, `ignore`) in addition to `chars:`, matching Ghostty's action vocabulary.

**Deferred** — webtty is a passthrough terminal with no built-in actions. The only meaningful action today is "send bytes to PTY" (`chars`). The schema is extensible: an `action` field can be added later without breaking existing `chars`-only bindings.

---

## 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.
- 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.
19 changes: 18 additions & 1 deletion docs/specs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ src/client/
{
cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollback,
theme, copyOnSelect, rightClickBehavior,
mouseScrollSpeed // used by the custom wheel handler, not passed to Terminal constructor
mouseScrollSpeed, // used by the custom wheel handler, not passed to Terminal constructor
keyboardBindings // used by the keydown capture handler, not passed to Terminal constructor
}
```

Expand Down Expand Up @@ -96,6 +97,21 @@ All status messages written to the terminal share a consistent style:
| WS close (unexpected) | `Connection lost. Reconnecting in 2s...` | Reconnect after 2s |
| WS error | `WebSocket error.` | — |

## Keyboard Bindings

Browser `KeyboardEvent` objects do not carry terminal escape sequences — the browser has no knowledge of the Alacritty/Ghostty custom-binding convention that maps modifier+key combos to specific byte sequences. As a result, keys like Shift+Enter arrive at ghostty-web as a plain `keydown` with `shiftKey=true`, and ghostty-web sends the same `\r` it would for unmodified Enter — not the `\x1b\r` (ESC CR) that TUI apps such as opencode expect.

A capture-phase `keydown` listener on the terminal container fires before ghostty-web's canvas handlers and intercepts matching bindings:

1. Walk `config.keyboardBindings` (user-configured entries).
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.

**`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.

## Copy Behavior

Controlled by two config keys from `GET /api/config`:
Expand Down Expand Up @@ -132,3 +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) | ✅ |
Loading
Loading