From a34ca1b09bc34e9fe8c0340e0906ea2cb5c63abd Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 31 Mar 2026 13:07:33 -0400 Subject: [PATCH 1/8] feat: add Show HN entry for webtty with usage details --- docs/adrs/021.client.file-drop.md | 193 ++++++++++++++++++++++++++++++ hn.md | 20 ++++ 2 files changed, 213 insertions(+) create mode 100644 docs/adrs/021.client.file-drop.md create mode 100644 hn.md diff --git a/docs/adrs/021.client.file-drop.md b/docs/adrs/021.client.file-drop.md new file mode 100644 index 0000000..7a216a2 --- /dev/null +++ b/docs/adrs/021.client.file-drop.md @@ -0,0 +1,193 @@ +# ADR 021: Client — File drag-and-drop via server upload and path injection + +**SPEC:** [client](../specs/client.md) +**Status:** Rejected +**Date:** 2026-03-31 + +## Why Rejected + +The upload approach was rejected because it violates the core requirement: **edit the original file, not a copy**. + +Uploading creates a server-side copy in a temp directory. The user edits that copy; the original local file is never touched. Any save inside vim writes to `/tmp/webtty-/file.txt`, not back to `/Users/alice/projects/file.txt`. This is the wrong behaviour — the user wants to drag a file into vim and have vim open the real file, exactly as a native terminal emulator would inject the original path. + +The correct solution is to pass the real filesystem path directly to vim without copying the file at all. On Linux this is achievable natively via `text/uri-list` in the drop event. On macOS/Windows the browser blocks `file://` URIs from drag data, requiring a different strategy. See the follow-on ADR for the revised approach. + +--- + +## Context + +Dragging a local file into the webtty browser terminal and dropping it onto a +running vim session does not open the file in vim. The same vim config (vimrc +with drag-and-drop support) works correctly in native terminals such as iTerm2. + +### What native terminals do + +When a file is dragged onto a native terminal emulator (iTerm2, Ghostty, etc.), +the OS drag-and-drop API delivers the **full absolute filesystem path** of the +dropped file (e.g. `/Users/alice/Documents/notes.txt`). The terminal emulator +intercepts the OS drop event and injects that path as keystrokes into the PTY. +vim receives the path as text input, and vimrc DnD handlers (e.g. +`:autocmd BufReadCmd …`) act on it normally. + +### Why this fails in a browser + +Two independent problems prevent the same flow in webtty: + +**1. No `dragover`/`drop` handler — browser navigates away** + +`src/client/index.ts` registers no drag event listeners on the terminal +container. ghostty-web also does not handle file drops. Without a +`dragover` listener that calls `preventDefault()`, the browser interprets an +unhandled file drop as a navigation request and replaces the terminal tab with +the dropped file's content — tearing the session away entirely. + +**2. Browser security model hides full filesystem paths** + +The [HTML File API](https://developer.mozilla.org/en-US/docs/Web/API/File) +intentionally withholds the local filesystem path from JavaScript. A drop +event gives only `File.name` (basename, e.g. `notes.txt`) and `File.type`. +There is no standard way to recover `/Users/alice/Documents/notes.txt` from +the browser. This is a deliberate privacy boundary — the same reason +`` shows a fake path. No amount of client-side code can +cross it. + +This is the fundamental gap from native terminals: iTerm2 has OS-level +access to the full path; a browser tab does not. + +### What is achievable + +The browser can read the **file content** and upload it to the webtty server. +The server has a real filesystem and can save the file to a session-scoped +temp directory. The server-side path (e.g. +`/tmp/webtty-/notes.txt`) is a valid absolute path that can be +injected into the PTY — vim can open it, read it, and write to it. This +reproduces the native terminal behaviour for the editing workflow, with the +one difference that the file lives in a server temp directory rather than its +original location. + +## Decision + +### Client — intercept drop, upload, inject path + +Add `dragover` and `drop` event listeners on the terminal container in +`src/client/index.ts`: + +1. `dragover` — call `preventDefault()` on every event to prevent browser + navigation. This is unconditional; it does not matter whether the drag + contains files. + +2. `drop` — for each file in `event.dataTransfer.files`: + - Read the content via `file.arrayBuffer()`. + - POST a `multipart/form-data` request to `/api/upload` with the file. + - Await the JSON response `{ path: "/tmp/webtty-/" }`. + - Send the server path to the PTY via `ws.send(path)` — as plain text, + identical to typing the path at the terminal. Multiple files are + separated by spaces (matching shell quoting conventions). + +Sending plain text means the path lands wherever the cursor is: +- At a shell prompt → the path appears as a typed argument (user presses Enter) +- In vim normal mode → the characters are interpreted as normal-mode input + (user should be in insert mode or at the command line when dropping) + +This is consistent with how iTerm2 behaves: it injects the path as text and +leaves interpretation to the application. + +### Server — `/api/upload` endpoint + +Add a `POST /api/upload` HTTP route to `src/server/`: + +- Accept `multipart/form-data` with one or more `file` fields. +- Save each file under `/tmp/webtty-/` (create on first use). +- Respond `200` with `{ paths: ["/tmp/webtty-/", …] }`. +- No authentication beyond session ownership — the session WebSocket + connection is already gated on session existence. + +Temp files are not cleaned up automatically by the server; they are owned +by the process and live until the OS reclaims `/tmp` (reboot or `tmpwatch`). +This matches how native terminals leave dropped files in their original +locations — cleanup is the user's concern. + +### Sequence + +``` +user drags file.txt from Finder onto the webtty terminal + → browser fires dragover: preventDefault() — no navigation + → browser fires drop: + client reads file.txt content (arrayBuffer) + client POSTs to /api/upload?session= + → server saves to /tmp/webtty-/file.txt + → server responds { paths: ["/tmp/webtty-/file.txt"] } + client: ws.send("/tmp/webtty-/file.txt") + → PTY receives "/tmp/webtty-/file.txt" as text input + → vim (if in insert mode or command line) receives the path + → user runs :e or the vimrc DnD handler fires ✅ +``` + +## Considered Options + +### Option A: Send `file.name` only (no upload) + +Intercept the drop and send only the basename (`notes.txt`) to the PTY. +No server changes required. + +Rejected — a bare filename with no path is not useful to vim's DnD handler or +`:e`. It works only by coincidence when a file of the same name exists in the +current working directory. This does not reproduce native terminal behaviour +for any realistic workflow. + +### Option B: Upload + path injection (chosen) + +See Decision above. Reproduces native terminal behaviour at the cost of a +server-side temp directory and a new HTTP endpoint. The file is accessible +via its absolute path; vim can open, edit, and save it normally. + +### Option C: File System Access API (browser, no upload) + +Chrome 86+ supports `DataTransferItem.getAsFileSystemHandle()`, which +returns a `FileSystemFileHandle`. A handle supports `getFile()` (read) and +`createWritable()` (write back to the original location with user permission). + +Rejected at this time: +- Safari does not support it. +- Requires an explicit user permission prompt per file. +- The handle gives read/write access to the browser context, not a + server-side path — the PTY still cannot receive a meaningful path unless + the file is also uploaded. There is no API to retrieve the original + filesystem path from a handle. +- Could be layered on top of Option B in the future to enable round-trip + writes back to the original file. + +### Option D: OSC 52 / terminal protocol DnD extension + +Some terminal emulators use out-of-band escape sequences to signal a drop +event to the running application (e.g. iTerm2's proprietary DnD protocol). +The PTY application opts in and handles the event natively. + +Rejected — no standardized cross-terminal DnD protocol exists. vim's vimrc +DnD support relies on path injection by the terminal emulator (Option B +behaviour), not on an in-band protocol. Implementing a webtty-specific +protocol would require a vim plugin and is out of scope. + +## Consequences + +- Dragging a local file onto the webtty terminal no longer navigates the + browser tab away — the session is preserved. +- The dropped file is accessible in the PTY at its server-side temp path. + vim (and any other TUI) can open it via that path. +- Multiple files dropped simultaneously are uploaded in parallel; their + paths are injected space-separated. +- Files persist in `/tmp/webtty-/` until the OS reclaims temp + space. No automatic cleanup is added (consistent with native terminal + behaviour where the original file already exists on disk). +- The workaround is self-contained: `dragover` prevents navigation + unconditionally; the upload path is only taken when `dataTransfer.files` + is non-empty. + +## Related Decisions + +- [ADR 014 — Non-text paste via Ctrl+V PTY forwarding](014.client.image-paste.md): + same class of problem — browser security model blocking a native terminal + feature (clipboard access); same solution class — intercept the browser + event and route content through the server. +- [ADR 017 — SGR mouse scroll sequences](017.client.mouse-scroll.md): + another ghostty-web gap worked around at the client layer. diff --git a/hn.md b/hn.md new file mode 100644 index 0000000..c83651f --- /dev/null +++ b/hn.md @@ -0,0 +1,20 @@ +Title: +Show HN: webtty – Run CLI/TUI apps in a browser tab (npx webtty) +URL: +https://github.com/jesse23/webtty +Text: +Built for people who like to keep everything in the browser — one window, fewer context switches, the terminal lives where the rest of your tools already are. +There are a few other browser terminals worth knowing: +- ttyd is solid and lightweight, but the session is destroyed when the WebSocket connection drops +- Zellij's web mode (v0.44.0, March 2026) supports the same, but I use vim as my base +- VS Code Server (`code serve-web`) is another good choice but you get the full IDE there. +- webtty is the KISS option: a pure terminal in the browser with a thin session layer on top. No multiplexer, no framework — just `bunx webtty` or `npx webtty`, works on macOS, Linux, and Windows (including CMD). No plans to make it rich or agentic — that's the point. +Under the hood it uses ghostty-web (Ghostty compiled to WebAssembly) for rendering, so proper TUI support — ncurses, vim, htop, lazygit all work. +Try it: + npx webtty # opens main session + npx webtty go [id] # named sessions as URLs + npx webtty help +Would love feedback on the Windows experience especially, since that's the underserved case. + +More on the browser-first terminal setup that motivated this: +https://github.com/jesse23/webtty/blob/main/docs/awesome-web.md From a7cbdb47c80e2d2dcc7d95648ec77c7a91c84aa7 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 31 Mar 2026 13:30:06 -0400 Subject: [PATCH 2/8] docs: document file DnD path analysis and move HN post to docs --- docs/adrs/021.client.file-drop.md | 271 +++++++++++++++--------------- hn.md => docs/hn.md | 0 2 files changed, 137 insertions(+), 134 deletions(-) rename hn.md => docs/hn.md (100%) diff --git a/docs/adrs/021.client.file-drop.md b/docs/adrs/021.client.file-drop.md index 7a216a2..5b00a52 100644 --- a/docs/adrs/021.client.file-drop.md +++ b/docs/adrs/021.client.file-drop.md @@ -1,17 +1,9 @@ -# ADR 021: Client — File drag-and-drop via server upload and path injection +# ADR 021: Client — File drag-and-drop path injection into PTY **SPEC:** [client](../specs/client.md) **Status:** Rejected **Date:** 2026-03-31 -## Why Rejected - -The upload approach was rejected because it violates the core requirement: **edit the original file, not a copy**. - -Uploading creates a server-side copy in a temp directory. The user edits that copy; the original local file is never touched. Any save inside vim writes to `/tmp/webtty-/file.txt`, not back to `/Users/alice/projects/file.txt`. This is the wrong behaviour — the user wants to drag a file into vim and have vim open the real file, exactly as a native terminal emulator would inject the original path. - -The correct solution is to pass the real filesystem path directly to vim without copying the file at all. On Linux this is achievable natively via `text/uri-list` in the drop event. On macOS/Windows the browser blocks `file://` URIs from drag data, requiring a different strategy. See the follow-on ADR for the revised approach. - --- ## Context @@ -24,170 +16,181 @@ with drag-and-drop support) works correctly in native terminals such as iTerm2. When a file is dragged onto a native terminal emulator (iTerm2, Ghostty, etc.), the OS drag-and-drop API delivers the **full absolute filesystem path** of the -dropped file (e.g. `/Users/alice/Documents/notes.txt`). The terminal emulator -intercepts the OS drop event and injects that path as keystrokes into the PTY. -vim receives the path as text input, and vimrc DnD handlers (e.g. -`:autocmd BufReadCmd …`) act on it normally. - -### Why this fails in a browser +dropped file (e.g. `/Users/alice/projects/notes.txt`). The terminal emulator +injects that path as keystrokes into the PTY. vim receives the path as text, and +vimrc DnD handlers act on it normally. -Two independent problems prevent the same flow in webtty: +### The first problem: no drop handler — browser navigates away -**1. No `dragover`/`drop` handler — browser navigates away** +`src/client/index.ts` registers no drag event listeners. Without a `dragover` +handler calling `preventDefault()`, the browser treats a file drop as a +navigation request and replaces the terminal tab with the file's content — the +session is gone. This is fixable with two listeners regardless of which path +resolution approach is taken. -`src/client/index.ts` registers no drag event listeners on the terminal -container. ghostty-web also does not handle file drops. Without a -`dragover` listener that calls `preventDefault()`, the browser interprets an -unhandled file drop as a navigation request and replaces the terminal tab with -the dropped file's content — tearing the session away entirely. - -**2. Browser security model hides full filesystem paths** +### The hard problem: the browser hides the filesystem path The [HTML File API](https://developer.mozilla.org/en-US/docs/Web/API/File) -intentionally withholds the local filesystem path from JavaScript. A drop -event gives only `File.name` (basename, e.g. `notes.txt`) and `File.type`. -There is no standard way to recover `/Users/alice/Documents/notes.txt` from -the browser. This is a deliberate privacy boundary — the same reason -`` shows a fake path. No amount of client-side code can -cross it. +intentionally withholds the local filesystem path from JavaScript. A drop event +gives only `File.name` (basename) and `File.type`. This is a deliberate privacy +boundary — a web page learning `/Users/alice/...` would expose the user's +directory structure to any site they visit. + +The core requirement is: **inject the original file path into the PTY so vim +opens the real file in place**. Every approach below was evaluated against this. -This is the fundamental gap from native terminals: iTerm2 has OS-level -access to the full path; a browser tab does not. +--- -### What is achievable +## Approaches Investigated -The browser can read the **file content** and upload it to the webtty server. -The server has a real filesystem and can save the file to a session-scoped -temp directory. The server-side path (e.g. -`/tmp/webtty-/notes.txt`) is a valid absolute path that can be -injected into the PTY — vim can open it, read it, and write to it. This -reproduces the native terminal behaviour for the editing workflow, with the -one difference that the file lives in a server temp directory rather than its -original location. +### Approach A: `text/uri-list` from the drop event -## Decision +When a file is dragged from the OS file manager, the drag transfer may include +a `text/uri-list` entry containing `file:///path/to/file`. Reading it: -### Client — intercept drop, upload, inject path +```ts +const uri = event.dataTransfer.getData('text/uri-list'); +// e.g. "file:///home/alice/projects/notes.txt" +const path = decodeURIComponent(uri.replace('file://', '')); +ws.send(path); // inject real path into PTY +``` -Add `dragover` and `drop` event listeners on the terminal container in -`src/client/index.ts`: +**On Linux** (X11/Wayland, Chrome/Firefox): this works. The desktop DnD protocol +passes `file://` URIs through, and browsers on Linux do not strip them. The real +path is available, no upload required. -1. `dragover` — call `preventDefault()` on every event to prevent browser - navigation. This is unconditional; it does not matter whether the drag - contains files. +**On macOS and Windows** (Chrome, Safari, Edge): browsers explicitly block +`file://` URIs from `getData('text/uri-list')` — the call returns an empty +string. This is a security policy, not a bug, and applies equally to `localhost` +and remote origins. -2. `drop` — for each file in `event.dataTransfer.files`: - - Read the content via `file.arrayBuffer()`. - - POST a `multipart/form-data` request to `/api/upload` with the file. - - Await the JSON response `{ path: "/tmp/webtty-/" }`. - - Send the server path to the PTY via `ws.send(path)` — as plain text, - identical to typing the path at the terminal. Multiple files are - separated by spaces (matching shell quoting conventions). +**Verdict**: viable on Linux only. Not cross-platform. -Sending plain text means the path lands wherever the cursor is: -- At a shell prompt → the path appears as a typed argument (user presses Enter) -- In vim normal mode → the characters are interpreted as normal-mode input - (user should be in insert mode or at the command line when dropping) +--- -This is consistent with how iTerm2 behaves: it injects the path as text and -leaves interpretation to the application. +### Approach B: `File.name` only (no upload) -### Server — `/api/upload` endpoint +Send only the basename from `event.dataTransfer.files[0].name`. -Add a `POST /api/upload` HTTP route to `src/server/`: +**Verdict**: rejected. A bare filename is not a path. It works only when a file +of the same name already exists in the PTY's current working directory — not a +reliable workflow. -- Accept `multipart/form-data` with one or more `file` fields. -- Save each file under `/tmp/webtty-/` (create on first use). -- Respond `200` with `{ paths: ["/tmp/webtty-/", …] }`. -- No authentication beyond session ownership — the session WebSocket - connection is already gated on session existence. +--- -Temp files are not cleaned up automatically by the server; they are owned -by the process and live until the OS reclaims `/tmp` (reboot or `tmpwatch`). -This matches how native terminals leave dropped files in their original -locations — cleanup is the user's concern. +### Approach C: File System Access API — `getAsFileSystemHandle()` -### Sequence +Chrome 86+ / Edge 86+ support `DataTransferItem.getAsFileSystemHandle()`, which +returns a `FileSystemFileHandle`. The handle supports `getFile()` (read) and +`createWritable()` (write back to the original file). This is how code-server +(VS Code in the browser) handles DnD in "no folder opened" mode: +```ts +const handle = await item.getAsFileSystemHandle(); +// handle.name → "notes.txt" (filename only, no path) +// handle.getFile() → File (content, no path) +// handle.createWritable() (write back to original) ``` -user drags file.txt from Finder onto the webtty terminal - → browser fires dragover: preventDefault() — no navigation - → browser fires drop: - client reads file.txt content (arrayBuffer) - client POSTs to /api/upload?session= - → server saves to /tmp/webtty-/file.txt - → server responds { paths: ["/tmp/webtty-/file.txt"] } - client: ws.send("/tmp/webtty-/file.txt") - → PTY receives "/tmp/webtty-/file.txt" as text input - → vim (if in insert mode or command line) receives the path - → user runs :e or the vimrc DnD handler fires ✅ + +**The critical finding**: `FileSystemHandle` has no `.path` or `.fullPath` +property. The spec intentionally omits it for the same privacy reason as the +File API. The handle gives read/write capability, not location information. + +VS Code works around this by assigning a synthetic internal URI +(`file:///notes.txt`) and routing all I/O through the handle via +`HTMLFileSystemProvider`. This works because Monaco runs in the browser and +never needs a real path — it talks to its own file service abstraction. + +vim is different: vim needs a real server-side path to `open()` a file. There +is no way to hand vim a `FileSystemHandle`. Even with this API the file content +must still be uploaded to the server to get a path vim can use. + +**Verdict**: does not provide a path. Cannot skip the upload step for a PTY +use case. + +--- + +### Approach D: Electron's `webUtils.getPathForFile()` + +VS Code Desktop (the native Electron app) uses: + +```ts +// src/vs/platform/dnd/browser/dnd.ts +export function getPathForFile(file: File): string | undefined { + if (isNative && typeof globalThis.vscode?.webUtils?.getPathForFile === 'function') { + return globalThis.vscode.webUtils.getPathForFile(file); + } + return undefined; // always undefined in a browser +} ``` -## Considered Options +This returns the real filesystem path from a `File` object. It is injected by +Electron's preload script and is only available in Electron renderer processes. + +webtty is a Node.js HTTP server; the browser connecting to it is a standard +Chrome/Firefox/Safari tab. Electron is not involved. + +**Verdict**: not applicable. Returns `undefined` in any browser context. + +--- + +### Approach E: Upload to server, inject path -### Option A: Send `file.name` only (no upload) +Read file content in the browser, POST to a webtty server endpoint, server +saves the file, server responds with the path, client injects path into PTY. -Intercept the drop and send only the basename (`notes.txt`) to the PTY. -No server changes required. +This is confirmed to be exactly how code-server handles browser DnD when a +workspace folder is open: + +``` +browser: DataTransferItem.webkitGetAsEntry() → entry.file() → File + → fileService.writeFile(targetPath, content) [streams to server] +server: writes file to workspace directory +editor: opens file at workspace path +``` -Rejected — a bare filename with no path is not useful to vim's DnD handler or -`:e`. It works only by coincidence when a file of the same name exists in the -current working directory. This does not reproduce native terminal behaviour -for any realistic workflow. +code-server uses this approach because it is the only reliable cross-platform +mechanism available in a browser. It works on all platforms and all browsers. -### Option B: Upload + path injection (chosen) +**The sticking point for webtty**: the upload creates a **copy** of the file on +the server. When webtty runs locally (the common case), "server" and "local +machine" are the same host — so the copy lands on the same disk. But the copy +is at a server-chosen path (e.g. PTY cwd or `/tmp/webtty-/`), not the +original location. Any edits vim makes are to the copy; the original file is +not touched. -See Decision above. Reproduces native terminal behaviour at the cost of a -server-side temp directory and a new HTTP endpoint. The file is accessible -via its absolute path; vim can open, edit, and save it normally. +**Verdict**: cross-platform and implementable, but does not satisfy the +requirement of editing the original file in place. Rejected on those grounds. -### Option C: File System Access API (browser, no upload) +--- -Chrome 86+ supports `DataTransferItem.getAsFileSystemHandle()`, which -returns a `FileSystemFileHandle`. A handle supports `getFile()` (read) and -`createWritable()` (write back to the original location with user permission). +## Why Rejected -Rejected at this time: -- Safari does not support it. -- Requires an explicit user permission prompt per file. -- The handle gives read/write access to the browser context, not a - server-side path — the PTY still cannot receive a meaningful path unless - the file is also uploaded. There is no API to retrieve the original - filesystem path from a handle. -- Could be layered on top of Option B in the future to enable round-trip - writes back to the original file. +No browser API — on macOS or Windows — delivers the real filesystem path of a +dragged file to JavaScript. The three concrete paths investigated: -### Option D: OSC 52 / terminal protocol DnD extension +| Approach | Path available? | Cross-platform? | +|---|---|---| +| `text/uri-list` | ✅ Linux only | ❌ | +| File System Access API | ❌ handle only, no path | Chrome/Edge only | +| Electron `webUtils` | ❌ not in browser | ❌ | -Some terminal emulators use out-of-band escape sequences to signal a drop -event to the running application (e.g. iTerm2's proprietary DnD protocol). -The PTY application opts in and handles the event natively. +The one approach that works cross-platform (upload + path injection) creates a +copy rather than editing the original, which is the wrong behaviour for the +intended workflow. -Rejected — no standardized cross-terminal DnD protocol exists. vim's vimrc -DnD support relies on path injection by the terminal emulator (Option B -behaviour), not on an in-band protocol. Implementing a webtty-specific -protocol would require a vim plugin and is out of scope. +The Linux `text/uri-list` path is viable and zero-cost (no upload, real path), +but implementing it for Linux only — while leaving macOS and Windows with +degraded or no behaviour — is not a useful feature boundary for a +cross-platform tool. -## Consequences +This decision is deferred until a viable cross-platform approach emerges, or +until the scope is explicitly narrowed to Linux only. -- Dragging a local file onto the webtty terminal no longer navigates the - browser tab away — the session is preserved. -- The dropped file is accessible in the PTY at its server-side temp path. - vim (and any other TUI) can open it via that path. -- Multiple files dropped simultaneously are uploaded in parallel; their - paths are injected space-separated. -- Files persist in `/tmp/webtty-/` until the OS reclaims temp - space. No automatic cleanup is added (consistent with native terminal - behaviour where the original file already exists on disk). -- The workaround is self-contained: `dragover` prevents navigation - unconditionally; the upload path is only taken when `dataTransfer.files` - is non-empty. +--- ## Related Decisions - [ADR 014 — Non-text paste via Ctrl+V PTY forwarding](014.client.image-paste.md): same class of problem — browser security model blocking a native terminal - feature (clipboard access); same solution class — intercept the browser - event and route content through the server. -- [ADR 017 — SGR mouse scroll sequences](017.client.mouse-scroll.md): - another ghostty-web gap worked around at the client layer. + feature; same constraint that the browser withholds OS-level data. diff --git a/hn.md b/docs/hn.md similarity index 100% rename from hn.md rename to docs/hn.md From ddee03a6be6ac4cb7eda4373e38b0f097b5b4438 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 31 Mar 2026 14:17:14 -0400 Subject: [PATCH 3/8] feat: implement measured padding to fill canvas gaps in terminal --- docs/adrs/022.client.canvas-fill.md | 258 ++++++++++++++++++++++++++++ src/client/index.ts | 31 +++- 2 files changed, 282 insertions(+), 7 deletions(-) create mode 100644 docs/adrs/022.client.canvas-fill.md diff --git a/docs/adrs/022.client.canvas-fill.md b/docs/adrs/022.client.canvas-fill.md new file mode 100644 index 0000000..bcd7d7d --- /dev/null +++ b/docs/adrs/022.client.canvas-fill.md @@ -0,0 +1,258 @@ +# ADR 022: Client — Canvas gap fill via measured padding + +**SPEC:** [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-03-31 + +--- + +## Context + +A black bar appears on the right and bottom edges of the terminal canvas. DOM +inspection confirms the canvas element is narrower and shorter than its +`#terminal` container: the container fills the viewport, but the canvas falls +short by ~22px horizontally and a similar amount vertically. + +### Why the canvas is always smaller than the container + +ghostty-web's `FitAddon.proposeDimensions()` computes the terminal dimensions as: + +```js +// ghostty-web dist/ghostty-web.js — FitAddon (constants: IA=15, EA=2, CA=1) +const N = s - o - w - IA; // available width = clientWidth - paddingLeft - paddingRight - 15 +const t = Math.max(EA, Math.floor(N / g.width)); // cols = floor(available / charWidth) +``` + +Two factors combine to produce the gap: + +**1. Hardcoded scrollbar reserve (`IA = 15`)** + +FitAddon unconditionally subtracts 15px from the available width before +computing cols. This is a scrollbar width reservation borrowed from browser +terminal conventions. webtty sets `overflow: hidden` on `#terminal` so there +is never an actual scrollbar — the 15px is wasted every time. + +**2. Floor rounding of fractional character widths** + +`Math.floor(available / charWidth)` always discards any remainder less than one +cell. With a typical monospace font the sub-cell remainder is 0–14px. + +ghostty-web then sizes the canvas to exactly `cols × charWidth`: + +```js +// ghostty-web — Terminal resize path +const A = this.renderer.getMetrics(); +this.canvas.style.width = `${A.width * this.cols}px`; +this.canvas.style.height = `${A.height * this.rows}px`; +``` + +The result is a permanent gap of `15 + (available % charWidth)` pixels on the +right and a similar floor-rounding gap at the bottom. For a typical font and +viewport the horizontal gap is ~22px. + +### Why background colour alone is insufficient + +Matching `#terminal`'s `background` to the theme colour (ADR fix for the black +bar) hides the gap visually but does not use the space — the canvas still ends +short and TUI apps that draw to the full terminal grid have a visible empty +strip at the edges. + +### Why CSS stretching is wrong + +Setting `canvas { width: 100% !important }` scales the canvas element to fill +the container via CSS. The canvas pixel buffer does not change — the browser +scales the existing pixels up. On any display with sufficient PPI the text +appears blurry. + +--- + +## Decision + +Replace the direct `fitAddon.fit()` call and `fitAddon.observeResize()` / +`window.resize` pair with a `fit()` wrapper that distributes the measured gap +as CSS padding on `#terminal`: + +```ts +function fit(): void { + container.style.padding = '0'; // clear stale padding first + fitAddon.fit(); // FitAddon measures full container + const canvas = container.querySelector('canvas') as HTMLElement | null; + if (!canvas) return; + const hGap = container.clientWidth - canvas.offsetWidth; + const vGap = container.clientHeight - canvas.offsetHeight; + container.style.paddingLeft = `${Math.floor(hGap / 2)}px`; + container.style.paddingRight = `${Math.ceil(hGap / 2)}px`; + container.style.paddingTop = `${Math.floor(vGap / 2)}px`; + container.style.paddingBottom = `${Math.ceil(vGap / 2)}px`; +} + +fit(); +new ResizeObserver(() => fit()).observe(container); +``` + +### Why padding must be cleared before each fit + +FitAddon reads `padding-left` and `padding-right` from `window.getComputedStyle` +and subtracts them from `clientWidth` before computing cols (line 3360 of +ghostty-web.js). If stale padding from the previous call is still present, the +available width is further reduced: cols shrinks and a new gap opens up. +Clearing to `0` before calling `fitAddon.fit()` ensures FitAddon always +measures the full container. + +### Why canvas.offsetWidth, not metrics.width arithmetic + +The natural first attempt is `hGap = container.clientWidth % metrics.width` +— computing only the floor-rounding remainder. This gives the sub-cell gap +(~6px) but completely misses the 15px scrollbar reserve, leaving a ~16px gap +unexplained. Using `canvas.offsetWidth` directly reads what ghostty-web +actually rendered, capturing both the scrollbar reserve and the rounding +remainder in one measurement. + +### Sequence on each resize + +``` +container resizes (ResizeObserver fires) + → container.style.padding = '0' + → fitAddon.fit(): + available = clientWidth - 0 - 0 - 15 (scrollbar reserve still subtracted) + cols = floor(available / charWidth) + canvas.style.width = cols × charWidth + → hGap = clientWidth - canvas.offsetWidth (= 15 + floor_remainder) + → paddingLeft = floor(hGap / 2) + → paddingRight = ceil(hGap / 2) + → content area = clientWidth - hGap = canvas.offsetWidth ✅ +``` + +The gap is split evenly on left and right (one side gets the extra pixel if +`hGap` is odd), matching the natural padding behaviour of native terminal +emulators such as Ghostty. + +## Considered Options + +### Option A: CSS `width: 100% !important` on canvas + +Overrides ghostty-web's inline `width` style and stretches the canvas element +to fill the container. No JavaScript changes required. + +Rejected — CSS scaling a canvas element scales the pixel buffer: the rendered +glyphs are visibly blurry, especially at sub-integer scale factors. The blurring +worsens at larger font sizes and higher DPI displays. + +### Option B: `container.clientWidth % metrics.width` for gap calculation + +Uses `metrics.width` (charWidth from `renderer.getMetrics()`) to compute only +the floor-rounding sub-cell remainder. Simpler and avoids querying the DOM for +the canvas element. + +Rejected — silently ignores ghostty-web's hardcoded 15px scrollbar reserve, +leaving ~15px of unaccounted gap. The resulting padding is too small; a visible +strip remains at the right and bottom edges. + +### Option C: Measured gap via `canvas.offsetWidth` (chosen) + +Reads the actual rendered canvas CSS width after each fit and computes the gap +directly. Captures the scrollbar reserve, the floor-rounding remainder, and any +future changes to ghostty-web's internal constants without code changes. + +## Consequences + +- The canvas fills the `#terminal` container exactly at every viewport size and + after every resize, with no blurring. +- The gap is split as equal left/right and top/bottom padding. For typical + font sizes this is a few pixels per side — visually identical to native + Ghostty's own padding behaviour. +- `fitAddon.observeResize()` and the `window.resize` listener are replaced by a + single `ResizeObserver` on the container. Behaviour is equivalent; the + observer fires on container resizes rather than window resizes, which is more + precise when the terminal is embedded in a larger layout. +- If ghostty-web changes its scrollbar reserve constant (`IA`), the padding + calculation self-corrects on the next resize — no code change required. + +## Fix to ghostty-web + +### What the bug is + +`FitAddon` in [`lib/addons/fit.ts`](https://github.com/coder/ghostty-web/blob/6a1a50df5b4f6b34d1b1de10fad3a0fc811bfbc0/lib/addons/fit.ts#L24) +has a module-level constant with no configuration path: + +```ts +const DEFAULT_SCROLLBAR_WIDTH = 15; // Reserve space for future scrollback scrollbar +``` + +Applied unconditionally in `proposeDimensions()`: + +```ts +const availableWidth = containerWidth - paddingLeft - paddingRight - DEFAULT_SCROLLBAR_WIDTH; +``` + +`FitAddon()` takes no constructor arguments and has no options interface. The +15px is always subtracted regardless of whether the container has a scrollbar. + +### What xterm.js does instead + +xterm.js's `FitAddon` (the library ghostty-web's addon is derived from) reads +the scrollbar width dynamically from terminal options: + +```ts +const scrollbarWidth = (this._terminal.options.scrollback === 0 || !showScrollbar + ? 0 + : (this._terminal.options.scrollbar?.width ?? ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH)); +``` + +When `scrollback === 0` or the scrollbar is hidden, xterm.js passes `0` — no +reserve. ghostty-web skips this logic entirely. + +### What to change + +Add an options interface to `FitAddon` so callers can pass `scrollbarWidth: 0` +when the container has no scrollbar: + +```ts +// lib/addons/fit.ts +export interface IFitAddonOptions { + scrollbarWidth?: number; +} + +export class FitAddon implements ITerminalAddon { + private _scrollbarWidth: number; + + constructor(options?: IFitAddonOptions) { + this._scrollbarWidth = options?.scrollbarWidth ?? DEFAULT_SCROLLBAR_WIDTH; + } + + proposeDimensions(): ITerminalDimensions | undefined { + // ... + const availableWidth = containerWidth - paddingLeft - paddingRight - this._scrollbarWidth; + // ... + } +} +``` + +webtty would then use: + +```ts +const fitAddon = new FitAddon({ scrollbarWidth: 0 }); +``` + +With this upstream fix, the padding workaround in `fit()` could be simplified: +the gap would be only the floor-rounding sub-cell remainder (~0–14px), and the +measured-gap approach would remain correct (Option C still applies for the +floor-rounding gap, but the 15px offset disappears). + +### Contribution checklist + +- [ ] Open issue: "`FitAddon` hardcodes 15px scrollbar reserve — no opt-out for + containers with `overflow: hidden`" +- [ ] PR: `lib/addons/fit.ts` — add `IFitAddonOptions` interface, `scrollbarWidth` + constructor param defaulting to `DEFAULT_SCROLLBAR_WIDTH` +- [ ] Update `lib/addons/fit.test.ts` line 209–212 — add a `scrollbarWidth: 0` + test case asserting the reserve is absent + +## Related Decisions + +- [ADR 017 — SGR mouse scroll sequences](017.client.mouse-scroll.md): same + pattern — a ghostty-web internal behaviour gap worked around at the webtty + client layer, with an upstream fix path documented. +- [ADR 014 — Non-text paste via Ctrl+V PTY forwarding](014.client.image-paste.md): + same pattern — ghostty-web behaviour corrected at the webtty layer with the + upstream fix documented alongside. diff --git a/src/client/index.ts b/src/client/index.ts index 0a589a1..fdeb419 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -67,9 +67,31 @@ const fitAddon = new FitAddon(); term.loadAddon(fitAddon); const container = document.getElementById('terminal') as HTMLElement; +if (config.theme?.background) { + container.style.background = config.theme.background; +} await term.open(container); -fitAddon.fit(); -fitAddon.observeResize(); + +// FitAddon computes cols = floor((containerWidth - scrollbarReserve) / charWidth), +// leaving a gap larger than one sub-cell. Measure the actual canvas dimensions +// after fitting and distribute the gap as padding so the canvas fills exactly. +// Padding must be cleared first: FitAddon reads it from computed style and +// subtracts it before computing cols, so stale padding would shrink the result. +function fit(): void { + container.style.padding = '0'; + fitAddon.fit(); + const canvas = container.querySelector('canvas') as HTMLElement | null; + if (!canvas) return; + const hGap = container.clientWidth - canvas.offsetWidth; + const vGap = container.clientHeight - canvas.offsetHeight; + container.style.paddingLeft = `${Math.floor(hGap / 2)}px`; + container.style.paddingRight = `${Math.ceil(hGap / 2)}px`; + container.style.paddingTop = `${Math.floor(vGap / 2)}px`; + container.style.paddingBottom = `${Math.ceil(vGap / 2)}px`; +} + +fit(); +new ResizeObserver(() => fit()).observe(container); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; let ws: WebSocket; @@ -195,11 +217,6 @@ term.onResize(({ cols, rows }: { cols: number; rows: number }) => { } }); -// Refit the terminal whenever the browser window is resized. -window.addEventListener('resize', () => { - fitAddon.fit(); -}); - // Copy the selected text to the clipboard whenever the selection changes. if (config.copyOnSelect) { term.onSelectionChange(() => { From 8c9c6ae9877dbd209e31301a7dec1dbb2ac55ac8 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 31 Mar 2026 14:31:59 -0400 Subject: [PATCH 4/8] fix: guard ResizeObserver loop and clamp negative gap --- src/client/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index fdeb419..1184b03 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -82,8 +82,8 @@ function fit(): void { fitAddon.fit(); const canvas = container.querySelector('canvas') as HTMLElement | null; if (!canvas) return; - const hGap = container.clientWidth - canvas.offsetWidth; - const vGap = container.clientHeight - canvas.offsetHeight; + const hGap = Math.max(0, container.clientWidth - canvas.offsetWidth); + const vGap = Math.max(0, container.clientHeight - canvas.offsetHeight); container.style.paddingLeft = `${Math.floor(hGap / 2)}px`; container.style.paddingRight = `${Math.ceil(hGap / 2)}px`; container.style.paddingTop = `${Math.floor(vGap / 2)}px`; @@ -91,7 +91,7 @@ function fit(): void { } fit(); -new ResizeObserver(() => fit()).observe(container); +new ResizeObserver(() => fit()).observe(container, { box: 'border-box' }); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; let ws: WebSocket; From b0690e27b12b0b16fa79d77851fd4dd55a21dad3 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 31 Mar 2026 15:13:47 -0400 Subject: [PATCH 5/8] feat: add runtime font-size zoom functionality via Ctrl/Cmd +/- shortcuts --- docs/adrs/023.client.font-size-zoom.md | 160 +++++++++++++++++++++++++ src/client/index.ts | 19 +++ 2 files changed, 179 insertions(+) create mode 100644 docs/adrs/023.client.font-size-zoom.md diff --git a/docs/adrs/023.client.font-size-zoom.md b/docs/adrs/023.client.font-size-zoom.md new file mode 100644 index 0000000..2732a1c --- /dev/null +++ b/docs/adrs/023.client.font-size-zoom.md @@ -0,0 +1,160 @@ +# ADR 023: Client — Runtime font-size zoom via Ctrl/Cmd +/- + +**SPEC:** [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-03-31 + +--- + +## Context + +The terminal font size is set once at startup from `config.fontSize`. There is +no in-session way to increase or decrease it. Users familiar with VS Code and +native terminal emulators expect `Ctrl/Cmd` + `=` to zoom in, `Ctrl/Cmd` + `-` +to zoom out, and `Ctrl/Cmd` + `0` to reset — without holding Shift for `+`. + +The feature has two non-obvious constraints: + +1. **Browser page-zoom conflict.** The same key combinations trigger the + browser's own page-zoom. The handler must `preventDefault()` to suppress it. + +2. **ghostty-web API surface.** `Terminal` (the public class) does not expose + `setFontSize()` directly. The method lives on `CanvasRenderer`, which is an + internal class not accessible from outside the package. + +--- + +## Decision + +Attach a `keydown` listener to `window` in capture phase. On `Ctrl` or `Meta` +plus `=`, `-`, or `0`: call `preventDefault()`, update `currentFontSize`, write +to `term.options.fontSize`, and call `fit()`. + +```ts +let currentFontSize = config.fontSize; +window.addEventListener( + 'keydown', + (e: KeyboardEvent) => { + if (!e.ctrlKey && !e.metaKey) return; + if (e.key !== '=' && e.key !== '-' && e.key !== '0') return; + e.preventDefault(); + if (e.key === '=') currentFontSize = Math.min(32, currentFontSize + 1); + else if (e.key === '-') currentFontSize = Math.max(6, currentFontSize - 1); + else currentFontSize = config.fontSize; + term.options.fontSize = currentFontSize; + fit(); + }, + { capture: true }, +); +``` + +### Why `e.key === '='` and not `'+'` + +On a standard keyboard `+` requires Shift. VS Code binds the unshifted `=` key +(`Ctrl+=`) so users do not have to press three keys to zoom in. Matching `e.key` +directly (`'='`) handles this correctly because `e.key` reflects the character +produced by the physical key, not the shifted character. + +### Why `term.options.fontSize = n` instead of a direct renderer call + +`term.renderer` is typed `CanvasRenderer | undefined` where `CanvasRenderer` is +not exported. Accessing it at runtime produces a TypeScript error and couples the +call site to an undocumented internal. + +The correct path is the xterm.js options-proxy pattern already present in +ghostty-web: `term.options` is a `Proxy` whose setter calls +`this.handleOptionChange(key, value)` for every assigned property. For +`'fontSize'` the handler executes: + +```js +// ghostty-web dist/ghostty-web.js — Terminal.handleOptionChange +this.renderer && (this.renderer.setFontSize(this.options.fontSize), this.handleFontChange()); +``` + +Assigning `term.options.fontSize = n` therefore triggers the full internal +update — renderer font change and canvas resize — through the documented public +surface. + +### Why `window` and not `container` + +Zoom is a viewport-level gesture. Users expect it to work whether the terminal +div has focus or not, exactly as browser page-zoom does. Attaching to `window` +ensures the shortcuts fire regardless of which element is focused. + +### Why `{ capture: true }` + +ghostty-web registers its own `keydown` listener on the container during +`term.open()`. Without capture, the browser dispatches the event to the container +listener first; ghostty-web may consume it or forward characters to the PTY +before the zoom handler sees it. Capture phase guarantees interception before +any bubbling-phase listener. + +`preventDefault()` in the same handler stops the browser page-zoom. Without it, +both the terminal zoom and the page zoom would fire on the same keystroke. + +### Why `fit()` after every zoom step + +`term.options.fontSize = n` resizes the canvas pixel buffer to match the new +font metrics. The canvas dimensions change, so the gap between canvas and +container changes too. `fit()` re-measures and redistributes the gap as padding +(see ADR 022), keeping the canvas centred at the new size. + +### Font size bounds + +| Bound | Value | Reason | +|-------|-------|--------| +| Minimum | 6 | Below ~6px glyphs are illegible; ghostty-web may also produce rendering artefacts | +| Maximum | 32 | Covers all practical use; beyond this a single line barely fits the viewport | +| Reset | `config.fontSize` | Returns to the server-configured default, not a hardcoded value | + +--- + +## Considered Options + +### Option A: CSS `transform: scale()` on the canvas + +Apply a CSS scale transform to the canvas element to visually enlarge the +terminal without touching ghostty-web. + +Rejected — CSS scaling a canvas scales the pixel buffer: rendered glyphs become +blurry (identical rejection reason as in ADR 022, Option A). Additionally, the +PTY cols/rows do not change, so the terminal application still thinks the +viewport is the original size and wraps lines accordingly. + +### Option B: `term.renderer?.setFontSize(n)` direct call + +Call `setFontSize` on `term.renderer` directly. + +Rejected — `CanvasRenderer` is not exported from ghostty-web. TypeScript reports +`Property 'setFontSize' does not exist on type 'Terminal'` at compile time. +Casting through `any` would suppress the error but silently break if the +internal structure changes. + +### Option C: `term.options.fontSize = n` via options proxy (chosen) + +Use the documented xterm.js-compatible options assignment path. + +Accepted — type-safe, uses the public API surface, and routes through +ghostty-web's own `handleOptionChange` so all internal side-effects (renderer +update, canvas resize) are handled consistently. + +--- + +## Consequences + +- In-session font zoom works with `Ctrl/Cmd` + `=`/`-`/`0`, matching VS Code + and native terminal emulator conventions. +- Browser page-zoom is suppressed on those key combinations for the lifetime of + the page. Users who want browser zoom must use the View menu or a different + shortcut. +- Font size is not persisted across sessions. Reload returns to `config.fontSize`. +- The zoom range (6–32) is hardcoded. A future config option + (`fontSizeMin`, `fontSizeMax`) could make it user-adjustable. + +## Related Decisions + +- [ADR 022 — Canvas gap fill via measured padding](022.client.canvas-fill.md): + `fit()` is called after every zoom step to recentre the canvas at the new size. +- [ADR 018 — Key-bindings config support](018.key-bindings.config-support.md): + zoom shortcuts bypass the configurable key-binding system intentionally — they + are a client-side UI gesture, not PTY input. diff --git a/src/client/index.ts b/src/client/index.ts index 1184b03..4f4ed8c 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -203,6 +203,25 @@ container.addEventListener( { capture: true }, ); +// Intercept Ctrl/Cmd +/- to resize the font without Shift, matching VS Code. +// Uses window so it fires regardless of focus, and preventDefault stops the +// browser's own page-zoom from triggering at the same time. +let currentFontSize = config.fontSize; +window.addEventListener( + 'keydown', + (e: KeyboardEvent) => { + if (!e.ctrlKey && !e.metaKey) return; + if (e.key !== '=' && e.key !== '-' && e.key !== '0') return; + e.preventDefault(); + if (e.key === '=') currentFontSize = Math.min(32, currentFontSize + 1); + else if (e.key === '-') currentFontSize = Math.max(6, currentFontSize - 1); + else currentFontSize = config.fontSize; + term.options.fontSize = currentFontSize; + fit(); + }, + { capture: true }, +); + // Forward terminal keystrokes and input to the PTY over WebSocket. term.onData((data: string) => { if (ws && ws.readyState === WebSocket.OPEN) { From 544cd8c48b9d85f9498bca46a5a400d9c7cbbecb Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 31 Mar 2026 15:17:31 -0400 Subject: [PATCH 6/8] fix: stop zoom key propagation to prevent = and - leaking to PTY --- src/client/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client/index.ts b/src/client/index.ts index 4f4ed8c..97a97fe 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -213,6 +213,7 @@ window.addEventListener( if (!e.ctrlKey && !e.metaKey) return; if (e.key !== '=' && e.key !== '-' && e.key !== '0') return; e.preventDefault(); + e.stopPropagation(); if (e.key === '=') currentFontSize = Math.min(32, currentFontSize + 1); else if (e.key === '-') currentFontSize = Math.max(6, currentFontSize - 1); else currentFontSize = config.fontSize; From 7876c5e9ae7e8170bc3106284ca4513b0ba46934 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 31 Mar 2026 15:25:24 -0400 Subject: [PATCH 7/8] docs: update last updated date and add font-size zoom functionality details --- docs/specs/client.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/specs/client.md b/docs/specs/client.md index 5f3f088..385e8e3 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -1,6 +1,6 @@ # SPEC: Client -**Last Updated:** 2026-03-27 +**Last Updated:** 2026-03-31 --- @@ -111,6 +111,20 @@ A capture-phase `keydown` listener on the terminal container fires before ghostt See [key-bindings spec](key-bindings.md) for the binding object schema and examples. +## Font-size Zoom + +`Ctrl/Cmd` + `=`, `-`, or `0` adjust the terminal font size in-session, matching VS Code and native terminal conventions. These shortcuts are **not configurable** — they are a fixed client-side UI gesture, not PTY input, and cannot be overridden via `keyboardBindings`. + +| Key | Action | +|-----|--------| +| `Ctrl/Cmd` + `=` | Increase font size by 1 (max 32) | +| `Ctrl/Cmd` + `-` | Decrease font size by 1 (min 6) | +| `Ctrl/Cmd` + `0` | Reset to `config.fontSize` | + +A capture-phase `keydown` listener on `window` fires first. It calls `preventDefault()` to suppress browser page-zoom and `stopPropagation()` to prevent the key from reaching ghostty-web's PTY input path. Font size is not persisted — reload returns to `config.fontSize`. + +See [ADR 023](../adrs/023.client.font-size-zoom.md). + ## Copy Behavior Controlled by two config keys from `GET /api/config`: @@ -148,3 +162,5 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | 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.key-bindings.config-support.md) | ✅ | +| Canvas gap fill | After each fit, distribute the gap between the container and canvas as symmetric padding so the canvas is centred at the new size | [ADR 022](../adrs/022.client.canvas-fill.md) | ✅ | +| Font-size zoom | `Ctrl/Cmd` + `=`/`-`/`0` adjust terminal font size in-session; not configurable; same shortcuts as VS Code | [ADR 023](../adrs/023.client.font-size-zoom.md) | ✅ | From c1f2ce834c535fa8b3690255583f0226250cdaef Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 31 Mar 2026 17:16:00 -0400 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20e.code=20key=20detection,=20bounds=20clamp,=20ADR?= =?UTF-8?q?=20accuracy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/adrs/023.client.font-size-zoom.md | 44 ++++++++++++++++++-------- docs/specs/client.md | 6 ++-- src/client/index.ts | 19 +++++++---- 3 files changed, 48 insertions(+), 21 deletions(-) diff --git a/docs/adrs/023.client.font-size-zoom.md b/docs/adrs/023.client.font-size-zoom.md index 2732a1c..96385da 100644 --- a/docs/adrs/023.client.font-size-zoom.md +++ b/docs/adrs/023.client.font-size-zoom.md @@ -27,20 +27,24 @@ The feature has two non-obvious constraints: ## Decision Attach a `keydown` listener to `window` in capture phase. On `Ctrl` or `Meta` -plus `=`, `-`, or `0`: call `preventDefault()`, update `currentFontSize`, write -to `term.options.fontSize`, and call `fit()`. +plus `=`/`+`, `-`, or `0`: call `preventDefault()` and `stopPropagation()`, +update `currentFontSize`, write to `term.options.fontSize`, and call `fit()`. ```ts -let currentFontSize = config.fontSize; +let currentFontSize = Math.min(32, Math.max(6, config.fontSize)); window.addEventListener( 'keydown', (e: KeyboardEvent) => { if (!e.ctrlKey && !e.metaKey) return; - if (e.key !== '=' && e.key !== '-' && e.key !== '0') return; + const zoomIn = e.code === 'Equal' || e.code === 'NumpadAdd'; + const zoomOut = (e.code === 'Minus' && !e.shiftKey) || e.code === 'NumpadSubtract'; + const zoomReset = (e.code === 'Digit0' && !e.shiftKey) || e.code === 'Numpad0'; + if (!zoomIn && !zoomOut && !zoomReset) return; e.preventDefault(); - if (e.key === '=') currentFontSize = Math.min(32, currentFontSize + 1); - else if (e.key === '-') currentFontSize = Math.max(6, currentFontSize - 1); - else currentFontSize = config.fontSize; + e.stopPropagation(); + if (zoomIn) currentFontSize = Math.min(32, currentFontSize + 1); + else if (zoomOut) currentFontSize = Math.max(6, currentFontSize - 1); + else currentFontSize = Math.min(32, Math.max(6, config.fontSize)); term.options.fontSize = currentFontSize; fit(); }, @@ -48,12 +52,22 @@ window.addEventListener( ); ``` -### Why `e.key === '='` and not `'+'` +### Why `e.code` for key detection -On a standard keyboard `+` requires Shift. VS Code binds the unshifted `=` key -(`Ctrl+=`) so users do not have to press three keys to zoom in. Matching `e.key` -directly (`'='`) handles this correctly because `e.key` reflects the character -produced by the physical key, not the shifted character. +`KeyboardEvent.key` is layout-dependent: it reflects the character produced with +the current keyboard locale and active modifiers. On a US layout `Ctrl+=` yields +`e.key === '='`; with Shift held it yields `'+'`; on non-US layouts the same +physical key may yield yet another character. Matching `e.key` therefore produces +inconsistent behaviour across keyboards. + +`KeyboardEvent.code` identifies the physical key regardless of locale or shift +state. `e.code === 'Equal'` matches the `=`/`+` key on any layout — so both +`Ctrl+=` (no Shift) and `Ctrl++` (Shift+`=`) trigger zoom-in, consistent with +VS Code. `!e.shiftKey` guards on `Minus` and `Digit0` prevent `_` and `)` from +accidentally firing zoom-out and zoom-reset. + +Numpad variants (`NumpadAdd`, `NumpadSubtract`, `Numpad0`) are included so users +with a numeric keypad get the same shortcuts without extra configuration. ### Why `term.options.fontSize = n` instead of a direct renderer call @@ -92,6 +106,10 @@ any bubbling-phase listener. `preventDefault()` in the same handler stops the browser page-zoom. Without it, both the terminal zoom and the page zoom would fire on the same keystroke. +`stopPropagation()` prevents the event from reaching ghostty-web's own `keydown` +listener on the container. Without it, ghostty-web would still see the event and +forward the literal character (`=`, `-`, `0`) to the PTY as typed input. + ### Why `fit()` after every zoom step `term.options.fontSize = n` resizes the canvas pixel buffer to match the new @@ -105,7 +123,7 @@ container changes too. `fit()` re-measures and redistributes the gap as padding |-------|-------|--------| | Minimum | 6 | Below ~6px glyphs are illegible; ghostty-web may also produce rendering artefacts | | Maximum | 32 | Covers all practical use; beyond this a single line barely fits the viewport | -| Reset | `config.fontSize` | Returns to the server-configured default, not a hardcoded value | +| Reset | `clamp(config.fontSize, 6, 32)` | Returns to the server-configured default, clamped so a config value outside the range does not invert the zoom direction | --- diff --git a/docs/specs/client.md b/docs/specs/client.md index 385e8e3..c447819 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -117,9 +117,11 @@ See [key-bindings spec](key-bindings.md) for the binding object schema and examp | Key | Action | |-----|--------| -| `Ctrl/Cmd` + `=` | Increase font size by 1 (max 32) | +| `Ctrl/Cmd` + `=` or `+` | Increase font size by 1 (max 32) | | `Ctrl/Cmd` + `-` | Decrease font size by 1 (min 6) | -| `Ctrl/Cmd` + `0` | Reset to `config.fontSize` | +| `Ctrl/Cmd` + `0` | Reset to `config.fontSize` (clamped to 6–32) | + +Numpad `+`, `-`, and `0` are also recognised. A capture-phase `keydown` listener on `window` fires first. It calls `preventDefault()` to suppress browser page-zoom and `stopPropagation()` to prevent the key from reaching ghostty-web's PTY input path. Font size is not persisted — reload returns to `config.fontSize`. diff --git a/src/client/index.ts b/src/client/index.ts index 97a97fe..8cef13b 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -205,18 +205,25 @@ container.addEventListener( // Intercept Ctrl/Cmd +/- to resize the font without Shift, matching VS Code. // Uses window so it fires regardless of focus, and preventDefault stops the -// browser's own page-zoom from triggering at the same time. -let currentFontSize = config.fontSize; +// browser's own page-zoom from triggering at the same time. stopPropagation +// prevents ghostty-web from forwarding the key as literal PTY input. +// e.code is used for physical key identity, independent of keyboard layout. +// currentFontSize is clamped to [6, 32] on init so a config value outside +// that range never inverts the zoom direction on the first keypress. +let currentFontSize = Math.min(32, Math.max(6, config.fontSize)); window.addEventListener( 'keydown', (e: KeyboardEvent) => { if (!e.ctrlKey && !e.metaKey) return; - if (e.key !== '=' && e.key !== '-' && e.key !== '0') return; + const zoomIn = e.code === 'Equal' || e.code === 'NumpadAdd'; + const zoomOut = (e.code === 'Minus' && !e.shiftKey) || e.code === 'NumpadSubtract'; + const zoomReset = (e.code === 'Digit0' && !e.shiftKey) || e.code === 'Numpad0'; + if (!zoomIn && !zoomOut && !zoomReset) return; e.preventDefault(); e.stopPropagation(); - if (e.key === '=') currentFontSize = Math.min(32, currentFontSize + 1); - else if (e.key === '-') currentFontSize = Math.max(6, currentFontSize - 1); - else currentFontSize = config.fontSize; + if (zoomIn) currentFontSize = Math.min(32, currentFontSize + 1); + else if (zoomOut) currentFontSize = Math.max(6, currentFontSize - 1); + else currentFontSize = Math.min(32, Math.max(6, config.fontSize)); term.options.fontSize = currentFontSize; fit(); },