From baf2332966f7122e2345800174bdcbbc1b63ea06 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 5 Apr 2026 17:52:35 -0400 Subject: [PATCH 01/15] docs: add deep-link spec for focus-existing-tab behavior --- docs/specs/deep-link.md | 95 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 docs/specs/deep-link.md diff --git a/docs/specs/deep-link.md b/docs/specs/deep-link.md new file mode 100644 index 0000000..06595a1 --- /dev/null +++ b/docs/specs/deep-link.md @@ -0,0 +1,95 @@ +# SPEC: Deep Link + +**Last Updated:** 2026-04-05 + +--- + +## Description + +Deep link support lets users open a specific webtty session directly from a URL — in a notification, a shell alias, a script, or a hyperlink. The behavior mirrors what iTerm2 and Alacritty offer via macOS URL scheme handlers (`iterm2://`, `x-alacritty://`): clicking a link starts the app if not running, and navigates to the right session. + +**Target platform:** macOS (primary). Linux (`xdg-open`) and Windows (`start`) are included since `openBrowser` already abstracts those; the focus-existing-tab mechanic is browser-dependent but works on all platforms. + +## URL Scheme + +webtty uses an `http://` URL directly — no custom OS-level URL scheme registration needed: + +``` +http://127.0.0.1:/s/ +``` + +`webtty go ` already opens this URL. The new behavior is what happens **inside the browser** when that URL is opened a second time: if a tab for that session is already open, focus it rather than opening a duplicate. + +## Focus-Existing-Tab Behavior + +### Problem + +`openBrowser(url)` always spawns a new tab on macOS (`open `). If `/s/my-session` is already open in a tab, the user ends up with two identical tabs. + +### Solution: BroadcastChannel focus handshake + +Each session tab listens on a `BroadcastChannel` named `webtty:focus:`. When a new navigation lands on `/s/`, the page checks — before fully mounting the terminal — whether another tab already owns that session: + +1. **New tab loads** `/s/my-session` +2. It posts `{ type: 'focus-request', sessionId: 'my-session' }` on `webtty:focus:my-session` +3. Any **existing tab** for the same session receives the message and calls `window.focus()` + `document.title` ping to bring itself forward +4. The existing tab replies with `{ type: 'focus-ack' }` +5. The **new tab**, on receiving `focus-ack` within 200 ms, closes itself (`window.close()`) +6. If no `focus-ack` arrives within 200 ms, the new tab proceeds to mount the terminal normally (it _is_ the first tab) + +### Why BroadcastChannel + +- Same-origin, no server round-trip +- Works in all modern browsers (Chrome, Firefox, Safari ≥ 15.4) +- No persistent state — the channel is ephemeral per tab lifetime +- `window.focus()` is permitted when called from within a message handler that is itself a response to user action or cross-tab coordination (not blocked as a pop-up) + +### Limitations + +- **`window.close()` only works if the tab was opened by script** (i.e. via `window.open()`). `open ` on macOS opens a new top-level tab that the browser does not consider "script-opened", so `window.close()` will be a no-op in that case. +- Workaround: instead of closing, the new tab **redirects** to a `/s//focus` stub page that shows a "Session already open in another tab — you can close this tab" message, or simply redirects back to the existing tab's URL (which the existing tab already focused). +- `window.focus()` behavior varies by browser and OS focus-stealing policy. On macOS + Chrome/Safari, cross-tab `window.focus()` typically raises the window but may not switch tabs without user permission. This is a known browser security constraint — no workaround exists without a native helper. + +## CLI Integration + +`webtty go ` behavior is unchanged at the CLI level. The focus-existing-tab logic is entirely client-side (browser tab), triggered by the normal `http://...` URL open. + +No new CLI command is introduced. The feature is transparent: `webtty go my-session` always works, and the browser handles deduplication. + +## Server Changes + +None. The server already serves `/s/:id` and the WebSocket endpoint. No new endpoints are needed. + +## Client Changes + +Two new behaviors in `src/client/index.ts`: + +### 1. Focus responder (existing tabs) + +On page load, register a `BroadcastChannel` listener for `webtty:focus:`: + +``` +channel.onmessage = (e) => { + if (e.data.type === 'focus-request') { + window.focus(); + channel.postMessage({ type: 'focus-ack' }); + } +}; +``` + +### 2. Focus initiator (new tab) + +On page load, before mounting the terminal, post a focus-request and wait up to 200 ms for an ack: + +``` +channel.postMessage({ type: 'focus-request', sessionId }); +// wait 200ms — if focus-ack received → show "already open" UI; else → mount terminal +``` + +If ack received: display a minimal fallback UI ("Session is open in another tab") rather than mounting a second terminal to the same PTY. The tab does not auto-close (browser restriction), but the existing tab has already been focused. + +## Features + +| Feature | Description | ADR | Done? | +|---------|-------------|-----|-------| +| Focus existing tab | When `webtty go ` opens a URL for an already-open session tab, the existing tab is focused and the new tab shows a fallback UI | — | ⬜ | From d6b2fd3d5fc3e880f1654692905ee1ed35cc892a Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 5 Apr 2026 18:01:03 -0400 Subject: [PATCH 02/15] docs: add 3rd party integration section to deep-link spec --- docs/specs/deep-link.md | 54 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/docs/specs/deep-link.md b/docs/specs/deep-link.md index 06595a1..57cf329 100644 --- a/docs/specs/deep-link.md +++ b/docs/specs/deep-link.md @@ -1,6 +1,6 @@ # SPEC: Deep Link -**Last Updated:** 2026-04-05 +**Last Updated:** 2026-04-05 (amended: 3rd party integration section) --- @@ -88,8 +88,60 @@ channel.postMessage({ type: 'focus-request', sessionId }); If ack received: display a minimal fallback UI ("Session is open in another tab") rather than mounting a second terminal to the same PTY. The tab does not auto-close (browser restriction), but the existing tab has already been focused. +## 3rd Party Integration + +Tools like [Vibe Island](https://vibeisland.app) sit in the macOS notch and monitor AI coding agents (Claude Code, OpenCode, Gemini CLI, Cursor, etc.), sending notifications when tasks complete and letting users jump to the right terminal tab with one click. + +### How these tools work + +The integration model varies by tool: + +| Tool | Integration mechanism | +|------|-----------------------| +| Claude Code | Hook entries written to `~/.claude/settings.json`; events sent over a local Unix socket bridge | +| Cursor | Hook entries written to `~/.cursor/hooks.json`; tab focus via a bundled VSIX extension | +| Gemini CLI | Hook entries written to `~/.gemini/settings.json`; Unix socket bridge | +| **OpenCode** | **HTTP SSE event stream** — no hook injection; Vibe Island polls OpenCode's REST/SSE API directly | + +webtty's position is closest to **OpenCode**: it already exposes an HTTP server with a REST API (`/api/sessions`) and WebSocket connections. No Unix socket bridge or config file injection is needed. + +### How a tool like Vibe Island can integrate with webtty + +**Discovery**: webtty advertises its running port via the same `PORT` env var / default `2346` convention. A third-party tool discovers a running webtty instance by polling `GET http://127.0.0.1:/api/sessions` — the same check the CLI uses (`isServerRunning()`). + +**Session listing**: `GET /api/sessions` returns all active sessions with `id`, `createdAt`, and `connected` (whether a WebSocket client is attached). This is sufficient for a notch UI to list running sessions. + +**Real-time session events**: The WebSocket at `/ws/` streams PTY output. A monitoring tool can connect to this endpoint to receive live output without affecting the user's terminal (multiple clients can attach to the same session; output is broadcast to all). + +**Jump to session**: To focus a specific session, the tool opens `http://127.0.0.1:/s/` in the default browser. The BroadcastChannel focus handshake (described above) handles the focus-existing-tab behavior — the existing tab comes forward, and the new navigation shows the fallback UI instead of opening a duplicate terminal. + +### What webtty needs to add for this to work + +The REST API and WebSocket are already there. The only missing piece is the **BroadcastChannel focus handshake** (the core feature of this spec). Once that lands, third-party tools get jump-to-session for free by opening the session URL. + +No new server endpoints, no config file protocol, no Unix socket bridge. + +### macOS URL scheme (`webtty://`) — future path + +iTerm2 registers `iterm2://` via `CFBundleURLTypes` in its app bundle's `Info.plist`, letting any app or notification open a terminal command directly. VS Code does the same with `vscode://`. + +webtty ships as an npm CLI — there is no app bundle and therefore no `Info.plist`. A `webtty://` scheme would require an optional native helper shim (a small Electron or Swift app that registers the protocol and forwards to the local server). This is out of scope for this spec but is the natural next step for tighter OS-level notification integration. + +The `http://127.0.0.1:PORT/s/` URL is a fully functional substitute in the meantime: any tool can open it with `open ` (macOS), `xdg-open` (Linux), or `start` (Windows), and the focus handshake handles the rest. + +### Summary: what third-party tools need to do + +| Action | How | +|--------|-----| +| Discover running webtty | `GET http://127.0.0.1:2346/api/sessions` — 200 + JSON array means running | +| List sessions | Same endpoint — returns `[{ id, createdAt, connected }]` | +| Watch session output | WebSocket `ws://127.0.0.1:2346/ws/?cols=80&rows=24` | +| Jump to session | `open http://127.0.0.1:2346/s/` — BroadcastChannel focuses existing tab | +| Custom port | Respect `PORT` env var; default `2346` | + ## Features | Feature | Description | ADR | Done? | |---------|-------------|-----|-------| | Focus existing tab | When `webtty go ` opens a URL for an already-open session tab, the existing tab is focused and the new tab shows a fallback UI | — | ⬜ | +| 3rd party integration | Tools like Vibe Island can discover, monitor, and jump to webtty sessions via the existing REST API + BroadcastChannel focus handshake | — | ⬜ | From fbe97bfa2482d420156b23386d9164504cbdb4ce Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 5 Apr 2026 18:10:55 -0400 Subject: [PATCH 03/15] =?UTF-8?q?docs:=20reorganize=20deep-link=20spec=20?= =?UTF-8?q?=E2=80=94=20scope/plan,=20inspection,=20features;=20add=20PID?= =?UTF-8?q?=20API=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/specs/deep-link.md | 212 ++++++++++++++++++++++++---------------- 1 file changed, 130 insertions(+), 82 deletions(-) diff --git a/docs/specs/deep-link.md b/docs/specs/deep-link.md index 57cf329..2bf3b1a 100644 --- a/docs/specs/deep-link.md +++ b/docs/specs/deep-link.md @@ -1,74 +1,133 @@ # SPEC: Deep Link -**Last Updated:** 2026-04-05 (amended: 3rd party integration section) +**Last Updated:** 2026-04-05 (amended: reorganized, PID-based API, 3rd party integration) --- -## Description +## Scope and Plan -Deep link support lets users open a specific webtty session directly from a URL — in a notification, a shell alias, a script, or a hyperlink. The behavior mirrors what iTerm2 and Alacritty offer via macOS URL scheme handlers (`iterm2://`, `x-alacritty://`): clicking a link starts the app if not running, and navigates to the right session. +### Problem -**Target platform:** macOS (primary). Linux (`xdg-open`) and Windows (`start`) are included since `openBrowser` already abstracts those; the focus-existing-tab mechanic is browser-dependent but works on all platforms. +Two related problems: -## URL Scheme +1. **Duplicate tabs** — `webtty go ` opens a new browser tab every time. If the session is already open, the user ends up with two identical tabs. +2. **No PID-based navigation** — Third-party tools (e.g. Vibe Island) track AI agent processes by PTY shell PID, not by session name. They have no way to map a PID to a webtty session or navigate directly to it. -webtty uses an `http://` URL directly — no custom OS-level URL scheme registration needed: +### What this spec covers -``` -http://127.0.0.1:/s/ +| Area | Change | +|------|--------| +| Client | BroadcastChannel focus handshake — focus existing tab instead of opening a duplicate | +| Server API | Expose `pid` in `GET /api/sessions` response | +| Server routing | `GET /s/pid/` — redirect to the session that owns that PTY PID | + +### Out of scope + +- `webtty://` custom URL scheme — requires a native app bundle (`Info.plist`). webtty is an npm CLI with no bundle. Documented as a future path in the [3rd Party Integration](#3rd-party-integration) section. +- CLI changes — `webtty go ` is unchanged. Focus logic is entirely client-side. +- SSE event stream — out of scope for this spec; the WebSocket already serves real-time output. + +--- + +## Inspection + +> Research findings that inform the design decisions above. + +### How Vibe Island works + +[Vibe Island](https://vibeisland.app) is a native macOS app that sits in the notch and monitors AI coding agents (Claude Code, OpenCode, Gemini CLI, Cursor, etc.). When a task completes, it sends a macOS notification. Clicking it jumps to the exact terminal tab where the agent ran. + +The integration model varies by tool: + +| Tool | How Vibe Island connects | How "jump" works | +|------|--------------------------|------------------| +| Claude Code | Hook entries written to `~/.claude/settings.json`; local Unix socket bridge | PID matching via macOS Accessibility API | +| Cursor | Hook entries written to `~/.cursor/hooks.json` | VSIX extension receives `cursor://vibeisland/jump?pid=`, walks `vscode.window.terminals`, matches `terminal.processId` | +| Gemini CLI | Hook entries written to `~/.gemini/settings.json`; Unix socket bridge | PID matching | +| OpenCode | HTTP SSE event stream — no hook injection needed | PID matching + port discovery | + +### The PID-based jump mechanism (VS Code/Cursor VSIX) + +Vibe Island's Cursor/VS Code extension: + +```js +vscode.window.registerUriHandler({ + async handleUri(uri) { + const params = new URLSearchParams(uri.query); + const pids = params.getAll('pid').map(p => parseInt(p, 10)); + + for (const terminal of vscode.window.terminals) { + const termPid = await terminal.processId; + if (pids.includes(termPid)) { + terminal.show(false); // focus the tab + return; + } + } + } +}); ``` -`webtty go ` already opens this URL. The new behavior is what happens **inside the browser** when that URL is opened a second time: if a tab for that session is already open, focus it rather than opening a duplicate. +Vibe Island knows the **shell PID** of each agent process from its monitoring hooks. On jump, it opens `cursor://vibeisland/jump?pid=12345`. The extension walks all open terminals, matches the PTY shell PID, and focuses the right one. -## Focus-Existing-Tab Behavior +### The gap for webtty -### Problem +webtty's current `GET /api/sessions` response is: + +```json +[{ "id": "main", "createdAt": 1700000000000, "connected": true }] +``` -`openBrowser(url)` always spawns a new tab on macOS (`open `). If `/s/my-session` is already open in a tab, the user ends up with two identical tabs. +The PTY PID is never exposed. Vibe Island cannot map a shell PID to a webtty session, and therefore cannot construct the jump URL `http://127.0.0.1:PORT/s/`. -### Solution: BroadcastChannel focus handshake +Both PTY backends already have the PID available: +- **node-pty**: `ptyProc.pid` (property on `IPty`) +- **Bun**: `proc.pid` (property on `Bun.spawn` result) -Each session tab listens on a `BroadcastChannel` named `webtty:focus:`. When a new navigation lands on `/s/`, the page checks — before fully mounting the terminal — whether another tab already owns that session: +It just needs to be surfaced through `PtyProcess`, `Session`, and `sessionToJson`. -1. **New tab loads** `/s/my-session` -2. It posts `{ type: 'focus-request', sessionId: 'my-session' }` on `webtty:focus:my-session` -3. Any **existing tab** for the same session receives the message and calls `window.focus()` + `document.title` ping to bring itself forward -4. The existing tab replies with `{ type: 'focus-ack' }` -5. The **new tab**, on receiving `focus-ack` within 200 ms, closes itself (`window.close()`) -6. If no `focus-ack` arrives within 200 ms, the new tab proceeds to mount the terminal normally (it _is_ the first tab) +### Port discovery -### Why BroadcastChannel +Vibe Island discovers running OpenCode instances via "multi-layer port discovery" (their phrasing). The most likely mechanism: scan common ports + read the process list (`ps aux | grep opencode serve --port`). webtty uses a fixed default port (`2346`) and respects `PORT` env var — the same convention is discoverable by the same scan. -- Same-origin, no server round-trip -- Works in all modern browsers (Chrome, Firefox, Safari ≥ 15.4) -- No persistent state — the channel is ephemeral per tab lifetime -- `window.focus()` is permitted when called from within a message handler that is itself a response to user action or cross-tab coordination (not blocked as a pop-up) +### BroadcastChannel (focus-existing-tab) -### Limitations +The browser cannot focus an existing tab from outside. The only same-origin mechanism is `BroadcastChannel`: a new tab loading `/s/` posts a focus-request; the existing tab for that session receives it, calls `window.focus()`, and acks; the new tab shows a fallback UI instead of mounting a second terminal to the same PTY. -- **`window.close()` only works if the tab was opened by script** (i.e. via `window.open()`). `open ` on macOS opens a new top-level tab that the browser does not consider "script-opened", so `window.close()` will be a no-op in that case. -- Workaround: instead of closing, the new tab **redirects** to a `/s//focus` stub page that shows a "Session already open in another tab — you can close this tab" message, or simply redirects back to the existing tab's URL (which the existing tab already focused). -- `window.focus()` behavior varies by browser and OS focus-stealing policy. On macOS + Chrome/Safari, cross-tab `window.focus()` typically raises the window but may not switch tabs without user permission. This is a known browser security constraint — no workaround exists without a native helper. +Constraints: +- `window.close()` is blocked for tabs not opened by script — the new tab cannot self-close when opened via `open ` on macOS. Show a "Session already open in another tab" message instead. +- `window.focus()` on macOS raises the window but browser security policy may not switch tabs without a user gesture. Best-effort — no workaround without a native helper. -## CLI Integration +### macOS URL scheme — future path -`webtty go ` behavior is unchanged at the CLI level. The focus-existing-tab logic is entirely client-side (browser tab), triggered by the normal `http://...` URL open. +iTerm2 registers `iterm2://` via `CFBundleURLTypes` in `Info.plist`. VS Code registers `vscode://` the same way. Both are native app bundles. -No new CLI command is introduced. The feature is transparent: `webtty go my-session` always works, and the browser handles deduplication. +webtty is an npm CLI — no `Info.plist`, no bundle. A `webtty://` scheme would require a small native shim (Electron or Swift) that registers the protocol and proxies to the local server. This would unlock notification-click → open-webtty without going through a browser URL bar. Deferred — the `http://` URL is sufficient for now. -## Server Changes +--- -None. The server already serves `/s/:id` and the WebSocket endpoint. No new endpoints are needed. +## Features -## Client Changes +| Feature | Description | ADR | Done? | +|---------|-------------|-----|-------| +| Focus existing tab | New tab loading `/s/` checks via BroadcastChannel whether that session is already open; if so, focuses the existing tab and shows a fallback UI | — | ⬜ | +| PID in session API | `GET /api/sessions` includes `pid: number \| null` per session (null before first WS connection spawns the PTY) | — | ⬜ | +| PID-based navigation | `GET /s/pid/` — server looks up the session owning that PTY PID and 302-redirects to `/s/`; 404 if no match | — | ⬜ | -Two new behaviors in `src/client/index.ts`: +### Focus existing tab — detail -### 1. Focus responder (existing tabs) +**Client changes** (`src/client/index.ts`): -On page load, register a `BroadcastChannel` listener for `webtty:focus:`: +On page load, open a `BroadcastChannel` named `webtty:focus:` and run the handshake before mounting the terminal: ``` +// 1. Post focus-request immediately on load +channel.postMessage({ type: 'focus-request', sessionId }); + +// 2. Wait up to 200ms for focus-ack from an existing tab +// → if ack received: show "Session already open in another tab" UI, skip terminal mount +// → if no ack: mount terminal normally (this is the first tab) + +// 3. Also listen for incoming focus-requests (this tab is already open) channel.onmessage = (e) => { if (e.data.type === 'focus-request') { window.focus(); @@ -77,71 +136,60 @@ channel.onmessage = (e) => { }; ``` -### 2. Focus initiator (new tab) +**Server changes**: none. -On page load, before mounting the terminal, post a focus-request and wait up to 200 ms for an ack: +### PID in session API — detail -``` -channel.postMessage({ type: 'focus-request', sessionId }); -// wait 200ms — if focus-ack received → show "already open" UI; else → mount terminal -``` +**`PtyProcess` interface** (`src/pty/types.ts`): add `pid: number`. -If ack received: display a minimal fallback UI ("Session is open in another tab") rather than mounting a second terminal to the same PTY. The tab does not auto-close (browser restriction), but the existing tab has already been focused. - -## 3rd Party Integration - -Tools like [Vibe Island](https://vibeisland.app) sit in the macOS notch and monitor AI coding agents (Claude Code, OpenCode, Gemini CLI, Cursor, etc.), sending notifications when tasks complete and letting users jump to the right terminal tab with one click. - -### How these tools work - -The integration model varies by tool: +**Backends**: +- `src/pty/node.ts`: return `pid: ptyProc.pid` +- `src/pty/bun.ts`: return `pid: proc.pid` -| Tool | Integration mechanism | -|------|-----------------------| -| Claude Code | Hook entries written to `~/.claude/settings.json`; events sent over a local Unix socket bridge | -| Cursor | Hook entries written to `~/.cursor/hooks.json`; tab focus via a bundled VSIX extension | -| Gemini CLI | Hook entries written to `~/.gemini/settings.json`; Unix socket bridge | -| **OpenCode** | **HTTP SSE event stream** — no hook injection; Vibe Island polls OpenCode's REST/SSE API directly | +**`Session`** (`src/server/session.ts`): no change to the Session struct — `pty.pid` is read directly from the PtyProcess when serializing. -webtty's position is closest to **OpenCode**: it already exposes an HTTP server with a REST API (`/api/sessions`) and WebSocket connections. No Unix socket bridge or config file injection is needed. +**`sessionToJson`**: include `pid: s.pty?.pid ?? null`. -### How a tool like Vibe Island can integrate with webtty +**API response** (updated shape): -**Discovery**: webtty advertises its running port via the same `PORT` env var / default `2346` convention. A third-party tool discovers a running webtty instance by polling `GET http://127.0.0.1:/api/sessions` — the same check the CLI uses (`isServerRunning()`). - -**Session listing**: `GET /api/sessions` returns all active sessions with `id`, `createdAt`, and `connected` (whether a WebSocket client is attached). This is sufficient for a notch UI to list running sessions. +```json +[{ "id": "main", "createdAt": 1700000000000, "connected": true, "pid": 12345 }] +``` -**Real-time session events**: The WebSocket at `/ws/` streams PTY output. A monitoring tool can connect to this endpoint to receive live output without affecting the user's terminal (multiple clients can attach to the same session; output is broadcast to all). +`pid` is `null` if the PTY has not been spawned yet (session created but no WebSocket client has connected). -**Jump to session**: To focus a specific session, the tool opens `http://127.0.0.1:/s/` in the default browser. The BroadcastChannel focus handshake (described above) handles the focus-existing-tab behavior — the existing tab comes forward, and the new navigation shows the fallback UI instead of opening a duplicate terminal. +### PID-based navigation — detail -### What webtty needs to add for this to work +**New server route** (`src/server/routes.ts`): -The REST API and WebSocket are already there. The only missing piece is the **BroadcastChannel focus handshake** (the core feature of this spec). Once that lands, third-party tools get jump-to-session for free by opening the session URL. +``` +GET /s/pid/ +``` -No new server endpoints, no config file protocol, no Unix socket bridge. +1. Parse `` as integer; return 404 if not a valid positive integer +2. Walk `sessionRegistry`, find the session where `session.pty?.pid === pid` +3. If found: 302 redirect to `/s/` +4. If not found: 404 -### macOS URL scheme (`webtty://`) — future path +This is the URL Vibe Island (or any tool) opens to jump to a webtty session by PID, without needing to know the session ID: -iTerm2 registers `iterm2://` via `CFBundleURLTypes` in its app bundle's `Info.plist`, letting any app or notification open a terminal command directly. VS Code does the same with `vscode://`. +``` +open http://127.0.0.1:2346/s/pid/12345 +``` -webtty ships as an npm CLI — there is no app bundle and therefore no `Info.plist`. A `webtty://` scheme would require an optional native helper shim (a small Electron or Swift app that registers the protocol and forwards to the local server). This is out of scope for this spec but is the natural next step for tighter OS-level notification integration. +The redirect lands on `/s/main` (or whichever session owns PID 12345), and the BroadcastChannel focus handshake brings the existing tab forward. -The `http://127.0.0.1:PORT/s/` URL is a fully functional substitute in the meantime: any tool can open it with `open ` (macOS), `xdg-open` (Linux), or `start` (Windows), and the focus handshake handles the rest. +### 3rd Party Integration -### Summary: what third-party tools need to do +With the above three features in place, tools like Vibe Island can integrate with webtty with no changes on their side beyond recognising webtty as a target: | Action | How | |--------|-----| | Discover running webtty | `GET http://127.0.0.1:2346/api/sessions` — 200 + JSON array means running | -| List sessions | Same endpoint — returns `[{ id, createdAt, connected }]` | +| List sessions with PIDs | Same endpoint — returns `[{ id, createdAt, connected, pid }]` | | Watch session output | WebSocket `ws://127.0.0.1:2346/ws/?cols=80&rows=24` | -| Jump to session | `open http://127.0.0.1:2346/s/` — BroadcastChannel focuses existing tab | +| Jump by session ID | `open http://127.0.0.1:2346/s/` | +| Jump by PTY PID | `open http://127.0.0.1:2346/s/pid/` — server redirects to the right session | | Custom port | Respect `PORT` env var; default `2346` | -## Features - -| Feature | Description | ADR | Done? | -|---------|-------------|-----|-------| -| Focus existing tab | When `webtty go ` opens a URL for an already-open session tab, the existing tab is focused and the new tab shows a fallback UI | — | ⬜ | -| 3rd party integration | Tools like Vibe Island can discover, monitor, and jump to webtty sessions via the existing REST API + BroadcastChannel focus handshake | — | ⬜ | +**No Unix socket bridge, no config file injection, no hook setup.** The REST API + PID-based redirect + BroadcastChannel focus is the complete integration surface. From e508a3e74ebd959ba82d8796e6252aef4864a3c8 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 5 Apr 2026 18:15:17 -0400 Subject: [PATCH 04/15] =?UTF-8?q?docs:=20fix=20port=20discovery=20note=20?= =?UTF-8?q?=E2=80=94=20opencode=20server=20is=20always-on,=20not=20a=20sep?= =?UTF-8?q?arate=20serve=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/specs/deep-link.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/deep-link.md b/docs/specs/deep-link.md index 2bf3b1a..4e35466 100644 --- a/docs/specs/deep-link.md +++ b/docs/specs/deep-link.md @@ -87,7 +87,7 @@ It just needs to be surfaced through `PtyProcess`, `Session`, and `sessionToJson ### Port discovery -Vibe Island discovers running OpenCode instances via "multi-layer port discovery" (their phrasing). The most likely mechanism: scan common ports + read the process list (`ps aux | grep opencode serve --port`). webtty uses a fixed default port (`2346`) and respects `PORT` env var — the same convention is discoverable by the same scan. +Vibe Island discovers running OpenCode instances via "multi-layer port discovery" (their phrasing). OpenCode's HTTP server starts automatically as part of normal operation — there is no separate `serve` command to opt into. The exact discovery mechanism is not published, but the likeliest approach is trying a known default port then falling back to a port range scan. webtty uses a fixed default port (`2346`) and respects the `PORT` env var — the same convention works with port-scan-based discovery. ### BroadcastChannel (focus-existing-tab) From ba5b0094f17b7e985ab1526e94fa70e7cd23d116 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 5 Apr 2026 18:20:17 -0400 Subject: [PATCH 05/15] docs: rename PID route from /s/pid/ to /p/ --- docs/specs/deep-link.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/specs/deep-link.md b/docs/specs/deep-link.md index 4e35466..ad412c5 100644 --- a/docs/specs/deep-link.md +++ b/docs/specs/deep-link.md @@ -19,7 +19,7 @@ Two related problems: |------|--------| | Client | BroadcastChannel focus handshake — focus existing tab instead of opening a duplicate | | Server API | Expose `pid` in `GET /api/sessions` response | -| Server routing | `GET /s/pid/` — redirect to the session that owns that PTY PID | +| Server routing | `GET /p/` — redirect to the session that owns that PTY PID | ### Out of scope @@ -111,7 +111,7 @@ webtty is an npm CLI — no `Info.plist`, no bundle. A `webtty://` scheme would |---------|-------------|-----|-------| | Focus existing tab | New tab loading `/s/` checks via BroadcastChannel whether that session is already open; if so, focuses the existing tab and shows a fallback UI | — | ⬜ | | PID in session API | `GET /api/sessions` includes `pid: number \| null` per session (null before first WS connection spawns the PTY) | — | ⬜ | -| PID-based navigation | `GET /s/pid/` — server looks up the session owning that PTY PID and 302-redirects to `/s/`; 404 if no match | — | ⬜ | +| PID-based navigation | `GET /p/` — server looks up the session owning that PTY PID and 302-redirects to `/s/`; 404 if no match | — | ⬜ | ### Focus existing tab — detail @@ -163,7 +163,7 @@ channel.onmessage = (e) => { **New server route** (`src/server/routes.ts`): ``` -GET /s/pid/ +GET /p/ ``` 1. Parse `` as integer; return 404 if not a valid positive integer @@ -174,7 +174,7 @@ GET /s/pid/ This is the URL Vibe Island (or any tool) opens to jump to a webtty session by PID, without needing to know the session ID: ``` -open http://127.0.0.1:2346/s/pid/12345 +open http://127.0.0.1:2346/p/12345 ``` The redirect lands on `/s/main` (or whichever session owns PID 12345), and the BroadcastChannel focus handshake brings the existing tab forward. @@ -189,7 +189,7 @@ With the above three features in place, tools like Vibe Island can integrate wit | List sessions with PIDs | Same endpoint — returns `[{ id, createdAt, connected, pid }]` | | Watch session output | WebSocket `ws://127.0.0.1:2346/ws/?cols=80&rows=24` | | Jump by session ID | `open http://127.0.0.1:2346/s/` | -| Jump by PTY PID | `open http://127.0.0.1:2346/s/pid/` — server redirects to the right session | +| Jump by PTY PID | `open http://127.0.0.1:2346/p/` — server redirects to the right session | | Custom port | Respect `PORT` env var; default `2346` | **No Unix socket bridge, no config file injection, no hook setup.** The REST API + PID-based redirect + BroadcastChannel focus is the complete integration surface. From c56d96d4a2d35fdfb5d0143235aed9e430db9d28 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 5 Apr 2026 18:27:04 -0400 Subject: [PATCH 06/15] docs: /p/ renders directly, no 302 redirect --- docs/specs/deep-link.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/specs/deep-link.md b/docs/specs/deep-link.md index ad412c5..fcb4630 100644 --- a/docs/specs/deep-link.md +++ b/docs/specs/deep-link.md @@ -111,7 +111,7 @@ webtty is an npm CLI — no `Info.plist`, no bundle. A `webtty://` scheme would |---------|-------------|-----|-------| | Focus existing tab | New tab loading `/s/` checks via BroadcastChannel whether that session is already open; if so, focuses the existing tab and shows a fallback UI | — | ⬜ | | PID in session API | `GET /api/sessions` includes `pid: number \| null` per session (null before first WS connection spawns the PTY) | — | ⬜ | -| PID-based navigation | `GET /p/` — server looks up the session owning that PTY PID and 302-redirects to `/s/`; 404 if no match | — | ⬜ | +| PID-based navigation | `GET /p/` — server resolves the PTY PID to a session and renders the terminal page directly (same as `/s/`); 404 if no match | — | ⬜ | ### Focus existing tab — detail @@ -168,17 +168,17 @@ GET /p/ 1. Parse `` as integer; return 404 if not a valid positive integer 2. Walk `sessionRegistry`, find the session where `session.pty?.pid === pid` -3. If found: 302 redirect to `/s/` +3. If found: render the terminal page directly with the resolved session ID — same handler as `/s/`, no redirect 4. If not found: 404 -This is the URL Vibe Island (or any tool) opens to jump to a webtty session by PID, without needing to know the session ID: +No 302 redirect. Rendering directly means one round-trip instead of two, the address bar stays at `/p/` (unambiguous — the user arrived here by PID), and the BroadcastChannel handshake fires immediately with the resolved session ID passed through to the client. The existing tab for that session gets focused either way since the channel is keyed on session ID, not the URL path. + +This is the URL Vibe Island (or any tool) opens to jump to a webtty session by PID: ``` open http://127.0.0.1:2346/p/12345 ``` -The redirect lands on `/s/main` (or whichever session owns PID 12345), and the BroadcastChannel focus handshake brings the existing tab forward. - ### 3rd Party Integration With the above three features in place, tools like Vibe Island can integrate with webtty with no changes on their side beyond recognising webtty as a target: @@ -189,7 +189,7 @@ With the above three features in place, tools like Vibe Island can integrate wit | List sessions with PIDs | Same endpoint — returns `[{ id, createdAt, connected, pid }]` | | Watch session output | WebSocket `ws://127.0.0.1:2346/ws/?cols=80&rows=24` | | Jump by session ID | `open http://127.0.0.1:2346/s/` | -| Jump by PTY PID | `open http://127.0.0.1:2346/p/` — server redirects to the right session | +| Jump by PTY PID | `open http://127.0.0.1:2346/p/` — server renders terminal directly for the matching session | | Custom port | Respect `PORT` env var; default `2346` | -**No Unix socket bridge, no config file injection, no hook setup.** The REST API + PID-based redirect + BroadcastChannel focus is the complete integration surface. +**No Unix socket bridge, no config file injection, no hook setup.** The REST API + PID-based navigation + BroadcastChannel focus is the complete integration surface. From ec3de4fde261b08369f9370be6a2a9641f7e74e0 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 5 Apr 2026 19:04:10 -0400 Subject: [PATCH 07/15] feat: expose pid on PtyProcess interface Add pid: number to PtyProcess, declare it in node-pty.d.ts, and implement in both Bun and node-pty backends. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/pty/bun.ts | 1 + src/pty/node-pty.d.ts | 1 + src/pty/node.ts | 1 + src/pty/types.ts | 2 ++ 4 files changed, 5 insertions(+) diff --git a/src/pty/bun.ts b/src/pty/bun.ts index 7330c1a..cf5ced7 100644 --- a/src/pty/bun.ts +++ b/src/pty/bun.ts @@ -38,6 +38,7 @@ export function spawn( }); return { + pid: proc.pid, onData(cb) { onDataCb = cb; }, diff --git a/src/pty/node-pty.d.ts b/src/pty/node-pty.d.ts index 9ae325b..0abe9ff 100644 --- a/src/pty/node-pty.d.ts +++ b/src/pty/node-pty.d.ts @@ -15,6 +15,7 @@ */ declare module '@lydell/node-pty' { interface IPty { + pid: number; onData(cb: (data: string) => void): void; onExit(cb: (e: { exitCode: number; signal?: number }) => void): void; write(data: string): void; diff --git a/src/pty/node.ts b/src/pty/node.ts index f6c0ebd..f5fe706 100644 --- a/src/pty/node.ts +++ b/src/pty/node.ts @@ -28,6 +28,7 @@ export function spawn( }); return { + pid: ptyProc.pid, onData(cb) { ptyProc.onData(cb); }, diff --git a/src/pty/types.ts b/src/pty/types.ts index 1aefa7e..739353a 100644 --- a/src/pty/types.ts +++ b/src/pty/types.ts @@ -1,5 +1,7 @@ /** Minimal abstraction over a running PTY process. Implemented by both the Bun and node-pty backends. */ export interface PtyProcess { + /** OS process ID of the shell spawned inside the PTY. */ + pid: number; /** Register a callback that receives raw UTF-8 output from the PTY. */ onData(cb: (data: string) => void): void; /** Register a callback invoked when the child process exits. */ From 1f9b05475fe14e2a44945312a3f2b376e5fb79a7 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 5 Apr 2026 19:04:20 -0400 Subject: [PATCH 08/15] feat: include pid in GET /api/sessions response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sessionToJson now returns pid: number | null — the OS PID if the PTY is live, null before first WS connection spawns the process. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/server/session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/session.ts b/src/server/session.ts index 466b8a2..15bf197 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -74,5 +74,5 @@ export function createSession(id: string): Session { * @returns A JSON-safe object with session ID, creation timestamp, and connection status. */ export function sessionToJson(s: Session) { - return { id: s.id, createdAt: s.createdAt, connected: s.clients.size > 0 }; + return { id: s.id, createdAt: s.createdAt, connected: s.clients.size > 0, pid: s.pty?.pid ?? null }; } From 91fdaec24b95af2285082f8c2535a3dc5267e82c Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 5 Apr 2026 19:04:31 -0400 Subject: [PATCH 09/15] feat: add GET /p/ route for PID-based session navigation Resolves a PTY PID to its session and issues a 302 to /s/. Returns 404 for unknown or invalid PIDs. Enables external tools (e.g. Vibe Island) to jump to a webtty session by OS PID. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/server/routes.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/server/routes.ts b/src/server/routes.ts index 4def51a..3da4a41 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -244,6 +244,25 @@ export async function handleRequest( return; } + const pidMatch = pathname.match(/^\/p\/(\d+)$/); + if (req.method === 'GET' && pidMatch) { + const pid = parseInt(pidMatch[1], 10); + if (!Number.isFinite(pid) || pid <= 0) { + res.writeHead(404); + res.end('Not Found'); + return; + } + const session = [...sessionRegistry.values()].find((s) => s.pty?.pid === pid); + if (!session) { + res.writeHead(404); + res.end('Not Found'); + return; + } + res.writeHead(302, { Location: `/s/${session.id}` }); + res.end(); + return; + } + if (pathname.startsWith('/dist/')) { const relativePath = pathname.slice(6); const ownFile = path.resolve(clientDistPath, relativePath); From 81e0f68f517bd403c923a133820b410d52afe9da Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 5 Apr 2026 19:04:41 -0400 Subject: [PATCH 10/15] feat: focus existing tab via BroadcastChannel on session load On load, the client posts a focus-request on webtty:focus:. If an existing tab acks within 200ms, the new tab shows a fallback UI instead of mounting a second terminal. The primary tab listens for future focus-requests and brings itself to the front. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/client/index.ts | 458 ++++++++++++++++++++++++-------------------- 1 file changed, 245 insertions(+), 213 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 8cef13b..aedeba8 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -46,243 +46,275 @@ interface ClientConfig { } const sessionId = window.location.pathname.split('/s/')[1] ?? 'main'; -const config: ClientConfig = await fetch('/api/config').then((r) => r.json()); -document.title = `${sessionId} | webtty`; - -await init(); - -const term = new Terminal({ - cols: config.cols, - rows: config.rows, - cursorStyle: config.cursorStyle, - cursorBlink: config.cursorStyleBlink, - fontSize: config.fontSize, - fontFamily: config.fontFamily, - scrollback: Math.ceil(config.scrollback / 80), - theme: config.theme, +// BroadcastChannel focus handshake — focus existing tab instead of mounting a duplicate. +// Post a focus-request; if an existing tab acks within 200ms, show a fallback UI instead +// of mounting a second terminal to the same PTY. See deep-link spec. +const focusChannel = new BroadcastChannel(`webtty:focus:${sessionId}`); +const isPrimary = await new Promise((resolve) => { + const timeout = setTimeout(() => resolve(true), 200); + focusChannel.onmessage = (e: MessageEvent) => { + if (e.data.type === 'focus-ack') { + clearTimeout(timeout); + resolve(false); + } else if (e.data.type === 'focus-request') { + window.focus(); + focusChannel.postMessage({ type: 'focus-ack' }); + } + }; + focusChannel.postMessage({ type: 'focus-request', sessionId }); }); -const fitAddon = new FitAddon(); -term.loadAddon(fitAddon); +if (!isPrimary) { + (document.getElementById('terminal') as HTMLElement).textContent = + 'Session already open in another tab.'; +} else { + // This is the primary tab — respond to focus-requests from future tabs. + focusChannel.onmessage = (e: MessageEvent) => { + if (e.data.type === 'focus-request') { + window.focus(); + focusChannel.postMessage({ type: 'focus-ack' }); + } + }; -const container = document.getElementById('terminal') as HTMLElement; -if (config.theme?.background) { - container.style.background = config.theme.background; -} -await term.open(container); + const config: ClientConfig = await fetch('/api/config').then((r) => r.json()); -// 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`; -} + document.title = `${sessionId} | webtty`; -fit(); -new ResizeObserver(() => fit()).observe(container, { box: 'border-box' }); + await init(); -const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; -let ws: WebSocket; + const term = new Terminal({ + cols: config.cols, + rows: config.rows, + cursorStyle: config.cursorStyle, + cursorBlink: config.cursorStyleBlink, + fontSize: config.fontSize, + fontFamily: config.fontFamily, + scrollback: Math.ceil(config.scrollback / 80), + theme: config.theme, + }); -function connect(): void { - const wsUrl = `${protocol}//${window.location.host}/ws/${sessionId}?cols=${term.cols}&rows=${term.rows}`; - ws = new WebSocket(wsUrl); + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); - const DIM = '\x1b[2m', - YELLOW = '\x1b[1;33m', - ITALIC = '\x1b[3m', - RESET = '\x1b[0m'; - const tag = `${DIM}[${RESET} ${YELLOW}webtty${RESET} ${DIM}]${RESET}`; - const msg = (text: string): string => `\r\n${tag} ${DIM}${ITALIC}${text}${RESET}\r\n`; + const container = document.getElementById('terminal') as HTMLElement; + if (config.theme?.background) { + container.style.background = config.theme.background; + } + await term.open(container); - ws.onopen = () => { - ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); - }; + // 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`; + } - ws.onmessage = (event: MessageEvent) => { - applyDecscusr(term, event.data); - term.write(event.data); - }; + fit(); + new ResizeObserver(() => fit()).observe(container, { box: 'border-box' }); - ws.onclose = (event: CloseEvent) => { - if (event.code === 4001) { - term.write(msg('Session removed.')); - setTimeout(() => window.close(), 500); - return; - } - if (event.code === 1001) { - term.write(msg('Server stopped.')); - setTimeout(() => window.close(), 500); - return; - } - term.write(msg('Connection lost. Reconnecting in 2s...')); - setTimeout(connect, 2000); - }; + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + let ws: WebSocket; - ws.onerror = () => { - term.write(msg('WebSocket error.')); - }; -} + function connect(): void { + const wsUrl = `${protocol}//${window.location.host}/ws/${sessionId}?cols=${term.cols}&rows=${term.rows}`; + ws = new WebSocket(wsUrl); -connect(); + const DIM = '\x1b[2m', + YELLOW = '\x1b[1;33m', + ITALIC = '\x1b[3m', + RESET = '\x1b[0m'; + const tag = `${DIM}[${RESET} ${YELLOW}webtty${RESET} ${DIM}]${RESET}`; + const msg = (text: string): string => `\r\n${tag} ${DIM}${ITALIC}${text}${RESET}\r\n`; -// ghostty-web's Terminal.handleWheel sends \x1b[A/\x1b[B (arrow keys) on the -// alternate screen regardless of mouse tracking state, moving the cursor instead -// of scrolling. When the PTY application has enabled mouse tracking (e.g. vim -// with `set mouse=a`), intercept wheel events and send the correct SGR mouse -// scroll sequence so the app receives a scroll event, not a cursor move. -// SGR button 64 = scroll up, 65 = scroll down. See ADR 017. -// -// config.mouseScrollSpeed scales SGR events per wheel tick (default 1). -// Values < 1 reduce rate via accumulation; values > 1 send multiple SGRs. -// The accumulator resets on direction change to prevent cross-direction bleed. -let scrollAccum = 0; -let scrollDir = 0; -term.attachCustomWheelEventHandler((e: WheelEvent): boolean => { - if (!term.hasMouseTracking()) return false; - const metrics = term.renderer?.getMetrics(); - if (!metrics) return false; - const dir = e.deltaY < 0 ? -1 : 1; - if (dir !== scrollDir) { - scrollAccum = 0; - scrollDir = dir; - } - scrollAccum += config.mouseScrollSpeed; - const ticks = Math.trunc(scrollAccum); - if (ticks === 0) return true; - scrollAccum -= ticks; - const rect = (e.target as HTMLElement).getBoundingClientRect(); - const col = Math.max(1, Math.floor((e.clientX - rect.left) / metrics.width) + 1); - const row = Math.max(1, Math.floor((e.clientY - rect.top) / metrics.height) + 1); - const btn = dir < 0 ? 64 : 65; - const seq = `\x1b[<${btn};${col};${row}M`; - if (ws && ws.readyState === WebSocket.OPEN) { - for (let i = 0; i < ticks; i++) ws.send(seq); - } - return true; -}); + ws.onopen = () => { + ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); + }; -// Intercept configured key+mods combos before ghostty-web sees them and send -// the bound chars directly to the PTY. See ADR 018. -container.addEventListener( - 'keydown', - (e: KeyboardEvent) => { - const key = e.key.toLowerCase(); - const active = new Set([ - ...(e.shiftKey ? ['shift'] : []), - ...(e.ctrlKey ? ['ctrl'] : []), - ...(e.altKey ? ['alt'] : []), - ...(e.metaKey ? ['meta'] : []), - ]); - const binding = config.keyboardBindings.find((b) => { - if (b.key.toLowerCase() !== key) return false; - const required = new Set((Array.isArray(b.mods) ? b.mods : []).map((m) => m.toLowerCase())); - if (required.size !== active.size) return false; - for (const m of required) if (!active.has(m)) return false; - return true; - }); - if (!binding) return; - e.preventDefault(); - e.stopPropagation(); - if (binding.chars && ws.readyState === WebSocket.OPEN) { - ws.send(binding.chars); - } - }, - { capture: true }, -); + ws.onmessage = (event: MessageEvent) => { + applyDecscusr(term, event.data); + term.write(event.data); + }; -// 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. 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; - 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 (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(); - }, - { capture: true }, -); + ws.onclose = (event: CloseEvent) => { + if (event.code === 4001) { + term.write(msg('Session removed.')); + setTimeout(() => window.close(), 500); + return; + } + if (event.code === 1001) { + term.write(msg('Server stopped.')); + setTimeout(() => window.close(), 500); + return; + } + term.write(msg('Connection lost. Reconnecting in 2s...')); + setTimeout(connect, 2000); + }; -// Forward terminal keystrokes and input to the PTY over WebSocket. -term.onData((data: string) => { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(data); + ws.onerror = () => { + term.write(msg('WebSocket error.')); + }; } -}); -// Notify the server when the terminal is resized so the PTY dimensions stay in sync. -term.onResize(({ cols, rows }: { cols: number; rows: number }) => { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'resize', cols, rows })); - } -}); + connect(); -// Copy the selected text to the clipboard whenever the selection changes. -if (config.copyOnSelect) { - term.onSelectionChange(() => { - const selection = term.getSelection() as string; - if (!selection) return; - navigator.clipboard.writeText(selection).catch(() => { - /* empty */ - }); + // ghostty-web's Terminal.handleWheel sends \x1b[A/\x1b[B (arrow keys) on the + // alternate screen regardless of mouse tracking state, moving the cursor instead + // of scrolling. When the PTY application has enabled mouse tracking (e.g. vim + // with `set mouse=a`), intercept wheel events and send the correct SGR mouse + // scroll sequence so the app receives a scroll event, not a cursor move. + // SGR button 64 = scroll up, 65 = scroll down. See ADR 017. + // + // config.mouseScrollSpeed scales SGR events per wheel tick (default 1). + // Values < 1 reduce rate via accumulation; values > 1 send multiple SGRs. + // The accumulator resets on direction change to prevent cross-direction bleed. + let scrollAccum = 0; + let scrollDir = 0; + term.attachCustomWheelEventHandler((e: WheelEvent): boolean => { + if (!term.hasMouseTracking()) return false; + const metrics = term.renderer?.getMetrics(); + if (!metrics) return false; + const dir = e.deltaY < 0 ? -1 : 1; + if (dir !== scrollDir) { + scrollAccum = 0; + scrollDir = dir; + } + scrollAccum += config.mouseScrollSpeed; + const ticks = Math.trunc(scrollAccum); + if (ticks === 0) return true; + scrollAccum -= ticks; + const rect = (e.target as HTMLElement).getBoundingClientRect(); + const col = Math.max(1, Math.floor((e.clientX - rect.left) / metrics.width) + 1); + const row = Math.max(1, Math.floor((e.clientY - rect.top) / metrics.height) + 1); + const btn = dir < 0 ? 64 : 65; + const seq = `\x1b[<${btn};${col};${row}M`; + if (ws && ws.readyState === WebSocket.OPEN) { + for (let i = 0; i < ticks; i++) ws.send(seq); + } + return true; }); -} -// Copy selected text to clipboard on right-click when copyPaste mode is active. -if (config.rightClickBehavior === 'copyPaste') { - container.addEventListener('contextmenu', (e: MouseEvent) => { - const selection = term.getSelection() as string; - if (!selection) return; - e.preventDefault(); - navigator.clipboard.writeText(selection).catch(() => { - /* empty */ - }); - term.clearSelection(); + // Intercept configured key+mods combos before ghostty-web sees them and send + // the bound chars directly to the PTY. See ADR 018. + container.addEventListener( + 'keydown', + (e: KeyboardEvent) => { + const key = e.key.toLowerCase(); + const active = new Set([ + ...(e.shiftKey ? ['shift'] : []), + ...(e.ctrlKey ? ['ctrl'] : []), + ...(e.altKey ? ['alt'] : []), + ...(e.metaKey ? ['meta'] : []), + ]); + const binding = config.keyboardBindings.find((b) => { + if (b.key.toLowerCase() !== key) return false; + const required = new Set((Array.isArray(b.mods) ? b.mods : []).map((m) => m.toLowerCase())); + if (required.size !== active.size) return false; + for (const m of required) if (!active.has(m)) return false; + return true; + }); + if (!binding) return; + e.preventDefault(); + e.stopPropagation(); + if (binding.chars && ws.readyState === WebSocket.OPEN) { + ws.send(binding.chars); + } + }, + { 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. 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; + 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 (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(); + }, + { capture: true }, + ); + + // Forward terminal keystrokes and input to the PTY over WebSocket. + term.onData((data: string) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(data); + } }); -} -// ghostty-web swallows Ctrl+V without sending \x16 to the PTY (unlike -// xterm.js). When clipboard has no text/plain, its paste handler drops it -// too. Send \x16 so TUI apps can invoke their native OS clipboard read. -// See ADR 014. -container.addEventListener( - 'paste', - (e: ClipboardEvent) => { - const cd = e.clipboardData; - if (!cd) return; - if (cd.getData('text/plain')) return; - e.preventDefault(); - e.stopImmediatePropagation(); + // Notify the server when the terminal is resized so the PTY dimensions stay in sync. + term.onResize(({ cols, rows }: { cols: number; rows: number }) => { if (ws && ws.readyState === WebSocket.OPEN) { - ws.send('\x16'); + ws.send(JSON.stringify({ type: 'resize', cols, rows })); } - }, - { capture: true }, -); + }); + + // Copy the selected text to the clipboard whenever the selection changes. + if (config.copyOnSelect) { + term.onSelectionChange(() => { + const selection = term.getSelection() as string; + if (!selection) return; + navigator.clipboard.writeText(selection).catch(() => { + /* empty */ + }); + }); + } + + // Copy selected text to clipboard on right-click when copyPaste mode is active. + if (config.rightClickBehavior === 'copyPaste') { + container.addEventListener('contextmenu', (e: MouseEvent) => { + const selection = term.getSelection() as string; + if (!selection) return; + e.preventDefault(); + navigator.clipboard.writeText(selection).catch(() => { + /* empty */ + }); + term.clearSelection(); + }); + } + + // ghostty-web swallows Ctrl+V without sending \x16 to the PTY (unlike + // xterm.js). When clipboard has no text/plain, its paste handler drops it + // too. Send \x16 so TUI apps can invoke their native OS clipboard read. + // See ADR 014. + container.addEventListener( + 'paste', + (e: ClipboardEvent) => { + const cd = e.clipboardData; + if (!cd) return; + if (cd.getData('text/plain')) return; + e.preventDefault(); + e.stopImmediatePropagation(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send('\x16'); + } + }, + { capture: true }, + ); +} From 06032c686d3429090247df5a1af378b4fda84a9d Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 5 Apr 2026 19:04:51 -0400 Subject: [PATCH 11/15] docs: mark deep-link features as complete in spec Focus existing tab, PID in session API, and PID-based navigation are now implemented. Also corrects /p/ behaviour to 302 redirect rather than direct render. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- docs/specs/deep-link.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/specs/deep-link.md b/docs/specs/deep-link.md index fcb4630..508f263 100644 --- a/docs/specs/deep-link.md +++ b/docs/specs/deep-link.md @@ -109,9 +109,9 @@ webtty is an npm CLI — no `Info.plist`, no bundle. A `webtty://` scheme would | Feature | Description | ADR | Done? | |---------|-------------|-----|-------| -| Focus existing tab | New tab loading `/s/` checks via BroadcastChannel whether that session is already open; if so, focuses the existing tab and shows a fallback UI | — | ⬜ | -| PID in session API | `GET /api/sessions` includes `pid: number \| null` per session (null before first WS connection spawns the PTY) | — | ⬜ | -| PID-based navigation | `GET /p/` — server resolves the PTY PID to a session and renders the terminal page directly (same as `/s/`); 404 if no match | — | ⬜ | +| Focus existing tab | New tab loading `/s/` checks via BroadcastChannel whether that session is already open; if so, focuses the existing tab and shows a fallback UI | — | ✅ | +| PID in session API | `GET /api/sessions` includes `pid: number \| null` per session (null before first WS connection spawns the PTY) | — | ✅ | +| PID-based navigation | `GET /p/` — server resolves the PTY PID to a session and renders the terminal page directly (same as `/s/`); 404 if no match | — | ✅ | ### Focus existing tab — detail @@ -168,11 +168,9 @@ GET /p/ 1. Parse `` as integer; return 404 if not a valid positive integer 2. Walk `sessionRegistry`, find the session where `session.pty?.pid === pid` -3. If found: render the terminal page directly with the resolved session ID — same handler as `/s/`, no redirect +3. If found: `302 Location: /s/` — browser lands at the canonical session URL 4. If not found: 404 -No 302 redirect. Rendering directly means one round-trip instead of two, the address bar stays at `/p/` (unambiguous — the user arrived here by PID), and the BroadcastChannel handshake fires immediately with the resolved session ID passed through to the client. The existing tab for that session gets focused either way since the channel is keyed on session ID, not the URL path. - This is the URL Vibe Island (or any tool) opens to jump to a webtty session by PID: ``` From 4372edc058c8165c8330600dff241d970ef23659 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 5 Apr 2026 19:08:20 -0400 Subject: [PATCH 12/15] fix: format sessionToJson return and add test coverage for deep-link features - Expand sessionToJson multi-line to satisfy biome formatter - Test pid null/set in sessionToJson unit tests - Test GET /api/sessions includes pid field - Test GET /p/:pid returns 404 for unknown and non-numeric PIDs - Test GET /p/:pid redirects 302 to /s/ after PTY spawns Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/server/routes.test.ts | 19 +++++++++++++++++++ src/server/session.test.ts | 11 +++++++++++ src/server/session.ts | 7 ++++++- src/server/websocket.test.ts | 24 ++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index d78c05a..496e488 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -89,6 +89,15 @@ describe('server — routes', () => { expect(Array.isArray(body)).toBe(true); }); + test('GET /api/sessions includes pid field (null before PTY spawns)', async () => { + const res = await fetch(`${baseUrl}/api/sessions`); + const body = (await res.json()) as Array<{ id: string; pid: number | null }>; + expect(body.length).toBeGreaterThan(0); + for (const s of body) { + expect('pid' in s).toBe(true); + } + }); + test('POST /api/sessions creates session with given id', async () => { const res = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', @@ -176,6 +185,16 @@ describe('server — routes', () => { expect(res.status).toBe(404); }); + test('GET /p/:pid returns 404 for unknown pid', async () => { + const res = await fetch(`${baseUrl}/p/99999999`, { redirect: 'manual' }); + expect(res.status).toBe(404); + }); + + test('GET /p/:pid returns 404 for non-numeric pid', async () => { + const res = await fetch(`${baseUrl}/p/notanumber`, { redirect: 'manual' }); + expect(res.status).toBe(404); + }); + test('POST /api/server/stop returns 200 and stops server', async () => { const res = await fetch(`${baseUrl}/api/server/stop`, { method: 'POST' }); expect(res.status).toBe(200); diff --git a/src/server/session.test.ts b/src/server/session.test.ts index 4405594..9fa9077 100644 --- a/src/server/session.test.ts +++ b/src/server/session.test.ts @@ -90,6 +90,17 @@ describe('sessionToJson', () => { expect(json.id).toBe('test'); expect(typeof json.createdAt).toBe('number'); }); + + test('pid is null when pty is not yet spawned', () => { + const session = createSession('test-pid-null'); + expect(sessionToJson(session).pid).toBeNull(); + }); + + test('pid reflects pty pid when pty is set', () => { + const session = createSession('test-pid-set'); + session.pty = { pid: 12345 } as never; + expect(sessionToJson(session).pid).toBe(12345); + }); }); describe('setLastUsedId', () => { diff --git a/src/server/session.ts b/src/server/session.ts index 15bf197..f92f208 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -74,5 +74,10 @@ export function createSession(id: string): Session { * @returns A JSON-safe object with session ID, creation timestamp, and connection status. */ export function sessionToJson(s: Session) { - return { id: s.id, createdAt: s.createdAt, connected: s.clients.size > 0, pid: s.pty?.pid ?? null }; + return { + id: s.id, + createdAt: s.createdAt, + connected: s.clients.size > 0, + pid: s.pty?.pid ?? null, + }; } diff --git a/src/server/websocket.test.ts b/src/server/websocket.test.ts index fd60510..2917fd4 100644 --- a/src/server/websocket.test.ts +++ b/src/server/websocket.test.ts @@ -204,6 +204,30 @@ describe('websocket', () => { expect(messages.join('')).toContain('resize-ok'); }); + test('GET /p/:pid redirects to session URL after PTY spawns', async () => { + await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'ws-test-pid-route' }), + }); + + const { ws, messages } = await connectWs(`${wsBase}/ws/ws-test-pid-route?cols=80&rows=24`); + await waitForMessages(messages, 1); + await closeWs(ws); + + const sessions = (await fetch(`${baseUrl}/api/sessions`).then((r) => r.json())) as Array<{ + id: string; + pid: number | null; + }>; + const session = sessions.find((s) => s.id === 'ws-test-pid-route'); + expect(session).toBeDefined(); + expect(typeof session!.pid).toBe('number'); + + const res = await fetch(`${baseUrl}/p/${session!.pid}`, { redirect: 'manual' }); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe('/s/ws-test-pid-route'); + }); + test('server shuts down when last session exits', async () => { await fetch(`${baseUrl}/api/sessions`, { method: 'POST', From 6548eb2be27b336b1d546c7a5c4c118aa9106a8d Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 5 Apr 2026 20:44:47 -0400 Subject: [PATCH 13/15] =?UTF-8?q?fix:=20address=20PR=20review=20comments?= =?UTF-8?q?=20=E2=80=94=20BroadcastChannel=20hardening=20and=20doc=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix race condition: remove focus-request handler from handshake promise so only an established primary tab can reply with focus-ack - Add BroadcastChannel feature detection with isPrimary=true fallback for environments that don't support it (older Safari, hardened webviews) - Add e.data runtime guard (null/object/string checks) before branching on message type in both handshake and primary listeners - Update sessionToJson @returns docstring to mention pid field - Fix spec inconsistency: 'renders directly' -> '302 Location: /s/' in both the features table and 3rd party integration table --- docs/specs/deep-link.md | 4 +-- src/client/index.ts | 60 +++++++++++++++++++++++++++-------------- src/server/session.ts | 2 +- 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/docs/specs/deep-link.md b/docs/specs/deep-link.md index 508f263..4a8fe3e 100644 --- a/docs/specs/deep-link.md +++ b/docs/specs/deep-link.md @@ -111,7 +111,7 @@ webtty is an npm CLI — no `Info.plist`, no bundle. A `webtty://` scheme would |---------|-------------|-----|-------| | Focus existing tab | New tab loading `/s/` checks via BroadcastChannel whether that session is already open; if so, focuses the existing tab and shows a fallback UI | — | ✅ | | PID in session API | `GET /api/sessions` includes `pid: number \| null` per session (null before first WS connection spawns the PTY) | — | ✅ | -| PID-based navigation | `GET /p/` — server resolves the PTY PID to a session and renders the terminal page directly (same as `/s/`); 404 if no match | — | ✅ | +| PID-based navigation | `GET /p/` — server resolves the PTY PID to a session and responds with `302 Location: /s/`; 404 if no match | — | ✅ | ### Focus existing tab — detail @@ -187,7 +187,7 @@ With the above three features in place, tools like Vibe Island can integrate wit | List sessions with PIDs | Same endpoint — returns `[{ id, createdAt, connected, pid }]` | | Watch session output | WebSocket `ws://127.0.0.1:2346/ws/?cols=80&rows=24` | | Jump by session ID | `open http://127.0.0.1:2346/s/` | -| Jump by PTY PID | `open http://127.0.0.1:2346/p/` — server renders terminal directly for the matching session | +| Jump by PTY PID | `open http://127.0.0.1:2346/p/` — server responds with `302 Location: /s/` for the matching session | | Custom port | Respect `PORT` env var; default `2346` | **No Unix socket bridge, no config file injection, no hook setup.** The REST API + PID-based navigation + BroadcastChannel focus is the complete integration surface. diff --git a/src/client/index.ts b/src/client/index.ts index aedeba8..7dfb7b8 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -50,32 +50,52 @@ const sessionId = window.location.pathname.split('/s/')[1] ?? 'main'; // BroadcastChannel focus handshake — focus existing tab instead of mounting a duplicate. // Post a focus-request; if an existing tab acks within 200ms, show a fallback UI instead // of mounting a second terminal to the same PTY. See deep-link spec. -const focusChannel = new BroadcastChannel(`webtty:focus:${sessionId}`); -const isPrimary = await new Promise((resolve) => { - const timeout = setTimeout(() => resolve(true), 200); - focusChannel.onmessage = (e: MessageEvent) => { - if (e.data.type === 'focus-ack') { - clearTimeout(timeout); - resolve(false); - } else if (e.data.type === 'focus-request') { - window.focus(); - focusChannel.postMessage({ type: 'focus-ack' }); - } - }; - focusChannel.postMessage({ type: 'focus-request', sessionId }); -}); +// +// Only an already-established primary tab replies with focus-ack, so two tabs opened +// simultaneously cannot both resolve isPrimary=false. BroadcastChannel is feature-detected +// to fall back gracefully in environments that don't support it (e.g. older Safari). +let isPrimary = true; +let focusChannel: BroadcastChannel | null = null; + +if (typeof window.BroadcastChannel === 'function') { + focusChannel = new BroadcastChannel(`webtty:focus:${sessionId}`); + isPrimary = await new Promise((resolve) => { + const timeout = setTimeout(() => resolve(true), 200); + focusChannel!.onmessage = (e: MessageEvent) => { + if ( + e.data !== null && + typeof e.data === 'object' && + typeof e.data.type === 'string' && + e.data.type === 'focus-ack' + ) { + clearTimeout(timeout); + resolve(false); + } + // Do NOT respond to focus-request here — only an established primary does that. + // Responding during the handshake would let two simultaneous tabs ack each other. + }; + focusChannel!.postMessage({ type: 'focus-request', sessionId }); + }); +} if (!isPrimary) { (document.getElementById('terminal') as HTMLElement).textContent = 'Session already open in another tab.'; } else { // This is the primary tab — respond to focus-requests from future tabs. - focusChannel.onmessage = (e: MessageEvent) => { - if (e.data.type === 'focus-request') { - window.focus(); - focusChannel.postMessage({ type: 'focus-ack' }); - } - }; + if (focusChannel) { + focusChannel.onmessage = (e: MessageEvent) => { + if ( + e.data !== null && + typeof e.data === 'object' && + typeof e.data.type === 'string' && + e.data.type === 'focus-request' + ) { + window.focus(); + focusChannel!.postMessage({ type: 'focus-ack' }); + } + }; + } const config: ClientConfig = await fetch('/api/config').then((r) => r.json()); diff --git a/src/server/session.ts b/src/server/session.ts index f92f208..6fabaff 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -71,7 +71,7 @@ export function createSession(id: string): Session { * Returns a plain JSON-safe representation of a session for API responses. * * @param s - The session to serialize. - * @returns A JSON-safe object with session ID, creation timestamp, and connection status. + * @returns A JSON-safe object with session ID, creation timestamp, connection status, and PTY PID (or `null` if no PTY has been spawned yet). */ export function sessionToJson(s: Session) { return { From f7dd35ec9d9522ae8b76e1b10ac71d0ad4e46f16 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 5 Apr 2026 21:24:47 -0400 Subject: [PATCH 14/15] revert: drop BroadcastChannel focus handshake; add ADR 024 window.focus() cannot switch tabs on macOS without a user gesture, and window.close() is blocked for tabs not opened via window.open(). The handshake produced worse UX (dead-end fallback UI) than doing nothing. webtty go opens a new tab unconditionally as before. - Remove BroadcastChannel code from src/client/index.ts - Update deep-link spec to mark focus-existing-tab as dropped - Add ADR 024 documenting the constraints and decision --- docs/adrs/024.client.focus-existing-tab.md | 61 +++ docs/specs/deep-link.md | 33 +- src/client/index.ts | 477 +++++++++------------ 3 files changed, 281 insertions(+), 290 deletions(-) create mode 100644 docs/adrs/024.client.focus-existing-tab.md diff --git a/docs/adrs/024.client.focus-existing-tab.md b/docs/adrs/024.client.focus-existing-tab.md new file mode 100644 index 0000000..db437af --- /dev/null +++ b/docs/adrs/024.client.focus-existing-tab.md @@ -0,0 +1,61 @@ +# ADR 024: Client — Focus existing tab via BroadcastChannel (dropped) + +**SPEC:** [deep-link](../specs/deep-link.md) +**Status:** Rejected +**Date:** 2026-04-06 + +--- + +## Context + +`webtty go ` opens a new browser tab every time. If a tab for that session is already open, the user ends up with two tabs attached to the same PTY. The goal was to detect the duplicate and focus the existing tab instead. + +The only same-origin mechanism available without a native helper is `BroadcastChannel`: the new tab posts a `focus-request`; the existing tab calls `window.focus()` and replies with `focus-ack`; the new tab skips mounting a terminal and shows a fallback UI. + +--- + +## Decision + +Do not implement BroadcastChannel focus handshake. `webtty go ` continues to open a new browser tab unconditionally. + +--- + +## Reasons + +### `window.focus()` cannot switch tabs on macOS + +Browsers block tab-switching from JavaScript without a direct user gesture. `window.focus()` raises the browser *window* to the front, but it does not switch to the tab that called it. The existing tab remains unfocused. The user still has to manually find and click it. + +### `window.close()` is blocked for non-script-opened tabs + +The new (duplicate) tab could show a "Session already open" message and close itself. But `window.close()` is only permitted for windows opened via `window.open()`. Tabs opened by the OS `open` command or by the user directly cannot self-close. The fallback UI is therefore a dead end: the user sees an unhelpful message in a tab they cannot close programmatically. + +### Net result is worse UX than doing nothing + +With the handshake: two tabs open, one shows a blank "Session already open" message with no action the user can take. Without the handshake: two tabs open, both show a live terminal — the user can at least close the unwanted one manually. + +--- + +## Considered Options + +### Option A: BroadcastChannel focus handshake (rejected — described above) + +### Option B: Skip `openBrowser` if session is `connected` + +`GET /api/sessions/` returns `connected: true` when a WebSocket client is attached (i.e. a browser tab is open). The CLI could skip calling `openBrowser` and print the URL instead. + +Rejected for now — `connected` is a proxy for "tab is open" but is not exact: a session can be `connected: false` between reconnects, or `connected: true` from a programmatic WebSocket client that is not a browser tab. The heuristic would produce false negatives (no browser opened when it should be). + +### Option C: Native helper / URL scheme + +A `webtty://` URL scheme registered via a native shim (Electron or Swift) could receive the open request and route to the existing tab. This is the only technically sound solution. + +Deferred — webtty is an npm CLI with no native bundle. The infrastructure cost is not justified at this stage. + +--- + +## Consequences + +- `webtty go ` always opens a new browser tab. Duplicate tabs are the user's responsibility to close. +- The BroadcastChannel code is removed from `src/client/index.ts`. +- Future tab-focus support requires a native helper (Option C above). diff --git a/docs/specs/deep-link.md b/docs/specs/deep-link.md index 4a8fe3e..b94d6ae 100644 --- a/docs/specs/deep-link.md +++ b/docs/specs/deep-link.md @@ -1,6 +1,6 @@ # SPEC: Deep Link -**Last Updated:** 2026-04-05 (amended: reorganized, PID-based API, 3rd party integration) +**Last Updated:** 2026-04-06 (amended: focus-existing-tab dropped — see ADR 024) --- @@ -10,14 +10,14 @@ Two related problems: -1. **Duplicate tabs** — `webtty go ` opens a new browser tab every time. If the session is already open, the user ends up with two identical tabs. +1. **Duplicate tabs** — `webtty go ` opens a new browser tab every time. If the session is already open, the user ends up with two identical tabs. *(Focus-existing-tab was attempted via BroadcastChannel but dropped — see ADR 024.)* 2. **No PID-based navigation** — Third-party tools (e.g. Vibe Island) track AI agent processes by PTY shell PID, not by session name. They have no way to map a PID to a webtty session or navigate directly to it. ### What this spec covers | Area | Change | |------|--------| -| Client | BroadcastChannel focus handshake — focus existing tab instead of opening a duplicate | +| ~~Client~~ | ~~BroadcastChannel focus handshake~~ — dropped, see ADR 024 | | Server API | Expose `pid` in `GET /api/sessions` response | | Server routing | `GET /p/` — redirect to the session that owns that PTY PID | @@ -109,34 +109,15 @@ webtty is an npm CLI — no `Info.plist`, no bundle. A `webtty://` scheme would | Feature | Description | ADR | Done? | |---------|-------------|-----|-------| -| Focus existing tab | New tab loading `/s/` checks via BroadcastChannel whether that session is already open; if so, focuses the existing tab and shows a fallback UI | — | ✅ | +| ~~Focus existing tab~~ | ~~BroadcastChannel handshake~~ | [ADR 024](../adrs/024.client.focus-existing-tab.md) | ❌ dropped | | PID in session API | `GET /api/sessions` includes `pid: number \| null` per session (null before first WS connection spawns the PTY) | — | ✅ | | PID-based navigation | `GET /p/` — server resolves the PTY PID to a session and responds with `302 Location: /s/`; 404 if no match | — | ✅ | ### Focus existing tab — detail -**Client changes** (`src/client/index.ts`): +**Status: dropped. See [ADR 024](../adrs/024.client.focus-existing-tab.md).** -On page load, open a `BroadcastChannel` named `webtty:focus:` and run the handshake before mounting the terminal: - -``` -// 1. Post focus-request immediately on load -channel.postMessage({ type: 'focus-request', sessionId }); - -// 2. Wait up to 200ms for focus-ack from an existing tab -// → if ack received: show "Session already open in another tab" UI, skip terminal mount -// → if no ack: mount terminal normally (this is the first tab) - -// 3. Also listen for incoming focus-requests (this tab is already open) -channel.onmessage = (e) => { - if (e.data.type === 'focus-request') { - window.focus(); - channel.postMessage({ type: 'focus-ack' }); - } -}; -``` - -**Server changes**: none. +`window.focus()` on macOS raises the browser window but cannot switch tabs without a user gesture — a hard browser security constraint. `window.close()` is blocked for tabs not opened by script. The BroadcastChannel handshake was implemented but removed: the new tab cannot meaningfully self-close or pull focus to the existing tab. `webtty go ` continues to open a new browser tab unconditionally. ### PID in session API — detail @@ -190,4 +171,4 @@ With the above three features in place, tools like Vibe Island can integrate wit | Jump by PTY PID | `open http://127.0.0.1:2346/p/` — server responds with `302 Location: /s/` for the matching session | | Custom port | Respect `PORT` env var; default `2346` | -**No Unix socket bridge, no config file injection, no hook setup.** The REST API + PID-based navigation + BroadcastChannel focus is the complete integration surface. +**No Unix socket bridge, no config file injection, no hook setup.** The REST API + PID-based navigation is the complete integration surface. diff --git a/src/client/index.ts b/src/client/index.ts index 7dfb7b8..b5d7649 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -47,294 +47,243 @@ interface ClientConfig { const sessionId = window.location.pathname.split('/s/')[1] ?? 'main'; -// BroadcastChannel focus handshake — focus existing tab instead of mounting a duplicate. -// Post a focus-request; if an existing tab acks within 200ms, show a fallback UI instead -// of mounting a second terminal to the same PTY. See deep-link spec. -// -// Only an already-established primary tab replies with focus-ack, so two tabs opened -// simultaneously cannot both resolve isPrimary=false. BroadcastChannel is feature-detected -// to fall back gracefully in environments that don't support it (e.g. older Safari). -let isPrimary = true; -let focusChannel: BroadcastChannel | null = null; +const config: ClientConfig = await fetch('/api/config').then((r) => r.json()); -if (typeof window.BroadcastChannel === 'function') { - focusChannel = new BroadcastChannel(`webtty:focus:${sessionId}`); - isPrimary = await new Promise((resolve) => { - const timeout = setTimeout(() => resolve(true), 200); - focusChannel!.onmessage = (e: MessageEvent) => { - if ( - e.data !== null && - typeof e.data === 'object' && - typeof e.data.type === 'string' && - e.data.type === 'focus-ack' - ) { - clearTimeout(timeout); - resolve(false); - } - // Do NOT respond to focus-request here — only an established primary does that. - // Responding during the handshake would let two simultaneous tabs ack each other. - }; - focusChannel!.postMessage({ type: 'focus-request', sessionId }); - }); -} - -if (!isPrimary) { - (document.getElementById('terminal') as HTMLElement).textContent = - 'Session already open in another tab.'; -} else { - // This is the primary tab — respond to focus-requests from future tabs. - if (focusChannel) { - focusChannel.onmessage = (e: MessageEvent) => { - if ( - e.data !== null && - typeof e.data === 'object' && - typeof e.data.type === 'string' && - e.data.type === 'focus-request' - ) { - window.focus(); - focusChannel!.postMessage({ type: 'focus-ack' }); - } - }; - } +document.title = `${sessionId} | webtty`; - const config: ClientConfig = await fetch('/api/config').then((r) => r.json()); +await init(); - document.title = `${sessionId} | webtty`; +const term = new Terminal({ + cols: config.cols, + rows: config.rows, + cursorStyle: config.cursorStyle, + cursorBlink: config.cursorStyleBlink, + fontSize: config.fontSize, + fontFamily: config.fontFamily, + scrollback: Math.ceil(config.scrollback / 80), + theme: config.theme, +}); - await init(); +const fitAddon = new FitAddon(); +term.loadAddon(fitAddon); - const term = new Terminal({ - cols: config.cols, - rows: config.rows, - cursorStyle: config.cursorStyle, - cursorBlink: config.cursorStyleBlink, - fontSize: config.fontSize, - fontFamily: config.fontFamily, - scrollback: Math.ceil(config.scrollback / 80), - theme: config.theme, - }); +const container = document.getElementById('terminal') as HTMLElement; +if (config.theme?.background) { + container.style.background = config.theme.background; +} +await term.open(container); - const fitAddon = new FitAddon(); - term.loadAddon(fitAddon); +// 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`; +} - const container = document.getElementById('terminal') as HTMLElement; - if (config.theme?.background) { - container.style.background = config.theme.background; - } - await term.open(container); +fit(); +new ResizeObserver(() => fit()).observe(container, { box: 'border-box' }); - // 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`; - } +const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; +let ws: WebSocket; - fit(); - new ResizeObserver(() => fit()).observe(container, { box: 'border-box' }); +function connect(): void { + const wsUrl = `${protocol}//${window.location.host}/ws/${sessionId}?cols=${term.cols}&rows=${term.rows}`; + ws = new WebSocket(wsUrl); - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - let ws: WebSocket; + const DIM = '\x1b[2m', + YELLOW = '\x1b[1;33m', + ITALIC = '\x1b[3m', + RESET = '\x1b[0m'; + const tag = `${DIM}[${RESET} ${YELLOW}webtty${RESET} ${DIM}]${RESET}`; + const msg = (text: string): string => `\r\n${tag} ${DIM}${ITALIC}${text}${RESET}\r\n`; - function connect(): void { - const wsUrl = `${protocol}//${window.location.host}/ws/${sessionId}?cols=${term.cols}&rows=${term.rows}`; - ws = new WebSocket(wsUrl); + ws.onopen = () => { + ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); + }; - const DIM = '\x1b[2m', - YELLOW = '\x1b[1;33m', - ITALIC = '\x1b[3m', - RESET = '\x1b[0m'; - const tag = `${DIM}[${RESET} ${YELLOW}webtty${RESET} ${DIM}]${RESET}`; - const msg = (text: string): string => `\r\n${tag} ${DIM}${ITALIC}${text}${RESET}\r\n`; + ws.onmessage = (event: MessageEvent) => { + applyDecscusr(term, event.data); + term.write(event.data); + }; - ws.onopen = () => { - ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); - }; + ws.onclose = (event: CloseEvent) => { + if (event.code === 4001) { + term.write(msg('Session removed.')); + setTimeout(() => window.close(), 500); + return; + } + if (event.code === 1001) { + term.write(msg('Server stopped.')); + setTimeout(() => window.close(), 500); + return; + } + term.write(msg('Connection lost. Reconnecting in 2s...')); + setTimeout(connect, 2000); + }; - ws.onmessage = (event: MessageEvent) => { - applyDecscusr(term, event.data); - term.write(event.data); - }; + ws.onerror = () => { + term.write(msg('WebSocket error.')); + }; +} - ws.onclose = (event: CloseEvent) => { - if (event.code === 4001) { - term.write(msg('Session removed.')); - setTimeout(() => window.close(), 500); - return; - } - if (event.code === 1001) { - term.write(msg('Server stopped.')); - setTimeout(() => window.close(), 500); - return; - } - term.write(msg('Connection lost. Reconnecting in 2s...')); - setTimeout(connect, 2000); - }; +connect(); - ws.onerror = () => { - term.write(msg('WebSocket error.')); - }; +// ghostty-web's Terminal.handleWheel sends \x1b[A/\x1b[B (arrow keys) on the +// alternate screen regardless of mouse tracking state, moving the cursor instead +// of scrolling. When the PTY application has enabled mouse tracking (e.g. vim +// with `set mouse=a`), intercept wheel events and send the correct SGR mouse +// scroll sequence so the app receives a scroll event, not a cursor move. +// SGR button 64 = scroll up, 65 = scroll down. See ADR 017. +// +// config.mouseScrollSpeed scales SGR events per wheel tick (default 1). +// Values < 1 reduce rate via accumulation; values > 1 send multiple SGRs. +// The accumulator resets on direction change to prevent cross-direction bleed. +let scrollAccum = 0; +let scrollDir = 0; +term.attachCustomWheelEventHandler((e: WheelEvent): boolean => { + if (!term.hasMouseTracking()) return false; + const metrics = term.renderer?.getMetrics(); + if (!metrics) return false; + const dir = e.deltaY < 0 ? -1 : 1; + if (dir !== scrollDir) { + scrollAccum = 0; + scrollDir = dir; } + scrollAccum += config.mouseScrollSpeed; + const ticks = Math.trunc(scrollAccum); + if (ticks === 0) return true; + scrollAccum -= ticks; + const rect = (e.target as HTMLElement).getBoundingClientRect(); + const col = Math.max(1, Math.floor((e.clientX - rect.left) / metrics.width) + 1); + const row = Math.max(1, Math.floor((e.clientY - rect.top) / metrics.height) + 1); + const btn = dir < 0 ? 64 : 65; + const seq = `\x1b[<${btn};${col};${row}M`; + if (ws && ws.readyState === WebSocket.OPEN) { + for (let i = 0; i < ticks; i++) ws.send(seq); + } + return true; +}); - connect(); - - // ghostty-web's Terminal.handleWheel sends \x1b[A/\x1b[B (arrow keys) on the - // alternate screen regardless of mouse tracking state, moving the cursor instead - // of scrolling. When the PTY application has enabled mouse tracking (e.g. vim - // with `set mouse=a`), intercept wheel events and send the correct SGR mouse - // scroll sequence so the app receives a scroll event, not a cursor move. - // SGR button 64 = scroll up, 65 = scroll down. See ADR 017. - // - // config.mouseScrollSpeed scales SGR events per wheel tick (default 1). - // Values < 1 reduce rate via accumulation; values > 1 send multiple SGRs. - // The accumulator resets on direction change to prevent cross-direction bleed. - let scrollAccum = 0; - let scrollDir = 0; - term.attachCustomWheelEventHandler((e: WheelEvent): boolean => { - if (!term.hasMouseTracking()) return false; - const metrics = term.renderer?.getMetrics(); - if (!metrics) return false; - const dir = e.deltaY < 0 ? -1 : 1; - if (dir !== scrollDir) { - scrollAccum = 0; - scrollDir = dir; - } - scrollAccum += config.mouseScrollSpeed; - const ticks = Math.trunc(scrollAccum); - if (ticks === 0) return true; - scrollAccum -= ticks; - const rect = (e.target as HTMLElement).getBoundingClientRect(); - const col = Math.max(1, Math.floor((e.clientX - rect.left) / metrics.width) + 1); - const row = Math.max(1, Math.floor((e.clientY - rect.top) / metrics.height) + 1); - const btn = dir < 0 ? 64 : 65; - const seq = `\x1b[<${btn};${col};${row}M`; - if (ws && ws.readyState === WebSocket.OPEN) { - for (let i = 0; i < ticks; i++) ws.send(seq); +// Intercept configured key+mods combos before ghostty-web sees them and send +// the bound chars directly to the PTY. See ADR 018. +container.addEventListener( + 'keydown', + (e: KeyboardEvent) => { + const key = e.key.toLowerCase(); + const active = new Set([ + ...(e.shiftKey ? ['shift'] : []), + ...(e.ctrlKey ? ['ctrl'] : []), + ...(e.altKey ? ['alt'] : []), + ...(e.metaKey ? ['meta'] : []), + ]); + const binding = config.keyboardBindings.find((b) => { + if (b.key.toLowerCase() !== key) return false; + const required = new Set((Array.isArray(b.mods) ? b.mods : []).map((m) => m.toLowerCase())); + if (required.size !== active.size) return false; + for (const m of required) if (!active.has(m)) return false; + return true; + }); + if (!binding) return; + e.preventDefault(); + e.stopPropagation(); + if (binding.chars && ws.readyState === WebSocket.OPEN) { + ws.send(binding.chars); } - return true; - }); - - // Intercept configured key+mods combos before ghostty-web sees them and send - // the bound chars directly to the PTY. See ADR 018. - container.addEventListener( - 'keydown', - (e: KeyboardEvent) => { - const key = e.key.toLowerCase(); - const active = new Set([ - ...(e.shiftKey ? ['shift'] : []), - ...(e.ctrlKey ? ['ctrl'] : []), - ...(e.altKey ? ['alt'] : []), - ...(e.metaKey ? ['meta'] : []), - ]); - const binding = config.keyboardBindings.find((b) => { - if (b.key.toLowerCase() !== key) return false; - const required = new Set((Array.isArray(b.mods) ? b.mods : []).map((m) => m.toLowerCase())); - if (required.size !== active.size) return false; - for (const m of required) if (!active.has(m)) return false; - return true; - }); - if (!binding) return; - e.preventDefault(); - e.stopPropagation(); - if (binding.chars && ws.readyState === WebSocket.OPEN) { - ws.send(binding.chars); - } - }, - { 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. 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; - 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 (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(); - }, - { capture: true }, - ); + }, + { capture: true }, +); - // Forward terminal keystrokes and input to the PTY over WebSocket. - term.onData((data: string) => { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(data); - } - }); +// 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. 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; + 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 (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(); + }, + { capture: true }, +); - // Notify the server when the terminal is resized so the PTY dimensions stay in sync. - term.onResize(({ cols, rows }: { cols: number; rows: number }) => { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'resize', cols, rows })); - } - }); +// Forward terminal keystrokes and input to the PTY over WebSocket. +term.onData((data: string) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(data); + } +}); - // Copy the selected text to the clipboard whenever the selection changes. - if (config.copyOnSelect) { - term.onSelectionChange(() => { - const selection = term.getSelection() as string; - if (!selection) return; - navigator.clipboard.writeText(selection).catch(() => { - /* empty */ - }); - }); +// Notify the server when the terminal is resized so the PTY dimensions stay in sync. +term.onResize(({ cols, rows }: { cols: number; rows: number }) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'resize', cols, rows })); } +}); - // Copy selected text to clipboard on right-click when copyPaste mode is active. - if (config.rightClickBehavior === 'copyPaste') { - container.addEventListener('contextmenu', (e: MouseEvent) => { - const selection = term.getSelection() as string; - if (!selection) return; - e.preventDefault(); - navigator.clipboard.writeText(selection).catch(() => { - /* empty */ - }); - term.clearSelection(); +// Copy the selected text to the clipboard whenever the selection changes. +if (config.copyOnSelect) { + term.onSelectionChange(() => { + const selection = term.getSelection() as string; + if (!selection) return; + navigator.clipboard.writeText(selection).catch(() => { + /* empty */ }); - } + }); +} - // ghostty-web swallows Ctrl+V without sending \x16 to the PTY (unlike - // xterm.js). When clipboard has no text/plain, its paste handler drops it - // too. Send \x16 so TUI apps can invoke their native OS clipboard read. - // See ADR 014. - container.addEventListener( - 'paste', - (e: ClipboardEvent) => { - const cd = e.clipboardData; - if (!cd) return; - if (cd.getData('text/plain')) return; - e.preventDefault(); - e.stopImmediatePropagation(); - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send('\x16'); - } - }, - { capture: true }, - ); +// Copy selected text to clipboard on right-click when copyPaste mode is active. +if (config.rightClickBehavior === 'copyPaste') { + container.addEventListener('contextmenu', (e: MouseEvent) => { + const selection = term.getSelection() as string; + if (!selection) return; + e.preventDefault(); + navigator.clipboard.writeText(selection).catch(() => { + /* empty */ + }); + term.clearSelection(); + }); } + +// ghostty-web swallows Ctrl+V without sending \x16 to the PTY (unlike +// xterm.js). When clipboard has no text/plain, its paste handler drops it +// too. Send \x16 so TUI apps can invoke their native OS clipboard read. +// See ADR 014. +container.addEventListener( + 'paste', + (e: ClipboardEvent) => { + const cd = e.clipboardData; + if (!cd) return; + if (cd.getData('text/plain')) return; + e.preventDefault(); + e.stopImmediatePropagation(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send('\x16'); + } + }, + { capture: true }, +); From 8177a1afc013565868c7036c43bf5c461f836b99 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 5 Apr 2026 22:55:50 -0400 Subject: [PATCH 15/15] fix: wait for prompt before resize in websocket test waitForPrompt was called after the resize message, but /bin/sh does not redraw its prompt on SIGWINCH so the prompt never re-appeared, causing a 3 s timeout. Move the waitForPrompt before the resize so the shell is confirmed ready before dimensions are changed. Co-Authored-By: Claude Sonnet 4.6 --- src/server/websocket.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/websocket.test.ts b/src/server/websocket.test.ts index 2917fd4..e6c1202 100644 --- a/src/server/websocket.test.ts +++ b/src/server/websocket.test.ts @@ -193,10 +193,10 @@ describe('websocket', () => { const { ws, messages } = await connectWs(`${wsBase}/ws/ws-test-resize?cols=80&rows=24`); await waitForMessages(messages, 1); + await waitForPrompt(messages); ws.send(JSON.stringify({ type: 'resize', cols: 120, rows: 40 })); - await waitForPrompt(messages); ws.send('echo resize-ok\n'); await waitForContent(messages, 'resize-ok'); await closeWs(ws);