diff --git a/docs/adrs/021.client.file-drop.md b/docs/adrs/021.client.file-drop.md new file mode 100644 index 0000000..5b00a52 --- /dev/null +++ b/docs/adrs/021.client.file-drop.md @@ -0,0 +1,196 @@ +# ADR 021: Client — File drag-and-drop path injection into PTY + +**SPEC:** [client](../specs/client.md) +**Status:** Rejected +**Date:** 2026-03-31 + +--- + +## 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/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. + +### The first problem: no 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. + +### 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) 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. + +--- + +## Approaches Investigated + +### Approach A: `text/uri-list` from the drop event + +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: + +```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 +``` + +**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. + +**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. + +**Verdict**: viable on Linux only. Not cross-platform. + +--- + +### Approach B: `File.name` only (no upload) + +Send only the basename from `event.dataTransfer.files[0].name`. + +**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. + +--- + +### Approach C: File System Access API — `getAsFileSystemHandle()` + +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) +``` + +**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 +} +``` + +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 + +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. + +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 +``` + +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. + +**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. + +**Verdict**: cross-platform and implementable, but does not satisfy the +requirement of editing the original file in place. Rejected on those grounds. + +--- + +## Why Rejected + +No browser API — on macOS or Windows — delivers the real filesystem path of a +dragged file to JavaScript. The three concrete paths investigated: + +| 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 | ❌ | + +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. + +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. + +This decision is deferred until a viable cross-platform approach emerges, or +until the scope is explicitly narrowed to Linux only. + +--- + +## 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; same constraint that the browser withholds OS-level data. 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/docs/hn.md b/docs/hn.md new file mode 100644 index 0000000..c83651f --- /dev/null +++ b/docs/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 diff --git a/src/client/index.ts b/src/client/index.ts index 0a589a1..1184b03 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 = 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`; + container.style.paddingBottom = `${Math.ceil(vGap / 2)}px`; +} + +fit(); +new ResizeObserver(() => fit()).observe(container, { box: 'border-box' }); 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(() => {