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
106 changes: 106 additions & 0 deletions docs/adrs/016.config.term-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# ADR 016: Config — `term` default is `xterm-256color`, not inherited from parent process

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

---

## Context

webtty spawns a PTY child process and sets `$TERM` in its environment via the
`term` config key. The previous default was `process.env.TERM ?? 'xterm-256color'`,
which read `$TERM` from the process that launched webtty and forwarded it to
every PTY child.

The intent was reasonable — preserve the user's terminal preference — but it
conflates two unrelated terminals: the **parent terminal** (the one the user
ran `bunx webtty` from) and the **PTY terminal** (the ghostty-web instance
rendered in the browser). These are independent; the parent's `$TERM` is
irrelevant to what the PTY child is actually talking to.

### The Alacritty breakage

Alacritty sets `TERM=alacritty` in its environment. When webtty was launched
from Alacritty, the PTY child inherited `TERM=alacritty`. Vim (and other
TUI apps) consult the `$TERM` terminfo entry to decide which escape sequences
to emit. Specifically, the `Ss`/`Se` capabilities control whether the app
emits DECSCUSR cursor-shape sequences.

The `alacritty` terminfo entry's `Ss`/`Se` support depends on the user's
installed terminfo version. On systems with an outdated or missing entry,
`Ss`/`Se` are absent, and vim never emits DECSCUSR at all — so webtty's
client-side DECSCUSR intercept (ADR 013) never fires and the cursor is
permanently locked at the startup shape.

The same webtty instance launched from VSCode worked correctly because VSCode
sets `TERM=xterm-256color`, whose well-established terminfo entry has `Ss`/`Se`.

### Why inheriting `$TERM` was wrong

The PTY child runs inside webtty's ghostty-web terminal emulator, not inside
Alacritty, VSCode, or whichever terminal the user happened to start webtty
from. `$TERM` is a capability advertisement directed at the child: "here is
the terminal you are attached to." Forwarding the parent's `$TERM` tells the
child it is talking to the parent's terminal, which is false.

The analogy: a terminal application like iTerm2 always sets `TERM=xterm-256color`
for its children regardless of how iTerm2 itself was launched. The child's view
of `$TERM` reflects the terminal it renders in, not the ancestry of processes.

`COLORTERM` was already hardcoded to `truecolor` in the original implementation,
applying exactly this principle. `term` should follow suit.

## Decision

Change the default value of `term` in `DEFAULT_CONFIG` from
`process.env.TERM ?? 'xterm-256color'` to `'xterm-256color'`.

`xterm-256color` is chosen because:

- Its terminfo entry is present on every major platform.
- It includes `Ss`/`Se` cursor-shape capabilities, enabling DECSCUSR from
vim, neovim, fish, and other TUI apps.
- ghostty-web's renderer is xterm-compatible; `xterm-256color` accurately
describes its capabilities from the child's perspective.

The `term` config key remains user-overridable for edge cases (e.g., users who
need `TERM=screen-256color` inside tmux sessions they launch from webtty).

## Considered Options

**Option A: Keep inheriting `process.env.TERM` (previous behavior)**

Broken in Alacritty and any other terminal that sets a non-xterm `$TERM`.
The parent's identity has no bearing on the child's capabilities.

**Option B: Hardcode `xterm-256color` (chosen)**

Correct: reflects webtty's actual terminal capabilities. Consistent with how
`COLORTERM` is already handled. User can override via config file.

**Option C: Use a more specific value (`xterm-kitty`, `ghostty`)**

Rejected — these entries are not universally installed and carry capabilities
webtty may not fully implement. `xterm-256color` is the broadest safe choice.

## Consequences

- vim, neovim, fish, and other DECSCUSR-emitting apps work correctly regardless
of which terminal the user launches webtty from.
- The parent terminal's `$TERM` is no longer visible to PTY children. This is
correct behavior; it was never meaningful there.
- Users who relied on inheriting a non-xterm `$TERM` by default must now set
`term` explicitly in `~/.config/webtty/config.json`. This is an intentional
breaking change: the previous default was wrong.
- `COLORTERM` and `term` now follow the same principle: both are fixed
capability advertisements from webtty to its children, not inherited from
the parent process.

