Skip to content

Commit 208094f

Browse files
jesse23jesse23sisyphus-dev-ai
authored
fix: vim cursor shape and mouse scroll in web terminal (#20)
## Summary - **Cursor shape in Alacritty** — `$TERM` default changed from `process.env.TERM` (inherited from the parent process) to a hardcoded `xterm-256color`. The parent terminal's identity is irrelevant to webtty's PTY children; inheriting `TERM=alacritty` caused vim to not emit DECSCUSR sequences, leaving the cursor permanently stuck at the startup shape (ADR 016). - **Mouse scroll moves cursor instead of scrolling** — ghostty-web's `Terminal.handleWheel` unconditionally sends arrow keys (`\x1b[A`/`\x1b[B`) on the alternate screen regardless of mouse tracking state. Added a custom wheel handler via `attachCustomWheelEventHandler` that sends proper SGR scroll sequences (`\x1b[<64/65;col;rowM`) when the PTY app has enabled mouse tracking (e.g. vim `set mouse=a`), matching native terminal behaviour (ADR 017). - **`mouseScrollSpeed` config** — new config key (default `1`) to scale SGR events per wheel tick. Values `< 1` reduce rate via accumulation (useful when scroll feels too fast); values `> 1` send multiple SGRs per tick. Takes effect on tab reload with no server restart required. --------- Co-authored-by: jesse23 <wenjia.peng@siemens.com> Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 02513e4 commit 208094f

9 files changed

Lines changed: 462 additions & 14 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# ADR 016: Config — `term` default is `xterm-256color`, not inherited from parent process
2+
3+
**SPEC:** [config](../specs/config.md)
4+
**Status:** Accepted
5+
**Date:** 2026-03-27
6+
7+
---
8+
9+
## Context
10+
11+
webtty spawns a PTY child process and sets `$TERM` in its environment via the
12+
`term` config key. The previous default was `process.env.TERM ?? 'xterm-256color'`,
13+
which read `$TERM` from the process that launched webtty and forwarded it to
14+
every PTY child.
15+
16+
The intent was reasonable — preserve the user's terminal preference — but it
17+
conflates two unrelated terminals: the **parent terminal** (the one the user
18+
ran `bunx webtty` from) and the **PTY terminal** (the ghostty-web instance
19+
rendered in the browser). These are independent; the parent's `$TERM` is
20+
irrelevant to what the PTY child is actually talking to.
21+
22+
### The Alacritty breakage
23+
24+
Alacritty sets `TERM=alacritty` in its environment. When webtty was launched
25+
from Alacritty, the PTY child inherited `TERM=alacritty`. Vim (and other
26+
TUI apps) consult the `$TERM` terminfo entry to decide which escape sequences
27+
to emit. Specifically, the `Ss`/`Se` capabilities control whether the app
28+
emits DECSCUSR cursor-shape sequences.
29+
30+
The `alacritty` terminfo entry's `Ss`/`Se` support depends on the user's
31+
installed terminfo version. On systems with an outdated or missing entry,
32+
`Ss`/`Se` are absent, and vim never emits DECSCUSR at all — so webtty's
33+
client-side DECSCUSR intercept (ADR 013) never fires and the cursor is
34+
permanently locked at the startup shape.
35+
36+
The same webtty instance launched from VSCode worked correctly because VSCode
37+
sets `TERM=xterm-256color`, whose well-established terminfo entry has `Ss`/`Se`.
38+
39+
### Why inheriting `$TERM` was wrong
40+
41+
The PTY child runs inside webtty's ghostty-web terminal emulator, not inside
42+
Alacritty, VSCode, or whichever terminal the user happened to start webtty
43+
from. `$TERM` is a capability advertisement directed at the child: "here is
44+
the terminal you are attached to." Forwarding the parent's `$TERM` tells the
45+
child it is talking to the parent's terminal, which is false.
46+
47+
The analogy: a terminal application like iTerm2 always sets `TERM=xterm-256color`
48+
for its children regardless of how iTerm2 itself was launched. The child's view
49+
of `$TERM` reflects the terminal it renders in, not the ancestry of processes.
50+
51+
`COLORTERM` was already hardcoded to `truecolor` in the original implementation,
52+
applying exactly this principle. `term` should follow suit.
53+
54+
## Decision
55+
56+
Change the default value of `term` in `DEFAULT_CONFIG` from
57+
`process.env.TERM ?? 'xterm-256color'` to `'xterm-256color'`.
58+
59+
`xterm-256color` is chosen because:
60+
61+
- Its terminfo entry is present on every major platform.
62+
- It includes `Ss`/`Se` cursor-shape capabilities, enabling DECSCUSR from
63+
vim, neovim, fish, and other TUI apps.
64+
- ghostty-web's renderer is xterm-compatible; `xterm-256color` accurately
65+
describes its capabilities from the child's perspective.
66+
67+
The `term` config key remains user-overridable for edge cases (e.g., users who
68+
need `TERM=screen-256color` inside tmux sessions they launch from webtty).
69+
70+
## Considered Options
71+
72+
**Option A: Keep inheriting `process.env.TERM` (previous behavior)**
73+
74+
Broken in Alacritty and any other terminal that sets a non-xterm `$TERM`.
75+
The parent's identity has no bearing on the child's capabilities.
76+
77+
**Option B: Hardcode `xterm-256color` (chosen)**
78+
79+
Correct: reflects webtty's actual terminal capabilities. Consistent with how
80+
`COLORTERM` is already handled. User can override via config file.
81+
82+
**Option C: Use a more specific value (`xterm-kitty`, `ghostty`)**
83+
84+
Rejected — these entries are not universally installed and carry capabilities
85+
webtty may not fully implement. `xterm-256color` is the broadest safe choice.
86+
87+
## Consequences
88+
89+
- vim, neovim, fish, and other DECSCUSR-emitting apps work correctly regardless
90+
of which terminal the user launches webtty from.
91+
- The parent terminal's `$TERM` is no longer visible to PTY children. This is
92+
correct behavior; it was never meaningful there.
93+
- Users who relied on inheriting a non-xterm `$TERM` by default must now set
94+
`term` explicitly in `~/.config/webtty/config.json`. This is an intentional
95+
breaking change: the previous default was wrong.
96+
- `COLORTERM` and `term` now follow the same principle: both are fixed
97+
capability advertisements from webtty to its children, not inherited from
98+
the parent process.
99+
100+
## Related Decisions
101+
102+
- [ADR 008 — Config](008.webtty.config.md): established `term` as a config key
103+
with `process.env.TERM` as the default.
104+
- [ADR 013 — DECSCUSR cursor style via PTY intercept](013.client.cursor-style.md):
105+
the client-side intercept that depends on the PTY child emitting DECSCUSR,
106+
which requires correct `Ss`/`Se` terminfo capabilities.
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
# ADR 017: Client — SGR mouse scroll sequences via custom wheel handler
2+
3+
**SPEC:** [client](../specs/client.md)
4+
**Status:** Accepted
5+
**Date:** 2026-03-27
6+
7+
---
8+
9+
## Context
10+
11+
When vim (or any TUI app) runs in webtty with `set mouse=a`, scrolling the
12+
mouse wheel moves the cursor up/down instead of scrolling the buffer. The same
13+
vim in a native terminal (VSCode, iTerm2) scrolls correctly.
14+
15+
### How terminal mouse scrolling is supposed to work
16+
17+
When a TUI app enables mouse tracking — typically via `DECSET ?1000h` (VT200
18+
mouse protocol) and `DECSET ?1006h` (SGR mouse encoding) — the terminal
19+
emulator is obligated to report wheel events as mouse escape sequences rather
20+
than handling them itself. In SGR format:
21+
22+
```
23+
\x1b[<64;col;rowM — scroll up
24+
\x1b[<65;col;rowM — scroll down
25+
```
26+
27+
The app then processes K_MOUSEUP / K_MOUSEDOWN and scrolls its own buffer.
28+
Arrow keys (`\x1b[A` / `\x1b[B`) are cursor movement, not scroll events —
29+
if the terminal sends those instead, the cursor moves.
30+
31+
### The ghostty-web bug
32+
33+
ghostty-web's `Terminal.handleWheel` (registered with `capture: true`) fires
34+
before the `InputHandler`'s own wheel handler. It calls `stopPropagation()`
35+
immediately, so `InputHandler.handleWheel` — which correctly checks mouse
36+
tracking state and sends SGR sequences — never runs.
37+
38+
`Terminal.handleWheel` then bifurcates on `isAlternateScreen()`:
39+
40+
```js
41+
if (this.wasmTerm?.isAlternateScreen()) {
42+
// send arrow keys — always, regardless of mouse tracking state
43+
const dir = deltaY > 0 ? 'down' : 'up';
44+
for (let i = 0; i < lines; i++)
45+
dir === 'up' ? dataEmitter.fire('\x1B[A') : dataEmitter.fire('\x1B[B');
46+
} else {
47+
// scroll the viewport (correct for the shell prompt)
48+
}
49+
```
50+
51+
vim always uses the alternate screen. So every scroll tick emits arrow keys
52+
into the PTY — moving the cursor — regardless of whether the app has requested
53+
mouse tracking. The mouse protocol the app negotiated is silently ignored.
54+
55+
### Why VSCode works
56+
57+
VSCode's terminal (xterm.js) checks whether the app has requested mouse
58+
events (`ctx.requestedEvents.wheel`) before falling back to arrow keys. When
59+
vim has enabled mouse tracking, xterm.js sends proper SGR sequences. ghostty-web
60+
skips this check entirely.
61+
62+
## Decision
63+
64+
Use `term.attachCustomWheelEventHandler()` to intercept wheel events before
65+
`Terminal.handleWheel` reaches its arrow-key path.
66+
67+
```ts
68+
let scrollAccum = 0;
69+
let scrollDir = 0;
70+
term.attachCustomWheelEventHandler((e: WheelEvent): boolean => {
71+
if (!term.hasMouseTracking()) return false;
72+
const metrics = term.renderer?.getMetrics();
73+
if (!metrics) return false;
74+
const dir = e.deltaY < 0 ? -1 : 1;
75+
if (dir !== scrollDir) { scrollAccum = 0; scrollDir = dir; }
76+
scrollAccum += config.mouseScrollSpeed;
77+
const ticks = Math.trunc(scrollAccum);
78+
if (ticks === 0) return true;
79+
scrollAccum -= ticks;
80+
const rect = (e.target as HTMLElement).getBoundingClientRect();
81+
const col = Math.max(1, Math.floor((e.clientX - rect.left) / metrics.width) + 1);
82+
const row = Math.max(1, Math.floor((e.clientY - rect.top) / metrics.height) + 1);
83+
const btn = dir < 0 ? 64 : 65;
84+
const seq = `\x1b[<${btn};${col};${row}M`;
85+
if (ws && ws.readyState === WebSocket.OPEN) {
86+
for (let i = 0; i < ticks; i++) ws.send(seq);
87+
}
88+
return true;
89+
});
90+
```
91+
92+
When the custom handler returns `true`, `Terminal.handleWheel` returns early
93+
and skips the arrow-key loop. When it returns `false` (mouse tracking off),
94+
the default behaviour is preserved — arrow keys are still sent for apps that
95+
benefit from that fallback (e.g. `less`, `man` without mouse support).
96+
97+
**Why SGR (button 64/65) and not X10 (button 4/5 + legacy encoding)?**
98+
SGR is always active in webtty because ghostty-web's `hasSgrMouseMode`
99+
defaults to `true`, and `TERM=xterm-256color` causes vim to enable SGR mode
100+
(`DECSET ?1006h`). Button codes 64 (scroll up) and 65 (scroll down) are the
101+
standard SGR wheel codes used by ghostty-web's own `InputHandler.handleWheel`.
102+
103+
## Considered Options
104+
105+
**Option A: Let InputHandler handle it**
106+
107+
`InputHandler.handleWheel` already does the right thing — but it never runs
108+
because `Terminal.handleWheel` calls `stopPropagation()` first. Removing
109+
`stopPropagation()` from `Terminal.handleWheel` would fix it but requires
110+
patching ghostty-web.
111+
112+
**Option B: Patch ghostty-web upstream**
113+
114+
The correct fix is for `Terminal.handleWheel` to check `hasMouseTracking()`
115+
before sending arrow keys — mirroring xterm.js's `ctx.requestedEvents.wheel`
116+
check. Until that patch lands, the custom handler is the correct workaround.
117+
118+
**Option C: `attachCustomWheelEventHandler` (chosen)**
119+
120+
Uses the public API ghostty-web already provides for exactly this purpose.
121+
Zero patches, removed when ghostty-web fixes its `Terminal.handleWheel`.
122+
123+
## Consequences
124+
125+
- vim `set mouse=a` scrolls the buffer as expected in webtty, matching native
126+
terminal behaviour.
127+
- Apps without mouse tracking (shell prompt, `less` without `-R`, etc.)
128+
continue to receive arrow keys from the default path — no regression.
129+
- One SGR sequence is sent per wheel tick by default (`mouseScrollSpeed: 1`).
130+
`mouseScrollSpeed` in config scales this: values `< 1` reduce rate via
131+
accumulation (e.g. `0.5` fires every other tick); values `> 1` send multiple
132+
SGRs per tick. Multi-line scroll per SGR is left
133+
to the app's `scroll` option (vim: `:set scroll=N`), consistent with how
134+
native terminals behave.
135+
- When ghostty-web fixes `Terminal.handleWheel` to respect mouse tracking
136+
state, `attachCustomWheelEventHandler` and this workaround can be removed.
137+
138+
## Fix to ghostty-web
139+
140+
### What the bug is
141+
142+
`Terminal.handleWheel` in `src/Terminal.ts` (registered on the canvas with
143+
`capture: true`) calls `stopPropagation()` unconditionally, which prevents
144+
`InputHandler.handleWheel` — the handler that correctly checks mouse tracking
145+
state — from ever running. It then sends arrow keys whenever
146+
`isAlternateScreen()` is true, without checking whether the application has
147+
requested mouse events:
148+
149+
```ts
150+
// src/Terminal.ts — Terminal.handleWheel (current, broken)
151+
this.handleWheel = (e: WheelEvent) => {
152+
e.preventDefault();
153+
e.stopPropagation(); // ← blocks InputHandler
154+
if (this.customWheelEventHandler?.(e)) return;
155+
156+
if (this.wasmTerm?.isAlternateScreen()) {
157+
const dir = e.deltaY > 0 ? 'down' : 'up';
158+
const lines = Math.min(Math.abs(Math.round(e.deltaY / 33)), 5);
159+
for (let i = 0; i < lines; i++)
160+
this.dataEmitter.fire(dir === 'up' ? '\x1B[A' : '\x1B[B'); // ← always
161+
} else {
162+
// scroll viewport ...
163+
}
164+
};
165+
```
166+
167+
### What to change
168+
169+
In the `isAlternateScreen()` branch, check `this.wasmTerm.hasMouseTracking()`
170+
before emitting arrow keys. When mouse tracking is active, emit the SGR mouse
171+
scroll sequence instead. The `canvas` element and `renderer` are already
172+
available on `this`:
173+
174+
```ts
175+
// src/Terminal.ts — Terminal.handleWheel (fixed)
176+
this.handleWheel = (e: WheelEvent) => {
177+
e.preventDefault();
178+
e.stopPropagation();
179+
if (this.customWheelEventHandler?.(e)) return;
180+
181+
if (this.wasmTerm?.isAlternateScreen()) {
182+
if (this.wasmTerm.hasMouseTracking()) {
183+
// App negotiated mouse tracking — send SGR scroll sequence, not arrow keys.
184+
const metrics = this.renderer?.getMetrics();
185+
if (metrics && this.canvas) {
186+
const rect = this.canvas.getBoundingClientRect();
187+
const col = Math.max(1, Math.floor((e.clientX - rect.left) / metrics.width) + 1);
188+
const row = Math.max(1, Math.floor((e.clientY - rect.top) / metrics.height) + 1);
189+
const btn = e.deltaY < 0 ? 64 : 65;
190+
this.dataEmitter.fire(`\x1b[<${btn};${col};${row}M`);
191+
}
192+
return;
193+
}
194+
// No mouse tracking: arrow-key fallback for apps like `less`.
195+
const dir = e.deltaY > 0 ? 'down' : 'up';
196+
const lines = Math.min(Math.abs(Math.round(e.deltaY / 33)), 5);
197+
for (let i = 0; i < lines; i++)
198+
this.dataEmitter.fire(dir === 'up' ? '\x1B[A' : '\x1B[B');
199+
} else {
200+
// scroll viewport (unchanged) ...
201+
}
202+
};
203+
```
204+
205+
### Why this is the right fix (not the webtty workaround)
206+
207+
The webtty workaround intercepts the event from outside via
208+
`attachCustomWheelEventHandler`, computes cell coordinates from the public
209+
`renderer.getMetrics()` and `e.target.getBoundingClientRect()`, then sends
210+
the sequence directly over the WebSocket. The upstream fix is structurally
211+
identical but happens inside `Terminal.handleWheel`, where `this.canvas` and
212+
`this.renderer` are already in scope — no BoundingClientRect detour needed,
213+
and the data flows through `dataEmitter` (the canonical internal channel) rather
214+
than bypassing it via WebSocket. With this upstream fix, the
215+
`attachCustomWheelEventHandler` call in webtty's `index.ts` can be deleted.
216+
217+
### Contribution checklist
218+
219+
- [ ] Open issue: `Terminal.handleWheel` sends arrow keys on alt screen even
220+
when mouse tracking is active (`hasMouseTracking() === true`)
221+
- [ ] PR: `src/Terminal.ts` — add `hasMouseTracking()` guard in the
222+
`isAlternateScreen()` branch; emit SGR scroll sequence when true, fall back
223+
to arrow keys when false
224+
- [ ] Test: `write('\x1b[?1000h')` (enable mouse tracking) → simulate wheel
225+
event → assert `onData` receives `\x1b[<64;…M` or `\x1b[<65;…M`, NOT
226+
`\x1b[A` / `\x1b[B`
227+
228+
## Related Decisions
229+
230+
- [ADR 013 — DECSCUSR cursor style via PTY intercept](013.client.cursor-style.md):
231+
same pattern — a ghostty-web rendering gap worked around at the webtty client
232+
layer until upstream fixes it.
233+
- [ADR 016 — `term` default is `xterm-256color`](016.config.term-default.md):
234+
ensures vim gets a TERM value whose terminfo includes mouse tracking
235+
capabilities, so vim actually sends the DECSET sequences that activate this path.

‎docs/specs/client.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# SPEC: Client
22

33
**Author:** jesse23
4-
**Last Updated:** 2026-03-26
4+
**Last Updated:** 2026-03-27
55

66
---
77

@@ -55,7 +55,8 @@ src/client/
5555
```ts
5656
{
5757
cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollback,
58-
theme, copyOnSelect, rightClickBehavior
58+
theme, copyOnSelect, rightClickBehavior,
59+
mouseScrollSpeed // used by the custom wheel handler, not passed to Terminal constructor
5960
}
6061
```
6162

@@ -130,3 +131,4 @@ When a session ends (shell exits → WS close code `4001`) or the server stops (
130131
| Copy behavior | `copyOnSelect` + `rightClickBehavior` — two independent configurable copy modes | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ |
131132
| Cursor style | `cursorStyle` / `cursorStyleBlink` defaults; DECSCUSR from PTY overrides at runtime via client-side intercept | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ |
132133
| 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) | ✅ |
134+
| 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) | ✅ |

0 commit comments

Comments
 (0)