-
Notifications
You must be signed in to change notification settings - Fork 0
fix: vim cursor shape and mouse scroll in web terminal #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
6b9d825
fix: update default TERM to xterm-256color and implement custom mouse…
a8eb56c
fix: add mouseScrollSpeed configuration and implement custom scroll h…
c9728a5
update
1d0743f
fix: enhance Theme interface with detailed property descriptions and …
6a0e90f
fix: replace saveConfig with initConfig in tests and update documenta…
c3c0e6b
fix: add mouseScrollSpeed configuration to terminal and update relate…
7a24061
fix: sort initConfig import alphabetically to pass biome organizeImports
7d12dec
fix: address copilot review comments
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.