From a34ca1b09bc34e9fe8c0340e0906ea2cb5c63abd Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 31 Mar 2026 13:07:33 -0400 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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;