diff --git a/docs/adrs/016.config.term-default.md b/docs/adrs/016.config.term-default.md new file mode 100644 index 0000000..40dede0 --- /dev/null +++ b/docs/adrs/016.config.term-default.md @@ -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. diff --git a/docs/adrs/017.client.mouse-scroll.md b/docs/adrs/017.client.mouse-scroll.md new file mode 100644 index 0000000..3dd0938 --- /dev/null +++ b/docs/adrs/017.client.mouse-scroll.md @@ -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. diff --git a/docs/specs/client.md b/docs/specs/client.md index c7ac1ab..0039120 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -1,7 +1,7 @@ # SPEC: Client **Author:** jesse23 -**Last Updated:** 2026-03-26 +**Last Updated:** 2026-03-27 --- @@ -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 } ``` @@ -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) | ✅ | diff --git a/docs/specs/config.md b/docs/specs/config.md index 11c49fa..e85fe73 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -1,7 +1,7 @@ # SPEC: Config **Author:** jesse23 -**Last Updated:** 2026-03-24 +**Last Updated:** 2026-03-27 --- @@ -92,7 +92,7 @@ spawn PTY with fresh: shell, term, colorTerm, scrollback - **Env overrides**: `PORT` overrides `config.port` at runtime. Applied after file load, never written back. - **Hot config reload**: - `port` / `host` — locked at startup (server socket already bound; restart required). - - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `theme`, `copyOnSelect`, `rightClickBehavior` — re-read on every tab reload. `cursorStyle` and `cursorStyleBlink` set the startup defaults; apps override them at runtime via DECSCUSR. + - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `theme`, `copyOnSelect`, `rightClickBehavior`, `mouseScrollSpeed` — re-read on every tab reload. `cursorStyle` and `cursorStyleBlink` set the startup defaults; apps override them at runtime via DECSCUSR. - `shell`, `term`, `colorTerm`, `scrollback` — re-read when a new PTY is spawned (i.e. first connection to a session that has no running shell). - An already-running session is never affected mid-flight. - Historical note: ADR 008/009/012 describe an earlier config flow that used a `cursorBlink` key and different HTML injection mechanics. Those ADRs are considered historical; this spec's `cursorStyle` / `cursorStyleBlink` behavior is authoritative. @@ -106,7 +106,7 @@ All keys are optional — omit any key to use the default value. | `port` | number | `2346` | HTTP listen port; env `PORT` takes precedence | | `host` | string | `"127.0.0.1"` | Bind address; use `"0.0.0.0"` for remote access | | `shell` | string | `$SHELL` / `%COMSPEC%` | Shell for new sessions | -| `term` | string | `$TERM` | `$TERM` env var passed to the shell | +| `term` | string | `"xterm-256color"` | `$TERM` env var passed to the shell. Fixed to `xterm-256color` — the PTY child talks to webtty, not to the parent terminal. See [ADR 016](../adrs/016.config.term-default.md). | | `colorTerm` | string | `"truecolor"` | `$COLORTERM` env var passed to the shell | | `scrollback` | number | `262144` | PTY history buffer in bytes; used for server-side replay on reload/reconnect | | `cols` | number | `80` | Initial terminal width in columns | @@ -115,6 +115,7 @@ All keys are optional — omit any key to use the default value. | `cursorStyleBlink` | boolean | `true` | Default blink state. Apps override at runtime via DECSCUSR — this is the startup default only. | | `copyOnSelect` | boolean | `true` | Auto-copy selection to clipboard on mouseup (kitty / Windows Terminal style) | | `rightClickBehavior` | string | `"default"` | Right-click behavior: `"copyPaste"` copies selection + clears it if selection exists, otherwise native menu; `"default"` always shows native context menu. Invalid values fall back to `"default"` | +| `mouseScrollSpeed` | number | `1` | Mouse wheel scroll speed multiplier for apps with mouse tracking (e.g. vim `set mouse=a`). `1` = one SGR event per wheel tick (default). Values `< 1` reduce rate (e.g. `0.5` fires every other tick); values `> 1` send multiple SGRs per tick. Must be `> 0`. | | `logs` | boolean | `false` | Write server stdout/stderr to `~/.config/webtty/server.log`. Appends on each start. Default `false` — server runs silently. | | `fontSize` | number | `13` | Font size in px | | `fontFamily` | string | `"Menlo, Consolas, 'DejaVu Sans Mono', monospace"` | CSS font-family stack | @@ -197,9 +198,10 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter | Feature | Description | ADR | Done? | |---------|-------------|-----|-------| | Config lifecycle | First-run write, merge with defaults, env overrides, hot-reload on tab reload | [ADR 008](../adrs/008.webtty.config.md) | ✅ | -| Server settings | `port`, `host` — locked at startup; `shell`, `term`, `colorTerm` — applied per new PTY | [ADR 008](../adrs/008.webtty.config.md) | ✅ | +| Server settings | `port`, `host` — locked at startup; `shell`, `term`, `colorTerm` — applied per new PTY. `term` defaults to `xterm-256color` (not inherited from parent process) | [ADR 008](../adrs/008.webtty.config.md), [ADR 016](../adrs/016.config.term-default.md) | ✅ | | Terminal appearance | `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `theme` — re-read on tab reload | [ADR 008](../adrs/008.webtty.config.md) | ✅ | | Hot config reload | Appearance re-read on tab reload; shell/PTY settings re-read on new PTY spawn; `port`/`host` locked for server lifetime | [ADR 009](../adrs/009.webtty.config-hot-reload.md) | ✅ | | Copy behavior | `copyOnSelect` + `rightClickBehavior` — configurable clipboard copy matching VS Code / kitty conventions | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Server logs | `logs: true` appends server stdout/stderr to `~/.config/webtty/server.log` | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Cursor style | `cursorStyle` sets the default cursor shape; DECSCUSR sequences from apps override at runtime | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | +| Mouse scroll speed | `mouseScrollSpeed` scales SGR events per wheel tick for apps with mouse tracking; default `1` | [ADR 017](../adrs/017.client.mouse-scroll.md) | ✅ | diff --git a/src/client/index.ts b/src/client/index.ts index 53d61b8..6070618 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -35,6 +35,7 @@ interface ClientConfig { theme: Theme; copyOnSelect: boolean; rightClickBehavior: 'default' | 'copyPaste'; + mouseScrollSpeed: number; } const sessionId = window.location.pathname.split('/s/')[1] ?? 'main'; @@ -108,6 +109,42 @@ function connect(): void { connect(); +// ghostty-web's Terminal.handleWheel sends \x1b[A/\x1b[B (arrow keys) on the +// alternate screen regardless of mouse tracking state, moving the cursor instead +// of scrolling. When the PTY application has enabled mouse tracking (e.g. vim +// with `set mouse=a`), intercept wheel events and send the correct SGR mouse +// scroll sequence so the app receives a scroll event, not a cursor move. +// SGR button 64 = scroll up, 65 = scroll down. See ADR 017. +// +// config.mouseScrollSpeed scales SGR events per wheel tick (default 1). +// Values < 1 reduce rate via accumulation; values > 1 send multiple SGRs. +// The accumulator resets on direction change to prevent cross-direction bleed. +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; +}); + // Forward terminal keystrokes and input to the PTY over WebSocket. term.onData((data: string) => { if (ws && ws.readyState === WebSocket.OPEN) { diff --git a/src/config.test.ts b/src/config.test.ts index cbe0731..c4ae9f8 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { DEFAULT_CONFIG, DEFAULT_THEME, loadConfig, saveConfig } from './config'; +import { DEFAULT_CONFIG, DEFAULT_THEME, initConfig, loadConfig } from './config'; let tmpDir: string; let configPath: string; @@ -24,20 +24,20 @@ afterEach(() => { } }); -describe('saveConfig', () => { +describe('initConfig', () => { test('creates the config directory if it does not exist', () => { - saveConfig(DEFAULT_CONFIG); + initConfig(); expect(fs.existsSync(path.dirname(configPath))).toBe(true); }); test('writes valid JSON', () => { - saveConfig(DEFAULT_CONFIG); + initConfig(); const raw = fs.readFileSync(configPath, 'utf8'); expect(() => JSON.parse(raw)).not.toThrow(); }); test('written file contains port and host', () => { - saveConfig(DEFAULT_CONFIG); + initConfig(); const raw = fs.readFileSync(configPath, 'utf8'); expect(raw).toContain('"port"'); expect(raw).toContain('"host"'); @@ -51,6 +51,10 @@ describe('loadConfig — first run', () => { expect(fs.existsSync(configPath)).toBe(true); }); + test('DEFAULT_CONFIG.term is xterm-256color', () => { + expect(DEFAULT_CONFIG.term).toBe('xterm-256color'); + }); + test('returns config that equals DEFAULT_CONFIG after first run', () => { const config = loadConfig(); expect(config.port).toBe(DEFAULT_CONFIG.port); diff --git a/src/config.ts b/src/config.ts index 6df4a9d..4529fee 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,46 +3,85 @@ import os from 'node:os'; import path from 'node:path'; export interface Theme { + /** Terminal background. */ background?: string; + /** Default text color. */ foreground?: string; + /** Cursor color. */ cursor?: string; + /** Selection highlight. */ selection?: string; + /** ANSI 0. */ black?: string; + /** ANSI 1. */ red?: string; + /** ANSI 2. */ green?: string; + /** ANSI 3. */ yellow?: string; + /** ANSI 4. */ blue?: string; + /** ANSI 5. */ purple?: string; + /** ANSI 6. */ cyan?: string; + /** ANSI 7. */ white?: string; + /** ANSI 8. */ brightBlack?: string; + /** ANSI 9. */ brightRed?: string; + /** ANSI 10. */ brightGreen?: string; + /** ANSI 11. */ brightYellow?: string; + /** ANSI 12. */ brightBlue?: string; + /** ANSI 13. */ brightPurple?: string; + /** ANSI 14. */ brightCyan?: string; + /** ANSI 15. */ brightWhite?: string; } +/** Right-click behavior: `"copyPaste"` copies selection + clears it if selection exists, otherwise native menu; `"default"` always shows native context menu. */ export type RightClickBehavior = 'default' | 'copyPaste'; export interface Config { + /** HTTP listen port; env `PORT` takes precedence. */ port: number; + /** Bind address; use `"0.0.0.0"` for remote access. */ host: string; + /** Shell for new sessions. */ shell: string; + /** `$TERM` env var passed to the shell. Fixed to `xterm-256color` — the PTY child talks to webtty, not to the parent terminal. */ term: string; + /** `$COLORTERM` env var passed to the shell. */ colorTerm: string; + /** PTY history buffer in bytes; used for server-side replay on reload/reconnect. */ scrollback: number; + /** Initial terminal width in columns. */ cols: number; + /** Initial terminal height in rows. */ rows: number; + /** Font size in px. */ fontSize: number; + /** CSS font-family stack. */ fontFamily: string; + /** Default cursor shape. Apps override at runtime via DECSCUSR — this is the startup default only. */ cursorStyle: 'block' | 'bar' | 'underline'; + /** Default blink state. Apps override at runtime via DECSCUSR — this is the startup default only. */ cursorStyleBlink: boolean; + /** Auto-copy selection to clipboard on mouseup (kitty / Windows Terminal style). */ copyOnSelect: boolean; + /** `"copyPaste"` copies selection + clears it on right-click if selection exists; `"default"` always shows native context menu. */ rightClickBehavior: RightClickBehavior; + /** Mouse wheel scroll speed multiplier for apps with mouse tracking. `1` = one SGR per tick. Values `< 1` reduce rate; values `> 1` send multiple SGRs per tick. Must be `> 0`. */ + mouseScrollSpeed: number; + /** Write server stdout/stderr to `~/.config/webtty/server.log`. Appends on each start. */ logs: boolean; + /** Terminal color palette. */ theme: Theme; } @@ -54,6 +93,7 @@ function getConfigPath(): string { return path.join(configDir(), 'config.json'); } +// NOTE: export for testing only; users should use loadConfig() and initConfig() instead export const DEFAULT_THEME: Theme = { background: '#000000', foreground: '#CCCCCC', @@ -77,6 +117,7 @@ export const DEFAULT_THEME: Theme = { brightWhite: '#F2F2F2', }; +// NOTE: export for testing only; users should use loadConfig() and initConfig() instead export const DEFAULT_CONFIG: Config = { port: 2346, host: '127.0.0.1', @@ -84,7 +125,7 @@ export const DEFAULT_CONFIG: Config = { process.platform === 'win32' ? (process.env.COMSPEC ?? 'cmd.exe') : (process.env.SHELL ?? '/bin/bash'), - term: process.env.TERM ?? 'xterm-256color', + term: 'xterm-256color', colorTerm: 'truecolor', scrollback: 256 * 1024, cols: 80, @@ -95,14 +136,24 @@ export const DEFAULT_CONFIG: Config = { cursorStyleBlink: true, copyOnSelect: true, rightClickBehavior: 'default' as RightClickBehavior, + mouseScrollSpeed: 1, logs: false, theme: DEFAULT_THEME, }; +/** + * Load config from `~/.config/webtty/config.json`, merged over `DEFAULT_CONFIG`. + * + * On first run (file absent), writes the default port/host stub and returns defaults. + * Unknown keys and keys with wrong types are silently ignored. + * Throws if the file exists but cannot be read or contains invalid JSON. + * + * @returns The resolved {@link Config}, with all missing keys filled from {@link DEFAULT_CONFIG}. + */ export function loadConfig(): Config { if (!fs.existsSync(getConfigPath())) { try { - saveConfig(DEFAULT_CONFIG); + initConfig(); } catch (err) { console.warn( `webtty: failed to write default config to ${getConfigPath()}: ${(err as Error).message}`, @@ -151,12 +202,21 @@ export function loadConfig(): Config { ? 'copyPaste' : 'default') as RightClickBehavior, }), + ...(typeof p.mouseScrollSpeed === 'number' && + p.mouseScrollSpeed > 0 && { mouseScrollSpeed: p.mouseScrollSpeed }), ...(typeof p.logs === 'boolean' && { logs: p.logs }), ...(p.theme && typeof p.theme === 'object' && { theme: { ...DEFAULT_THEME, ...p.theme } }), }; } -export function saveConfig(_config: Config): void { +/** + * Write the initial config stub (`port` + `host` only) to `~/.config/webtty/config.json`. + * + * Only writes the two keys that are safe to persist as defaults; all other + * keys are intentionally omitted so future webtty versions can add new fields + * without the stub going stale. + */ +export function initConfig(): void { fs.mkdirSync(path.dirname(getConfigPath()), { recursive: true }); const content = JSON.stringify( { diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index c7a7c0f..d78c05a 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -75,6 +75,7 @@ describe('server — routes', () => { expect(typeof body.fontSize).toBe('number'); expect(typeof body.copyOnSelect).toBe('boolean'); expect(typeof body.rightClickBehavior).toBe('string'); + expect(typeof body.mouseScrollSpeed).toBe('number'); expect(body.port).toBeUndefined(); expect(body.host).toBeUndefined(); expect(body.shell).toBeUndefined(); diff --git a/src/server/routes.ts b/src/server/routes.ts index 1108482..05a7fc5 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -76,6 +76,7 @@ export async function handleRequest( theme: config.theme, copyOnSelect: config.copyOnSelect, rightClickBehavior: config.rightClickBehavior, + mouseScrollSpeed: config.mouseScrollSpeed, }; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(clientConfig));