## Related Decisions

- [ADR 008 — Config](008.webtty.config.md): established `term` as a config key
with `process.env.TERM` as the default.
- [ADR 013 — DECSCUSR cursor style via PTY intercept](013.client.cursor-style.md):
the client-side intercept that depends on the PTY child emitting DECSCUSR,
which requires correct `Ss`/`Se` terminfo capabilities.
235 changes: 235 additions & 0 deletions docs/adrs/017.client.mouse-scroll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
# ADR 017: Client — SGR mouse scroll sequences via custom wheel handler

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

---

## Context

When vim (or any TUI app) runs in webtty with `set mouse=a`, scrolling the
mouse wheel moves the cursor up/down instead of scrolling the buffer. The same
vim in a native terminal (VSCode, iTerm2) scrolls correctly.

### How terminal mouse scrolling is supposed to work

When a TUI app enables mouse tracking — typically via `DECSET ?1000h` (VT200
mouse protocol) and `DECSET ?1006h` (SGR mouse encoding) — the terminal
emulator is obligated to report wheel events as mouse escape sequences rather
than handling them itself. In SGR format:

```
\x1b[<64;col;rowM — scroll up
\x1b[<65;col;rowM — scroll down
```

The app then processes K_MOUSEUP / K_MOUSEDOWN and scrolls its own buffer.
Arrow keys (`\x1b[A` / `\x1b[B`) are cursor movement, not scroll events —
if the terminal sends those instead, the cursor moves.

### The ghostty-web bug

ghostty-web's `Terminal.handleWheel` (registered with `capture: true`) fires
before the `InputHandler`'s own wheel handler. It calls `stopPropagation()`
immediately, so `InputHandler.handleWheel` — which correctly checks mouse
tracking state and sends SGR sequences — never runs.

`Terminal.handleWheel` then bifurcates on `isAlternateScreen()`:

```js
if (this.wasmTerm?.isAlternateScreen()) {
// send arrow keys — always, regardless of mouse tracking state
const dir = deltaY > 0 ? 'down' : 'up';
for (let i = 0; i < lines; i++)
dir === 'up' ? dataEmitter.fire('\x1B[A') : dataEmitter.fire('\x1B[B');
} else {
// scroll the viewport (correct for the shell prompt)
}
```

vim always uses the alternate screen. So every scroll tick emits arrow keys
into the PTY — moving the cursor — regardless of whether the app has requested
mouse tracking. The mouse protocol the app negotiated is silently ignored.

### Why VSCode works

VSCode's terminal (xterm.js) checks whether the app has requested mouse
events (`ctx.requestedEvents.wheel`) before falling back to arrow keys. When
vim has enabled mouse tracking, xterm.js sends proper SGR sequences. ghostty-web
skips this check entirely.

## Decision

Use `term.attachCustomWheelEventHandler()` to intercept wheel events before
`Terminal.handleWheel` reaches its arrow-key path.

