From da14e921089bb1779f10c8b72a93b44246a8c51c Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 20 Apr 2026 10:18:12 -0400 Subject: [PATCH 01/16] fix: prevent visual mode activation on hover in Vim by blocking mousemove events --- src/client/index.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/client/index.ts b/src/client/index.ts index b5d7649..5f26d85 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -139,6 +139,20 @@ function connect(): void { connect(); +// ghostty-web reports hover mousemove events (e.buttons === 0) as button-32 +// SGR drags to the PTY. Vim with `set mouse=a` interprets button-32 as +// "extend selection" and enters visual mode. Block no-button mousemove events +// before ghostty-web sees them so hover never triggers a drag report. +container.addEventListener( + 'mousemove', + (e: MouseEvent) => { + if (term.hasMouseTracking() && e.buttons === 0) { + e.stopPropagation(); + } + }, + { capture: true }, +); + // 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 From 0bab2627d7de28fd3844800d5eca71ba7dedec9e Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 20 Apr 2026 10:25:58 -0400 Subject: [PATCH 02/16] fix: prevent vim visual mode on hover, lost drags, and missed resizes - Block mousemove (e.buttons===0) when mouse tracking is active so ghostty-web's hover-as-button-32 misreport never triggers vim visual mode on mouse movement. - Set pointer capture on the canvas on pointerdown so drag operations (e.g. vim split resize) are not interrupted when the pointer crosses outside the canvas boundary. - Add window resize listener alongside ResizeObserver to catch monitor hot-plug and DPI changes that resize the viewport without triggering the element-level observer. Co-Authored-By: Claude Sonnet 4.6 --- src/client/index.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/client/index.ts b/src/client/index.ts index 5f26d85..2c4992c 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -93,6 +93,10 @@ function fit(): void { fit(); new ResizeObserver(() => fit()).observe(container, { box: 'border-box' }); +// ResizeObserver misses monitor hot-plug and DPI changes because those resize +// the viewport without changing the container's layout box. window resize fires +// reliably for both, so use both observers together. +window.addEventListener('resize', fit); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; let ws: WebSocket; @@ -139,6 +143,19 @@ function connect(): void { connect(); +// When dragging inside the terminal (e.g. vim split resize), the pointer can +// leave the canvas boundary and the browser stops delivering mousemove to it. +// Setting pointer capture on the canvas on pointerdown redirects all subsequent +// pointer and synthesized mouse events to that element for the duration of the +// press, so drags never lose tracking mid-gesture. +container.addEventListener( + 'pointerdown', + (e: PointerEvent) => { + const canvas = container.querySelector('canvas'); + canvas?.setPointerCapture(e.pointerId); + }, +); + // ghostty-web reports hover mousemove events (e.buttons === 0) as button-32 // SGR drags to the PTY. Vim with `set mouse=a` interprets button-32 as // "extend selection" and enters visual mode. Block no-button mousemove events From cebfd088dea1d2608e91258e2d3272d36e082886 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 20 Apr 2026 11:13:13 -0400 Subject: [PATCH 03/16] fix: ignore PORT env var, always use config port process.env.PORT was silently overriding the config-file port, causing webtty to target a different port than configured when PORT happened to be set in the shell environment. Co-Authored-By: Claude Sonnet 4.6 --- src/cli/http.ts | 4 ++-- src/server/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cli/http.ts b/src/cli/http.ts index 2f4d24b..387f472 100644 --- a/src/cli/http.ts +++ b/src/cli/http.ts @@ -7,8 +7,8 @@ import { configDir, loadConfig } from '../config'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -/** Active server port, resolved from `PORT` env or config default. */ -export const PORT = Number(process.env.PORT) || 2346; +/** Active server port, resolved from config (env PORT is intentionally ignored). */ +export const PORT = loadConfig().port; /** Base URL for internal CLI↔server API calls (always 127.0.0.1, avoids IPv6 lookup). */ export const BASE_URL = `http://127.0.0.1:${PORT}`; diff --git a/src/server/index.ts b/src/server/index.ts index d3ab79a..866ff66 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -11,7 +11,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const config = loadConfig(); -const HTTP_PORT = Number(process.env.PORT) || config.port; +const HTTP_PORT = config.port; // 'localhost' resolves to ::1 (IPv6) on modern macOS/Node; bind to 127.0.0.1 instead // but keep 'localhost' as the display host so browser URLs use it as intended. const HTTP_HOST_DISPLAY = config.host; From e53145958a9e63520545a990dbdff32c1b970533 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 20 Apr 2026 12:19:49 -0400 Subject: [PATCH 04/16] fix: use node-pty instead of Bun.Terminal on Windows Bun.Terminal does not support the terminal option on Windows (throws ERR_INVALID_ARG_TYPE), causing the WebSocket handler to crash on the first PTY spawn and dropping every connection with "Connection lost". node-pty works correctly under Bun on Windows. Co-Authored-By: Claude Sonnet 4.6 --- src/pty/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pty/index.ts b/src/pty/index.ts index 40c6a5b..878493a 100644 --- a/src/pty/index.ts +++ b/src/pty/index.ts @@ -1,6 +1,7 @@ export type { PtyProcess } from './types'; -const isBun = !!process.versions.bun; +// Bun.Terminal does not support the `terminal` option on Windows; fall back to node-pty. +const isBun = !!process.versions.bun && process.platform !== 'win32'; console.log(`pty: ${isBun ? 'Bun.Terminal' : 'node-pty'}`); const { spawn: _spawn } = await (isBun ? import('./bun') : import('./node')); From 94a2d227800be28041a765579d4ea6b2524075ef Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 21 Apr 2026 23:43:29 -0400 Subject: [PATCH 05/16] chore: add .gitattributes to enforce LF line endings on all platforms Prevents biome formatter failures on Windows where git autocrlf=true was converting LF to CRLF on checkout. Co-Authored-By: Claude Sonnet 4.6 --- .gitattributes | 1 + src/client/index.ts | 11 ++++------- 2 files changed, 5 insertions(+), 7 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/src/client/index.ts b/src/client/index.ts index 2c4992c..456fe80 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -148,13 +148,10 @@ connect(); // Setting pointer capture on the canvas on pointerdown redirects all subsequent // pointer and synthesized mouse events to that element for the duration of the // press, so drags never lose tracking mid-gesture. -container.addEventListener( - 'pointerdown', - (e: PointerEvent) => { - const canvas = container.querySelector('canvas'); - canvas?.setPointerCapture(e.pointerId); - }, -); +container.addEventListener('pointerdown', (e: PointerEvent) => { + const canvas = container.querySelector('canvas'); + canvas?.setPointerCapture(e.pointerId); +}); // ghostty-web reports hover mousemove events (e.buttons === 0) as button-32 // SGR drags to the PTY. Vim with `set mouse=a` interprets button-32 as From d507992c9f5609abe3a90ef422ab3dfaa8e56e20 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 21 Apr 2026 23:49:23 -0400 Subject: [PATCH 06/16] fix: restore PORT env override in server for tests and direct invocation The CLI already reads port exclusively from config, so ambient PORT never silently overrides a user's config. The server itself still needs to honor PORT so integration tests can bind to a free port and the server can be run directly with a custom port without editing config.json. Co-Authored-By: Claude Sonnet 4.6 --- src/server/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/index.ts b/src/server/index.ts index 866ff66..d3ab79a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -11,7 +11,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const config = loadConfig(); -const HTTP_PORT = config.port; +const HTTP_PORT = Number(process.env.PORT) || config.port; // 'localhost' resolves to ::1 (IPv6) on modern macOS/Node; bind to 127.0.0.1 instead // but keep 'localhost' as the display host so browser URLs use it as intended. const HTTP_HOST_DISPLAY = config.host; From 3184892253baafc3a306459288a26a6306fbecad Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 22 Apr 2026 06:05:43 -0400 Subject: [PATCH 07/16] fix: resolve server entry to dist/ when spawning with node on Windows When the CLI runs from source (src/cli/index.ts), __dirname is src/cli. The useNode path previously resolved ../server/index.js to src/server/index.js which does not exist. Now detect the running-from-source case via the .ts extension and jump two levels up to dist/server/index.js instead. Also write a port config into the test tmpHome before each runCli call so the CLI subprocess reads the correct test port from loadConfig() rather than defaulting to 2346. Co-Authored-By: Claude Sonnet 4.6 --- src/cli/commands.test.ts | 7 ++++++- src/cli/http.ts | 21 ++++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index 88610bf..4184b46 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -28,8 +28,13 @@ async function runCli( port: number, ...args: string[] ): Promise<{ stdout: string; stderr: string; exitCode: number }> { + // Write config with the test port so loadConfig() in the CLI subprocess returns the right port. + const cfgDir = path.join(tmpHome, '.config', 'webtty'); + fsModule.mkdirSync(cfgDir, { recursive: true }); + fsModule.writeFileSync(path.join(cfgDir, 'config.json'), JSON.stringify({ port })); + const proc = Bun.spawn([process.execPath, CLI_ENTRY, ...args], { - env: { ...process.env, PORT: String(port), WEBTTY_NO_OPEN: '1', HOME: tmpHome }, + env: { ...process.env, WEBTTY_NO_OPEN: '1', HOME: tmpHome }, stdout: 'pipe', stderr: 'pipe', }); diff --git a/src/cli/http.ts b/src/cli/http.ts index 387f472..d718df3 100644 --- a/src/cli/http.ts +++ b/src/cli/http.ts @@ -47,11 +47,22 @@ export async function startServer(timeoutMs = 10000, _spawn = childProcess.spawn const useNode = process.platform === 'win32' && isBun; const serverExec = useNode ? 'node' : process.execPath; - // When the executor is Node.js it cannot run .ts files directly, so always - // resolve to the compiled .js entry regardless of whether the CLI itself is - // running from source. - const isTs = isBun && !useNode && __filename.endsWith('.ts'); - const serverEntry = path.resolve(__dirname, isTs ? '../server/index.ts' : '../server/index.js'); + // Resolve the server entry. When useNode is true the executor is Node.js, + // which cannot run .ts files, so we always need the compiled .js output. + // The CLI may itself run from source (src/cli/) or from dist (dist/cli/); + // adjust the relative path accordingly so we always land in the same dist/. + const runningFromSrc = __filename.endsWith('.ts'); + let serverEntry: string; + if (useNode) { + serverEntry = runningFromSrc + ? path.resolve(__dirname, '../../dist/server/index.js') // src/cli → dist/server + : path.resolve(__dirname, '../server/index.js'); // dist/cli → dist/server + } else { + serverEntry = path.resolve( + __dirname, + runningFromSrc ? '../server/index.ts' : '../server/index.js', + ); + } if (!fs.existsSync(serverEntry)) { console.error(`webtty: server entry not found at ${serverEntry}`); (process.exit as (code?: number) => void)(1); From 3b61f524d535cf91b7d1c67446557277d587b5ec Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 22 Apr 2026 06:34:26 -0400 Subject: [PATCH 08/16] refactor: address code review comments on PR #29 - client: coalesce ResizeObserver + window resize calls through requestAnimationFrame so both signals produce one fit() per frame instead of two back-to-back layout passes. - http: replace eager module-level PORT/BASE_URL constants with lazy getPort()/getBaseUrl() functions so loadConfig() is only called when a command actually needs the port, avoiding unnecessary disk I/O and config-file creation on every CLI import. - test: normalize backslash paths before the server/index substring check so the mock works correctly on Windows. Co-Authored-By: Claude Sonnet 4.6 --- src/cli/commands.ts | 14 +++++++------- src/cli/http.test.ts | 2 +- src/cli/http.ts | 21 ++++++++++++++------- src/client/index.ts | 17 ++++++++++++++--- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 093b374..96d5abe 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -2,7 +2,7 @@ import * as childProcess from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { configDir, loadConfig } from '../config'; -import { BASE_URL, isServerRunning, openBrowser, PORT, startServer, stopServer } from './http'; +import { getBaseUrl, getPort, isServerRunning, openBrowser, startServer, stopServer } from './http'; /** * Converts a bind host to a browser-navigable host. @@ -27,11 +27,11 @@ export async function cmdGo(id = 'main'): Promise { } let sessionId: string; - const check = await fetch(`${BASE_URL}/api/sessions/${encodeURIComponent(id)}`); + const check = await fetch(`${getBaseUrl()}/api/sessions/${encodeURIComponent(id)}`); if (check.status === 200) { sessionId = id; } else { - const res = await fetch(`${BASE_URL}/api/sessions`, { + const res = await fetch(`${getBaseUrl()}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), @@ -45,7 +45,7 @@ export async function cmdGo(id = 'main'): Promise { sessionId = session.id; } - const url = `http://${toBrowserHost(loadConfig().host)}:${PORT}/s/${sessionId}`; + const url = `http://${toBrowserHost(loadConfig().host)}:${getPort()}/s/${sessionId}`; console.log(url); openBrowser(url); } @@ -58,7 +58,7 @@ export async function cmdGo(id = 'main'): Promise { export async function cmdList(filter?: string): Promise { let res: Response; try { - res = await fetch(`${BASE_URL}/api/sessions`); + res = await fetch(`${getBaseUrl()}/api/sessions`); } catch { console.log('webtty is not running'); process.exit(1); @@ -92,7 +92,7 @@ export async function cmdRemove(id?: string): Promise { } let res: Response; try { - res = await fetch(`${BASE_URL}/api/sessions/${encodeURIComponent(id)}`, { + res = await fetch(`${getBaseUrl()}/api/sessions/${encodeURIComponent(id)}`, { method: 'DELETE', }); } catch { @@ -127,7 +127,7 @@ export async function cmdRename(id?: string, newId?: string): Promise { } let res: Response; try { - res = await fetch(`${BASE_URL}/api/sessions/${encodeURIComponent(id)}`, { + res = await fetch(`${getBaseUrl()}/api/sessions/${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: newId }), diff --git a/src/cli/http.test.ts b/src/cli/http.test.ts index 617e6a0..d6bca24 100644 --- a/src/cli/http.test.ts +++ b/src/cli/http.test.ts @@ -134,7 +134,7 @@ describe('startServer', () => { test('exits with error when server entry not found', async () => { const realExistsSync = fs.existsSync.bind(fs); const existsSpy = spyOn(fs, 'existsSync').mockImplementation((p) => - String(p).includes('server/index') ? false : realExistsSync(p), + String(p).replace(/\\/g, '/').includes('server/index') ? false : realExistsSync(p), ); const exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); diff --git a/src/cli/http.ts b/src/cli/http.ts index d718df3..b4c9549 100644 --- a/src/cli/http.ts +++ b/src/cli/http.ts @@ -7,11 +7,15 @@ import { configDir, loadConfig } from '../config'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -/** Active server port, resolved from config (env PORT is intentionally ignored). */ -export const PORT = loadConfig().port; +/** Active server port, read from config on first call (env PORT is intentionally ignored). */ +export function getPort(): number { + return loadConfig().port; +} /** Base URL for internal CLI↔server API calls (always 127.0.0.1, avoids IPv6 lookup). */ -export const BASE_URL = `http://127.0.0.1:${PORT}`; +export function getBaseUrl(): string { + return `http://127.0.0.1:${getPort()}`; +} /** Returns the path to the server log file: `~/.config/webtty/server.log`. */ export function logPath(): string { @@ -21,7 +25,7 @@ export function logPath(): string { /** Returns `true` if the webtty server is reachable and responding to API requests. */ export async function isServerRunning(): Promise { try { - const res = await fetch(`${BASE_URL}/api/sessions`); + const res = await fetch(`${getBaseUrl()}/api/sessions`); if (!res.ok) return false; const body = await res.json(); return Array.isArray(body); @@ -82,7 +86,7 @@ export async function startServer(timeoutMs = 10000, _spawn = childProcess.spawn const child = _spawn(serverExec, [serverEntry], { detached: true, stdio, - env: { ...process.env, PORT: String(PORT) }, + env: { ...process.env, PORT: String(getPort()) }, }); child.on('error', (err) => { const hint = useNode @@ -106,11 +110,14 @@ export async function startServer(timeoutMs = 10000, _spawn = childProcess.spawn /** * Sends `POST /api/server/stop`, then polls until the server is no longer reachable. * - * @param baseUrl - The server base URL (default: BASE_URL). + * @param baseUrl - The server base URL (default: getBaseUrl()). * @param timeoutMs - Maximum time to wait for the server to stop (default: 5000 ms). * @returns `true` if the server stopped successfully, `false` otherwise. */ -export async function stopServer(baseUrl: string = BASE_URL, timeoutMs = 5000): Promise { +export async function stopServer( + baseUrl: string = getBaseUrl(), + timeoutMs = 5000, +): Promise { try { const res = await fetch(`${baseUrl}/api/server/stop`, { method: 'POST' }); if (!res.ok) return false; diff --git a/src/client/index.ts b/src/client/index.ts index 456fe80..62ebd27 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -91,12 +91,23 @@ function fit(): void { container.style.paddingBottom = `${Math.ceil(vGap / 2)}px`; } -fit(); -new ResizeObserver(() => fit()).observe(container, { box: 'border-box' }); +// Both ResizeObserver and window 'resize' can fire for the same physical event. +// Schedule through rAF so multiple signals coalesce into one fit per frame. +let pendingFitFrame: number | null = null; +function scheduleFit(): void { + if (pendingFitFrame !== null) return; + pendingFitFrame = window.requestAnimationFrame(() => { + pendingFitFrame = null; + fit(); + }); +} + +scheduleFit(); +new ResizeObserver(() => scheduleFit()).observe(container, { box: 'border-box' }); // ResizeObserver misses monitor hot-plug and DPI changes because those resize // the viewport without changing the container's layout box. window resize fires // reliably for both, so use both observers together. -window.addEventListener('resize', fit); +window.addEventListener('resize', scheduleFit); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; let ws: WebSocket; From 127d9e8d2457b9d92b4f12b56899c44b9ab74680 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 22 Apr 2026 06:48:56 -0400 Subject: [PATCH 09/16] fix: remove setPointerCapture that broke basic vim drag setPointerCapture on pointerdown interfered with ghostty-web's internal pointer tracking, causing vim to enter and immediately exit visual mode on any drag. The hover-mousemove blocker alone is sufficient to prevent the original ghost visual-mode-on-hover issue. Co-Authored-By: Claude Sonnet 4.6 --- src/client/index.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 62ebd27..88fe949 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -154,16 +154,6 @@ function connect(): void { connect(); -// When dragging inside the terminal (e.g. vim split resize), the pointer can -// leave the canvas boundary and the browser stops delivering mousemove to it. -// Setting pointer capture on the canvas on pointerdown redirects all subsequent -// pointer and synthesized mouse events to that element for the duration of the -// press, so drags never lose tracking mid-gesture. -container.addEventListener('pointerdown', (e: PointerEvent) => { - const canvas = container.querySelector('canvas'); - canvas?.setPointerCapture(e.pointerId); -}); - // ghostty-web reports hover mousemove events (e.buttons === 0) as button-32 // SGR drags to the PTY. Vim with `set mouse=a` interprets button-32 as // "extend selection" and enters visual mode. Block no-button mousemove events From 927214989992e90f394acc20c576d80c202265e7 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 22 Apr 2026 07:03:06 -0400 Subject: [PATCH 10/16] fix: filter hover SGR at onData level instead of blocking DOM events Blocking mousemove DOM events in capture phase disrupted ghostty-web's internal mouse state, breaking vim drag (visual mode entered then immediately exited). New approach: let ghostty-web see all mouse events normally, then drop the outgoing \x1b[<32;...M (button-32 motion) sequences from onData when no mouse button is held. ghostty-web uses button-32 for both hover and button-1 drag; tracking mousedown/mouseup lets us distinguish them and only suppress the hover case before it reaches the PTY. Co-Authored-By: Claude Sonnet 4.6 --- src/client/index.ts | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 88fe949..ec0b16a 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -154,20 +154,6 @@ function connect(): void { connect(); -// ghostty-web reports hover mousemove events (e.buttons === 0) as button-32 -// SGR drags to the PTY. Vim with `set mouse=a` interprets button-32 as -// "extend selection" and enters visual mode. Block no-button mousemove events -// before ghostty-web sees them so hover never triggers a drag report. -container.addEventListener( - 'mousemove', - (e: MouseEvent) => { - if (term.hasMouseTracking() && e.buttons === 0) { - e.stopPropagation(); - } - }, - { capture: true }, -); - // 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 @@ -261,7 +247,22 @@ window.addEventListener( ); // Forward terminal keystrokes and input to the PTY over WebSocket. +// ghostty-web sends SGR button-32 (\x1b[<32;…M) for both hover motion +// (e.buttons === 0) and button-1 drag. Vim with `set mouse=a` treats +// button-32 as "extend selection", so hover triggers spurious visual mode. +// Track button state and drop button-32 sequences that arrive while no +// button is held (pure hover), letting real drags pass through unchanged. +let mouseButtonsHeld = 0; +container.addEventListener('mousedown', () => mouseButtonsHeld++, { capture: true }); +container.addEventListener( + 'mouseup', + () => { + if (mouseButtonsHeld > 0) mouseButtonsHeld--; + }, + { capture: true }, +); term.onData((data: string) => { + if (mouseButtonsHeld === 0 && data.startsWith('\x1b[<32;')) return; if (ws && ws.readyState === WebSocket.OPEN) { ws.send(data); } From 8a19cf436515f53e19430ec7ec34d67005d98f48 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 26 Apr 2026 15:45:22 -0400 Subject: [PATCH 11/16] fix: correct hover SGR encoding and deduplicate drag events for vim mouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ghostty-web encodes hover motion (e.buttons===0) as SGR button-32 (\x1b[<32;col;rowM) — the same code as a left-button drag — because its internal mouseButtonsPressed bitmask is 0 when no button is held (0+32=32). Vim with mouse=a enables mode 1003 (any-event tracking), so every hover move arrived as , toggling visual mode on each cursor movement. Two fixes in onData: 1. Hover rewrite: a capture-phase mousemove listener reads the DOM event's authoritative e.buttons before ghostty-web's bubble-phase handler encodes it. When e.buttons===0, rewrite \x1b[<32; to \x1b[<35; (Cb=35 = 32+3, the correct SGR no-button code). Position is still forwarded so vim can track the cursor for subsequent drag operations. 2. Drag dedup: ghostty-web fires multiple mousemove events per character cell. Vim treats at the same cell twice as a visual-mode toggle, so consecutive identical \x1b[<32;col;rowM sequences are deduplicated. Both fixes are gated on hasMouseTracking() and the \x1b[<32; prefix, so they are no-ops when mouse tracking is inactive or when other Cb values (press, release, scroll) are involved. --- src/client/index.ts | 58 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index ec0b16a..f7ee488 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -247,22 +247,58 @@ window.addEventListener( ); // Forward terminal keystrokes and input to the PTY over WebSocket. -// ghostty-web sends SGR button-32 (\x1b[<32;…M) for both hover motion -// (e.buttons === 0) and button-1 drag. Vim with `set mouse=a` treats -// button-32 as "extend selection", so hover triggers spurious visual mode. -// Track button state and drop button-32 sequences that arrive while no -// button is held (pure hover), letting real drags pass through unchanged. -let mouseButtonsHeld = 0; -container.addEventListener('mousedown', () => mouseButtonsHeld++, { capture: true }); +// +// ghostty-web bug: handleMouseMove always encodes motion as SGR button-32 +// (\x1b[<32;col;rowM) regardless of whether a button is held. It uses its +// own mouseButtonsPressed bitmask; when no button is pressed the bitmask is 0, +// and 0+32=32 — the same Cb value as a left-button drag. Real terminals use +// Cb=35 (32+3, "no button") for hover and Cb=32 for left-button drag. +// +// Impact on vim: with `set mouse=a` vim requests mode 1003 (any-event +// tracking). Every hover move arrives as \x1b[<32;…M which vim decodes as +// , toggling visual mode on each pixel of cursor movement. +// +// Fix 1 — hover rewrite: a capture-phase mousemove listener reads the DOM +// event's authoritative e.buttons *before* ghostty-web's bubble-phase handler +// encodes it. When e.buttons===0 (hover), onData rewrites \x1b[<32; to +// \x1b[<35; so vim receives the correct no-button code. The position is still +// forwarded so vim can track the cursor for subsequent drag operations. +// +// Fix 2 — drag dedup: ghostty-web fires multiple mousemove events for the same +// character cell while the pointer stays within it. Vim treats a +// at the same cell twice as a toggle (enter visual → exit visual), so we +// deduplicate consecutive identical drag sequences before forwarding. +// +// Safety on other platforms: the rewrite only fires when hasMouseTracking() +// is true AND e.buttons===0. On macOS (or any platform where ghostty-web +// correctly encodes hover as 35 or where vim uses mode 1002 and ghostty-web +// already skips hover motion), the condition is never met and the data passes +// through unchanged. +let isHoverMove = false; +let lastDragSeq = ''; container.addEventListener( - 'mouseup', - () => { - if (mouseButtonsHeld > 0) mouseButtonsHeld--; + 'mousemove', + (e: MouseEvent) => { + isHoverMove = term.hasMouseTracking() && e.buttons === 0; + if (isHoverMove) lastDragSeq = ''; // reset dedup on button release }, { capture: true }, ); term.onData((data: string) => { - if (mouseButtonsHeld === 0 && data.startsWith('\x1b[<32;')) return; + if (isHoverMove && data.startsWith('\x1b[<32;')) { + // Hover: rewrite Cb 32 → 35 (no-button motion per SGR spec) + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(data.replace('\x1b[<32;', '\x1b[<35;')); + } + return; + } + if (data.startsWith('\x1b[<32;')) { + // Drag: drop duplicate same-cell events to prevent vim visual-mode toggle + if (data === lastDragSeq) return; + lastDragSeq = data; + } else { + lastDragSeq = ''; // any non-drag event resets the dedup guard + } if (ws && ws.readyState === WebSocket.OPEN) { ws.send(data); } From a81137502f66b9ca132a0e5eec8aa732c76247eb Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 26 Apr 2026 15:49:56 -0400 Subject: [PATCH 12/16] refactor: extract SGR mouse helpers into mouse.ts and add unit tests Move rewriteHoverMotion and isDuplicateDrag out of index.ts into a dedicated mouse.ts module so the pure transform logic can be unit-tested without a browser environment. Add mouse.test.ts with 12 tests covering hover rewrite, drag dedup, and all pass-through cases (press, release, scroll, plain text). index.ts now imports from mouse.ts; runtime behaviour is unchanged. --- src/client/index.ts | 48 ++++++---------------------- src/client/mouse.test.ts | 68 ++++++++++++++++++++++++++++++++++++++++ src/client/mouse.ts | 60 +++++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 39 deletions(-) create mode 100644 src/client/mouse.test.ts create mode 100644 src/client/mouse.ts diff --git a/src/client/index.ts b/src/client/index.ts index f7ee488..8a4485f 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,5 +1,6 @@ import { FitAddon, init, Terminal } from 'ghostty-web'; import { applyDecscusr } from './cursor'; +import { isDuplicateDrag, rewriteHoverMotion } from './mouse'; interface KeyboardBinding { key: string; @@ -247,33 +248,8 @@ window.addEventListener( ); // Forward terminal keystrokes and input to the PTY over WebSocket. -// -// ghostty-web bug: handleMouseMove always encodes motion as SGR button-32 -// (\x1b[<32;col;rowM) regardless of whether a button is held. It uses its -// own mouseButtonsPressed bitmask; when no button is pressed the bitmask is 0, -// and 0+32=32 — the same Cb value as a left-button drag. Real terminals use -// Cb=35 (32+3, "no button") for hover and Cb=32 for left-button drag. -// -// Impact on vim: with `set mouse=a` vim requests mode 1003 (any-event -// tracking). Every hover move arrives as \x1b[<32;…M which vim decodes as -// , toggling visual mode on each pixel of cursor movement. -// -// Fix 1 — hover rewrite: a capture-phase mousemove listener reads the DOM -// event's authoritative e.buttons *before* ghostty-web's bubble-phase handler -// encodes it. When e.buttons===0 (hover), onData rewrites \x1b[<32; to -// \x1b[<35; so vim receives the correct no-button code. The position is still -// forwarded so vim can track the cursor for subsequent drag operations. -// -// Fix 2 — drag dedup: ghostty-web fires multiple mousemove events for the same -// character cell while the pointer stays within it. Vim treats a -// at the same cell twice as a toggle (enter visual → exit visual), so we -// deduplicate consecutive identical drag sequences before forwarding. -// -// Safety on other platforms: the rewrite only fires when hasMouseTracking() -// is true AND e.buttons===0. On macOS (or any platform where ghostty-web -// correctly encodes hover as 35 or where vim uses mode 1002 and ghostty-web -// already skips hover motion), the condition is never met and the data passes -// through unchanged. +// See src/client/mouse.ts for the ghostty-web SGR bug and the two fixes +// (hover rewrite + drag dedup) applied below. let isHoverMove = false; let lastDragSeq = ''; container.addEventListener( @@ -285,20 +261,14 @@ container.addEventListener( { capture: true }, ); term.onData((data: string) => { - if (isHoverMove && data.startsWith('\x1b[<32;')) { - // Hover: rewrite Cb 32 → 35 (no-button motion per SGR spec) - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(data.replace('\x1b[<32;', '\x1b[<35;')); - } + const seq = rewriteHoverMotion(data, isHoverMove); + if (seq !== data) { + // Hover: forwarded with corrected Cb=35 (no-button motion per SGR spec) + if (ws && ws.readyState === WebSocket.OPEN) ws.send(seq); return; } - if (data.startsWith('\x1b[<32;')) { - // Drag: drop duplicate same-cell events to prevent vim visual-mode toggle - if (data === lastDragSeq) return; - lastDragSeq = data; - } else { - lastDragSeq = ''; // any non-drag event resets the dedup guard - } + if (isDuplicateDrag(data, lastDragSeq)) return; // drop same-cell drag repeat + lastDragSeq = data.startsWith('\x1b[<32;') ? data : ''; // track or reset if (ws && ws.readyState === WebSocket.OPEN) { ws.send(data); } diff --git a/src/client/mouse.test.ts b/src/client/mouse.test.ts new file mode 100644 index 0000000..67ab80d --- /dev/null +++ b/src/client/mouse.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test'; +import { isDuplicateDrag, rewriteHoverMotion } from './mouse'; + +describe('rewriteHoverMotion', () => { + test('rewrites \\x1b[<32; to \\x1b[<35; when isHover is true', () => { + expect(rewriteHoverMotion('\x1b[<32;8;4M', true)).toBe('\x1b[<35;8;4M'); + }); + + test('preserves col;row and M suffix after rewrite', () => { + expect(rewriteHoverMotion('\x1b[<32;120;50M', true)).toBe('\x1b[<35;120;50M'); + }); + + test('returns data unchanged when isHover is false (drag)', () => { + expect(rewriteHoverMotion('\x1b[<32;8;4M', false)).toBe('\x1b[<32;8;4M'); + }); + + test('returns data unchanged for non-motion sequences regardless of isHover', () => { + // left press + expect(rewriteHoverMotion('\x1b[<0;8;4M', true)).toBe('\x1b[<0;8;4M'); + // left release + expect(rewriteHoverMotion('\x1b[<0;8;4m', true)).toBe('\x1b[<0;8;4m'); + // scroll up + expect(rewriteHoverMotion('\x1b[<64;8;4M', true)).toBe('\x1b[<64;8;4M'); + // middle drag + expect(rewriteHoverMotion('\x1b[<33;8;4M', true)).toBe('\x1b[<33;8;4M'); + }); + + test('returns data unchanged for plain text', () => { + expect(rewriteHoverMotion('hello', true)).toBe('hello'); + }); + + test('does not rewrite when isHover is false even for button-32', () => { + // pointer moved to a new cell while button is held — must not rewrite + const drag = '\x1b[<32;9;4M'; + expect(rewriteHoverMotion(drag, false)).toBe(drag); + }); +}); + +describe('isDuplicateDrag', () => { + test('returns true for identical consecutive button-32 sequences', () => { + const seq = '\x1b[<32;8;4M'; + expect(isDuplicateDrag(seq, seq)).toBe(true); + }); + + test('returns false when position changes', () => { + expect(isDuplicateDrag('\x1b[<32;9;4M', '\x1b[<32;8;4M')).toBe(false); + }); + + test('returns false when lastDragSeq is empty (first drag event)', () => { + expect(isDuplicateDrag('\x1b[<32;8;4M', '')).toBe(false); + }); + + test('returns false for non-drag sequences even if identical', () => { + // press, release, scroll must never be deduped + expect(isDuplicateDrag('\x1b[<0;8;4M', '\x1b[<0;8;4M')).toBe(false); + expect(isDuplicateDrag('\x1b[<0;8;4m', '\x1b[<0;8;4m')).toBe(false); + expect(isDuplicateDrag('\x1b[<64;8;4M', '\x1b[<64;8;4M')).toBe(false); + }); + + test('returns false when row differs', () => { + expect(isDuplicateDrag('\x1b[<32;8;5M', '\x1b[<32;8;4M')).toBe(false); + }); + + test('returns true only for exact string match', () => { + expect(isDuplicateDrag('\x1b[<32;8;4M', '\x1b[<32;8;4M')).toBe(true); + expect(isDuplicateDrag('\x1b[<32;8;4M', '\x1b[<35;8;4M')).toBe(false); + }); +}); diff --git a/src/client/mouse.ts b/src/client/mouse.ts new file mode 100644 index 0000000..b0e7adb --- /dev/null +++ b/src/client/mouse.ts @@ -0,0 +1,60 @@ +/** + * SGR mouse sequence helpers. + * + * ghostty-web bug: handleMouseMove always encodes motion as SGR button-32 + * (\x1b[<32;col;rowM) regardless of whether a button is held. It uses its + * own mouseButtonsPressed bitmask; when no button is pressed the bitmask is 0, + * and 0+32=32 — the same Cb value as a left-button drag. Real terminals use + * Cb=35 (32+3, "no button") for hover and Cb=32 for left-button drag. + * + * Impact on vim: with `set mouse=a` vim requests mode 1003 (any-event + * tracking). Every hover move arrives as \x1b[<32;…M which vim decodes as + * , toggling visual mode on each pixel of cursor movement. + * + * The two exported functions implement the fixes applied in onData: + * + * - rewriteHoverMotion: when the DOM reports no button held (e.buttons===0), + * rewrite \x1b[<32; → \x1b[<35; so vim receives the correct no-button code + * (Cb=35 per the SGR spec). The position is still forwarded so vim can + * track the cursor for subsequent drag operations. + * + * - isDuplicateDrag: ghostty-web fires multiple mousemove events for the same + * character cell while the pointer stays within it. Vim treats a + * at the same cell twice as a visual-mode toggle (enter → exit), so + * consecutive identical drag sequences must be deduplicated. + */ + +/** Prefix that ghostty-web emits for both hover and left-button drag. */ +const DRAG_PREFIX = '\x1b[<32;'; +/** Correct SGR prefix for no-button hover motion (Cb = 32 + 3 = 35). */ +const HOVER_PREFIX = '\x1b[<35;'; + +/** + * If `isHover` is true and `data` is a ghostty-web hover-misencoded drag + * sequence, rewrite the button byte from 32 to 35 (the correct SGR no-button + * code) and return the corrected sequence. Otherwise return `data` unchanged. + * + * @param data Raw SGR sequence from ghostty-web's onData callback. + * @param isHover Whether the originating DOM mousemove had `e.buttons === 0`. + */ +export function rewriteHoverMotion(data: string, isHover: boolean): string { + if (isHover && data.startsWith(DRAG_PREFIX)) { + return HOVER_PREFIX + data.slice(DRAG_PREFIX.length); + } + return data; +} + +/** + * Returns true when `data` is a left-button drag sequence (`\x1b[<32;…M`) + * that is identical to the previous drag sequence, indicating the pointer + * has not moved to a new character cell. + * + * Callers should maintain `lastDragSeq` state and pass it in; on a false + * return they update `lastDragSeq = data`, on a true return they skip sending. + * + * @param data Raw SGR sequence from ghostty-web's onData callback. + * @param lastDragSeq The last drag sequence that was forwarded to the PTY. + */ +export function isDuplicateDrag(data: string, lastDragSeq: string): boolean { + return data.startsWith(DRAG_PREFIX) && data === lastDragSeq; +} From 68336509fc99255e64ed3a1ec10534137e9525ae Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 26 Apr 2026 16:22:34 -0400 Subject: [PATCH 13/16] fix: address copilot review comments --- src/cli/http.ts | 2 +- src/pty/index.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cli/http.ts b/src/cli/http.ts index b4c9549..861c3e3 100644 --- a/src/cli/http.ts +++ b/src/cli/http.ts @@ -86,7 +86,7 @@ export async function startServer(timeoutMs = 10000, _spawn = childProcess.spawn const child = _spawn(serverExec, [serverEntry], { detached: true, stdio, - env: { ...process.env, PORT: String(getPort()) }, + env: { ...process.env, PORT: String(config.port) }, }); child.on('error', (err) => { const hint = useNode diff --git a/src/pty/index.ts b/src/pty/index.ts index 878493a..fc1ae5f 100644 --- a/src/pty/index.ts +++ b/src/pty/index.ts @@ -1,6 +1,8 @@ export type { PtyProcess } from './types'; -// Bun.Terminal does not support the `terminal` option on Windows; fall back to node-pty. +// Bun.Terminal does not implement PTY on Windows (Bun.spawn({ terminal }) +// is a no-op there), so fall back to node-pty on that platform. +// On all other platforms, prefer Bun.Terminal when running under Bun. const isBun = !!process.versions.bun && process.platform !== 'win32'; console.log(`pty: ${isBun ? 'Bun.Terminal' : 'node-pty'}`); From 8e2a82e6005c32d68bb6da473c556964534bfbfa Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 26 Apr 2026 22:41:30 -0400 Subject: [PATCH 14/16] test: fix Windows-local test failures - commands.test.ts: restore HOME by deleting the key (not setting string 'undefined') when it was unset before the test mutated it - pty/index.test.ts: use COMSPEC/cmd.exe on Windows; skip interactive data test under Bun on Windows (Bun ConPTY pipe closes after initial banner) - pty/node.ts: pass /d to cmd.exe to disable AutoRun (clink registry hook) - websocket.test.ts: run server under Node on Windows+Bun (mirrors http.ts workaround); use platform NL for ws.send commands; broaden prompt regex to match cmd.exe prompt followed by ESC sequence --- src/cli/commands.test.ts | 3 ++- src/pty/index.test.ts | 28 ++++++++++++++++++++++------ src/pty/node.ts | 8 +++++++- src/server/websocket.test.ts | 36 ++++++++++++++++++++++++++++-------- 4 files changed, 59 insertions(+), 16 deletions(-) diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index 4184b46..4dded4f 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -682,7 +682,8 @@ describe('cli — unit (mocked http)', () => { {} as ReturnType, ); cmds.cmdConfig(); - process.env.HOME = origHome; + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; mkdirSpy.mockRestore(); spawnSpy.mockRestore(); }); diff --git a/src/pty/index.test.ts b/src/pty/index.test.ts index 51ef601..ceef3e7 100644 --- a/src/pty/index.test.ts +++ b/src/pty/index.test.ts @@ -13,9 +13,21 @@ function waitForData(received: string[], content: string, timeout = 3000): Promi }); } +// On Windows use COMSPEC (the full path to cmd.exe) to bypass any PATH shims +// that shell enhancers like clink install (clink wraps cmd.exe with a bat launcher). +const TEST_SHELL = process.platform === 'win32' ? (process.env.COMSPEC ?? 'cmd.exe') : '/bin/sh'; + +// On Windows, Bun's ConPTY/net.Socket integration has a known issue where the +// pipe closes after the initial banner is written, causing ERR_SOCKET_CLOSED on +// the first PTY write. The server always runs under Node on Windows (see http.ts), +// so skip the interactive data test under Bun on Windows — it passes in CI (Linux) +// and under Node on Windows. +const isBunOnWindows = + process.platform === 'win32' && typeof (globalThis as Record).Bun !== 'undefined'; + describe('spawnForSession', () => { test('returns a PtyProcess with the expected interface', () => { - const pty = spawnForSession(80, 24, '/bin/sh', 'xterm-256color', 'truecolor'); + const pty = spawnForSession(80, 24, TEST_SHELL, 'xterm-256color', 'truecolor'); expect(typeof pty.onData).toBe('function'); expect(typeof pty.onExit).toBe('function'); @@ -27,16 +39,20 @@ describe('spawnForSession', () => { pty.kill(); }); - test('spawned process can receive data', async () => { - const pty = spawnForSession(80, 24, '/bin/sh', 'xterm-256color', 'truecolor'); + test.skipIf(isBunOnWindows)('spawned process can receive data', async () => { + const pty = spawnForSession(80, 24, TEST_SHELL, 'xterm-256color', 'truecolor'); const received: string[] = []; pty.onData((data) => received.push(data)); - pty.write('echo __ready__\n'); + const echoReady = process.platform === 'win32' ? 'echo __ready__\r\n' : 'echo __ready__\n'; + const echoHello = process.platform === 'win32' ? 'echo hello-pty\r\n' : 'echo hello-pty\n'; + const exitCmd = process.platform === 'win32' ? 'exit\r\n' : 'exit\n'; + + pty.write(echoReady); await waitForData(received, '__ready__'); - pty.write('echo hello-pty\n'); + pty.write(echoHello); await waitForData(received, 'hello-pty'); - pty.write('exit\n'); + pty.write(exitCmd); await new Promise((resolve) => pty.onExit(() => resolve())); expect(received.join('')).toContain('hello-pty'); diff --git a/src/pty/node.ts b/src/pty/node.ts index f5fe706..21c93cb 100644 --- a/src/pty/node.ts +++ b/src/pty/node.ts @@ -19,7 +19,13 @@ export function spawn( term: string, colorTerm: string, ): PtyProcess { - const ptyProc = nodePty.spawn(shell, [], { + // On Windows, cmd.exe may have an AutoRun registry key that launches shell + // enhancers (e.g. clink). These take over the ConPTY pipe and cause + // ERR_SOCKET_CLOSED on the first write. Pass /d to disable AutoRun. + const shellArgs = + process.platform === 'win32' && /cmd\.exe$/i.test(shell) ? ['/d'] : []; + + const ptyProc = nodePty.spawn(shell, shellArgs, { name: term, cols, rows, diff --git a/src/server/websocket.test.ts b/src/server/websocket.test.ts index 0d641bc..e75c881 100644 --- a/src/server/websocket.test.ts +++ b/src/server/websocket.test.ts @@ -8,8 +8,19 @@ import { cleanupTmpHome, getFreePort, makeTmpHome, waitForServer } from '../util const ANSI_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); const stripAnsi = (s: string) => s.replace(ANSI_RE, ''); +// cmd.exe requires CR+LF to execute commands; sh/bash accept LF alone. +const NL = process.platform === 'win32' ? '\r\n' : '\n'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const SERVER_ENTRY = path.resolve(__dirname, 'index.ts'); + +// On Windows, Bun's ConPTY/net.Socket integration doesn't support the pipe +// handles node-pty requires. Mirror the same workaround as http.ts: run the +// server under Node when on Windows+Bun so node-pty works correctly. +const isBun = typeof (globalThis as Record).Bun !== 'undefined'; +const useNode = process.platform === 'win32' && isBun; +const SERVER_EXEC = useNode ? 'node' : process.execPath; +const SERVER_ENTRY = useNode + ? path.resolve(__dirname, '../../dist/server/index.js') + : path.resolve(__dirname, 'index.ts'); function connectWs(wsUrl: string): Promise<{ ws: WebSocket; messages: string[] }> { return new Promise((resolve, reject) => { @@ -39,7 +50,7 @@ function waitForPrompt(messages: string[], timeout = 3000): Promise { const deadline = Date.now() + timeout; const check = () => { const all = messages.join(''); - if (all.includes('\x1b]133;B') || all.match(/[$%#>➜] *$/m)) return resolve(); + if (all.includes('\x1b]133;B') || all.match(/[$%#>➜](?:\s|\x1b|$)/m)) return resolve(); if (Date.now() > deadline) return reject(new Error('Timeout waiting for shell prompt')); setTimeout(check, 50); }; @@ -80,8 +91,17 @@ describe('websocket', () => { port = await getFreePort(); baseUrl = `http://127.0.0.1:${port}`; wsBase = `ws://127.0.0.1:${port}`; - proc = spawn(process.execPath, [SERVER_ENTRY], { - env: { ...process.env, PORT: String(port), HOME: tmpHome, SHELL: '/bin/sh' }, + proc = spawn(SERVER_EXEC, [SERVER_ENTRY], { + env: { + ...process.env, + PORT: String(port), + HOME: tmpHome, + // Let the server resolve the shell via its own platform detection (config.ts). + // Forcing /bin/sh here breaks Windows where that path does not exist. + ...(process.platform !== 'win32' && { SHELL: '/bin/sh' }), + // On Windows, clink (if installed) auto-injects into cmd.exe and breaks PTY socket writes. + ...(process.platform === 'win32' && { CLINK_NOAUTORUN: '1' }), + }, stdio: 'ignore', }); await waitForServer(baseUrl); @@ -155,7 +175,7 @@ describe('websocket', () => { await waitForPrompt(m1); - ws1.send('echo hello-fanout\n'); + ws1.send(`echo hello-fanout${NL}`); await waitForContent(m1, 'hello-fanout'); await waitForContent(m2, 'hello-fanout'); @@ -177,7 +197,7 @@ describe('websocket', () => { await waitForMessages(messages, 1); const closeCode = new Promise((resolve) => ws.on('close', (code) => resolve(code))); - ws.send('exit\n'); + ws.send(`exit${NL}`); expect(await closeCode).toBe(4001); const res = await fetch(`${baseUrl}/api/sessions/ws-test-exit`); @@ -197,7 +217,7 @@ describe('websocket', () => { ws.send(JSON.stringify({ type: 'resize', cols: 120, rows: 40 })); - ws.send('echo resize-ok\n'); + ws.send(`echo resize-ok${NL}`); await waitForContent(messages, 'resize-ok'); await closeWs(ws); @@ -245,7 +265,7 @@ describe('websocket', () => { const { ws, messages } = await connectWs(`${wsBase}/ws/ws-test-last?cols=80&rows=24`); await waitForMessages(messages, 1); - ws.send('exit\n'); + ws.send(`exit${NL}`); await new Promise((resolve) => ws.on('close', () => resolve())); const deadline = Date.now() + 3000; From cae27ca49e8c0098e5fe83ab40d5e89211513f6a Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 26 Apr 2026 22:47:50 -0400 Subject: [PATCH 15/16] fix: guard configDir against non-absolute HOME, ignore undefined/ artifact --- .gitignore | 3 +++ src/config.ts | 10 +++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 83fb59d..b1921a3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ lcov.info .agents/ .claude/ .sisyphus/ + +# Artifact from tests running with HOME unset (HOME=undefined pollutes cwd) +undefined/ diff --git a/src/config.ts b/src/config.ts index 369c679..87392a1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -101,7 +101,15 @@ export interface Config { /** Returns the webtty config directory: `~/.config/webtty`. */ export function configDir(): string { - return path.join(process.env.HOME ?? os.homedir(), '.config', 'webtty'); + // os.homedir() is the authoritative home directory. process.env.HOME is used + // as an override in tests (e.g. to isolate config state), but only when it is + // an absolute path — guarding against accidental env pollution such as the + // string "undefined" being assigned when HOME was originally unset on Windows. + const home = + process.env.HOME && path.isAbsolute(process.env.HOME) + ? process.env.HOME + : os.homedir(); + return path.join(home, '.config', 'webtty'); } function getConfigPath(): string { From 9100dae04ac21e5300a89ee278d9dfe71aabe89e Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 26 Apr 2026 22:59:21 -0400 Subject: [PATCH 16/16] fix: lint and format errors from Windows test fixes --- src/config.ts | 4 +--- src/pty/index.test.ts | 3 ++- src/pty/node.ts | 3 +-- src/server/websocket.test.ts | 1 + 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/config.ts b/src/config.ts index 87392a1..1464601 100644 --- a/src/config.ts +++ b/src/config.ts @@ -106,9 +106,7 @@ export function configDir(): string { // an absolute path — guarding against accidental env pollution such as the // string "undefined" being assigned when HOME was originally unset on Windows. const home = - process.env.HOME && path.isAbsolute(process.env.HOME) - ? process.env.HOME - : os.homedir(); + process.env.HOME && path.isAbsolute(process.env.HOME) ? process.env.HOME : os.homedir(); return path.join(home, '.config', 'webtty'); } diff --git a/src/pty/index.test.ts b/src/pty/index.test.ts index ceef3e7..929cf4c 100644 --- a/src/pty/index.test.ts +++ b/src/pty/index.test.ts @@ -23,7 +23,8 @@ const TEST_SHELL = process.platform === 'win32' ? (process.env.COMSPEC ?? 'cmd.e // so skip the interactive data test under Bun on Windows — it passes in CI (Linux) // and under Node on Windows. const isBunOnWindows = - process.platform === 'win32' && typeof (globalThis as Record).Bun !== 'undefined'; + process.platform === 'win32' && + typeof (globalThis as Record).Bun !== 'undefined'; describe('spawnForSession', () => { test('returns a PtyProcess with the expected interface', () => { diff --git a/src/pty/node.ts b/src/pty/node.ts index 21c93cb..3639e8d 100644 --- a/src/pty/node.ts +++ b/src/pty/node.ts @@ -22,8 +22,7 @@ export function spawn( // On Windows, cmd.exe may have an AutoRun registry key that launches shell // enhancers (e.g. clink). These take over the ConPTY pipe and cause // ERR_SOCKET_CLOSED on the first write. Pass /d to disable AutoRun. - const shellArgs = - process.platform === 'win32' && /cmd\.exe$/i.test(shell) ? ['/d'] : []; + const shellArgs = process.platform === 'win32' && /cmd\.exe$/i.test(shell) ? ['/d'] : []; const ptyProc = nodePty.spawn(shell, shellArgs, { name: term, diff --git a/src/server/websocket.test.ts b/src/server/websocket.test.ts index e75c881..8cd61b8 100644 --- a/src/server/websocket.test.ts +++ b/src/server/websocket.test.ts @@ -50,6 +50,7 @@ function waitForPrompt(messages: string[], timeout = 3000): Promise { const deadline = Date.now() + timeout; const check = () => { const all = messages.join(''); + // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC (\x1b) is intentional — matching terminal escape sequences in PTY output if (all.includes('\x1b]133;B') || all.match(/[$%#>➜](?:\s|\x1b|$)/m)) return resolve(); if (Date.now() > deadline) return reject(new Error('Timeout waiting for shell prompt')); setTimeout(check, 50);