```ts
let scrollAccum = 0;
let scrollDir = 0;
term.attachCustomWheelEventHandler((e: WheelEvent): boolean => {
if (!term.hasMouseTracking()) return false;
const metrics = term.renderer?.getMetrics();
if (!metrics) return false;
const dir = e.deltaY < 0 ? -1 : 1;
if (dir !== scrollDir) { scrollAccum = 0; scrollDir = dir; }
scrollAccum += config.mouseScrollSpeed;
const ticks = Math.trunc(scrollAccum);
if (ticks === 0) return true;
scrollAccum -= ticks;
const rect = (e.target as HTMLElement).getBoundingClientRect();
const col = Math.max(1, Math.floor((e.clientX - rect.left) / metrics.width) + 1);
const row = Math.max(1, Math.floor((e.clientY - rect.top) / metrics.height) + 1);
const btn = dir < 0 ? 64 : 65;
const seq = `\x1b[<${btn};${col};${row}M`;
if (ws && ws.readyState === WebSocket.OPEN) {
for (let i = 0; i < ticks; i++) ws.send(seq);
}
return true;
});
```

When the custom handler returns `true`, `Terminal.handleWheel` returns early
and skips the arrow-key loop. When it returns `false` (mouse tracking off),
the default behaviour is preserved — arrow keys are still sent for apps that
benefit from that fallback (e.g. `less`, `man` without mouse support).

**Why SGR (button 64/65) and not X10 (button 4/5 + legacy encoding)?**
SGR is always active in webtty because ghostty-web's `hasSgrMouseMode`
defaults to `true`, and `TERM=xterm-256color` causes vim to enable SGR mode
(`DECSET ?1006h`). Button codes 64 (scroll up) and 65 (scroll down) are the
standard SGR wheel codes used by ghostty-web's own `InputHandler.handleWheel`.

## Considered Options

**Option A: Let InputHandler handle it**

`InputHandler.handleWheel` already does the right thing — but it never runs
because `Terminal.handleWheel` calls `stopPropagation()` first. Removing
`stopPropagation()` from `Terminal.handleWheel` would fix it but requires
patching ghostty-web.

**Option B: Patch ghostty-web upstream**

The correct fix is for `Terminal.handleWheel` to check `hasMouseTracking()`
before sending arrow keys — mirroring xterm.js's `ctx.requestedEvents.wheel`
check. Until that patch lands, the custom handler is the correct workaround.

**Option C: `attachCustomWheelEventHandler` (chosen)**

Uses the public API ghostty-web already provides for exactly this purpose.
Zero patches, removed when ghostty-web fixes its `Terminal.handleWheel`.

## Consequences

- vim `set mouse=a` scrolls the buffer as expected in webtty, matching native
terminal behaviour.
- Apps without mouse tracking (shell prompt, `less` without `-R`, etc.)
continue to receive arrow keys from the default path — no regression.
- One SGR sequence is sent per wheel tick by default (`mouseScrollSpeed: 1`).
`mouseScrollSpeed` in config scales this: values `< 1` reduce rate via
accumulation (e.g. `0.5` fires every other tick); values `> 1` send multiple
SGRs per tick. Multi-line scroll per SGR is left
to the app's `scroll` option (vim: `:set scroll=N`), consistent with how
native terminals behave.
- When ghostty-web fixes `Terminal.handleWheel` to respect mouse tracking
state, `attachCustomWheelEventHandler` and this workaround can be removed.

## Fix to ghostty-web

### What the bug is

`Terminal.handleWheel` in `src/Terminal.ts` (registered on the canvas with
`capture: true`) calls `stopPropagation()` unconditionally, which prevents
`InputHandler.handleWheel` — the handler that correctly checks mouse tracking
state — from ever running. It then sends arrow keys whenever
`isAlternateScreen()` is true, without checking whether the application has
requested mouse events:

```ts
// src/Terminal.ts — Terminal.handleWheel (current, broken)
this.handleWheel = (e: WheelEvent) => {
e.preventDefault();
e.stopPropagation(); // ← blocks InputHandler
if (this.customWheelEventHandler?.(e)) return;

if (this.wasmTerm?.isAlternateScreen()) {
const dir = e.deltaY > 0 ? 'down' : 'up';
const lines = Math.min(Math.abs(Math.round(e.deltaY / 33)), 5);
for (let i = 0; i < lines; i++)
this.dataEmitter.fire(dir === 'up' ? '\x1B[A' : '\x1B[B'); // ← always
} else {
// scroll viewport ...
}
};
```

### What to change

In the `isAlternateScreen()` branch, check `this.wasmTerm.hasMouseTracking()`
before emitting arrow keys. When mouse tracking is active, emit the SGR mouse
scroll sequence instead. The `canvas` element and `renderer` are already
available on `this`:

```ts
// src/Terminal.ts — Terminal.handleWheel (fixed)
this.handleWheel = (e: WheelEvent) => {
e.preventDefault();
e.stopPropagation();
if (this.customWheelEventHandler?.(e)) return;

if (this.wasmTerm?.isAlternateScreen()) {
if (this.wasmTerm.hasMouseTracking()) {
// App negotiated mouse tracking — send SGR scroll sequence, not arrow keys.
const metrics = this.renderer?.getMetrics();
if (metrics && this.canvas) {
const rect = this.canvas.getBoundingClientRect();
const col = Math.max(1, Math.floor((e.clientX - rect.left) / metrics.width) + 1);
const row = Math.max(1, Math.floor((e.clientY - rect.top) / metrics.height) + 1);
const btn = e.deltaY < 0 ? 64 : 65;
this.dataEmitter.fire(`\x1b[<${btn};${col};${row}M`);
}
return;
}
// No mouse tracking: arrow-key fallback for apps like `less`.
const dir = e.deltaY > 0 ? 'down' : 'up';
const lines = Math.min(Math.abs(Math.round(e.deltaY / 33)), 5);
for (let i = 0; i < lines; i++)
this.dataEmitter.fire(dir === 'up' ? '\x1B[A' : '\x1B[B');
} else {
// scroll viewport (unchanged) ...
}
};
```

### Why this is the right fix (not the webtty workaround)

The webtty workaround intercepts the event from outside via
`attachCustomWheelEventHandler`, computes cell coordinates from the public
`renderer.getMetrics()` and `e.target.getBoundingClientRect()`, then sends
the sequence directly over the WebSocket. The upstream fix is structurally
identical but happens inside `Terminal.handleWheel`, where `this.canvas` and
`this.renderer` are already in scope — no BoundingClientRect detour needed,
and the data flows through `dataEmitter` (the canonical internal channel) rather
than bypassing it via WebSocket. With this upstream fix, the
`attachCustomWheelEventHandler` call in webtty's `index.ts` can be deleted.

### Contribution checklist

- [ ] Open issue: `Terminal.handleWheel` sends arrow keys on alt screen even
when mouse tracking is active (`hasMouseTracking() === true`)
- [ ] PR: `src/Terminal.ts` — add `hasMouseTracking()` guard in the
`isAlternateScreen()` branch; emit SGR scroll sequence when true, fall back
to arrow keys when false
- [ ] Test: `write('\x1b[?1000h')` (enable mouse tracking) → simulate wheel
event → assert `onData` receives `\x1b[<64;…M` or `\x1b[<65;…M`, NOT
`\x1b[A` / `\x1b[B`

## Related Decisions

- [ADR 013 — DECSCUSR cursor style via PTY intercept](013.client.cursor-style.md):
same pattern — a ghostty-web rendering gap worked around at the webtty client
layer until upstream fixes it.
- [ADR 016 — `term` default is `xterm-256color`](016.config.term-default.md):
ensures vim gets a TERM value whose terminfo includes mouse tracking
capabilities, so vim actually sends the DECSET sequences that activate this path.
6 changes: 4 additions & 2 deletions docs/specs/client.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# SPEC: Client

**Author:** jesse23
**Last Updated:** 2026-03-26
**Last Updated:** 2026-03-27

---

Expand Down Expand Up @@ -55,7 +55,8 @@ src/client/
```ts
{
cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollback,
theme, copyOnSelect, rightClickBehavior
theme, copyOnSelect, rightClickBehavior,
mouseScrollSpeed // used by the custom wheel handler, not passed to Terminal constructor
}
Comment thread
jesse23 marked this conversation as resolved.
```

Expand Down Expand Up @@ -130,3 +131,4 @@ When a session ends (shell exits → WS close code `4001`) or the server stops (
| Copy behavior | `copyOnSelect` + `rightClickBehavior` — two independent configurable copy modes | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ |
| 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) | ✅ |
Loading
Loading