Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto eol=lf
Comment thread
jesse23 marked this conversation as resolved.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ lcov.info
.agents/
.claude/
.sisyphus/

# Artifact from tests running with HOME unset (HOME=undefined pollutes cwd)
undefined/
10 changes: 8 additions & 2 deletions src/cli/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
Expand Down Expand Up @@ -677,7 +682,8 @@ describe('cli — unit (mocked http)', () => {
{} as ReturnType<typeof childProcessModule.spawnSync>,
);
cmds.cmdConfig();
process.env.HOME = origHome;
if (origHome === undefined) delete process.env.HOME;
else process.env.HOME = origHome;
mkdirSpy.mockRestore();
spawnSpy.mockRestore();
});
Expand Down
14 changes: 7 additions & 7 deletions src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -27,11 +27,11 @@ export async function cmdGo(id = 'main'): Promise<void> {
}

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 }),
Expand All @@ -45,7 +45,7 @@ export async function cmdGo(id = 'main'): Promise<void> {
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);
}
Expand All @@ -58,7 +58,7 @@ export async function cmdGo(id = 'main'): Promise<void> {
export async function cmdList(filter?: string): Promise<void> {
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);
Expand Down Expand Up @@ -92,7 +92,7 @@ export async function cmdRemove(id?: string): Promise<void> {
}
let res: Response;
try {
res = await fetch(`${BASE_URL}/api/sessions/${encodeURIComponent(id)}`, {
res = await fetch(`${getBaseUrl()}/api/sessions/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
} catch {
Expand Down Expand Up @@ -127,7 +127,7 @@ export async function cmdRename(id?: string, newId?: string): Promise<void> {
}
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 }),
Expand Down
2 changes: 1 addition & 1 deletion src/cli/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});
Expand Down
42 changes: 30 additions & 12 deletions src/cli/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `PORT` env or config default. */
export const PORT = Number(process.env.PORT) || 2346;
/** 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 {
Expand All @@ -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<boolean> {
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);
Expand All @@ -47,11 +51,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);
Expand All @@ -71,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(config.port) },
});
child.on('error', (err) => {
const hint = useNode
Expand All @@ -95,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<boolean> {
export async function stopServer(
baseUrl: string = getBaseUrl(),
timeoutMs = 5000,
): Promise<boolean> {
try {
const res = await fetch(`${baseUrl}/api/server/stop`, { method: 'POST' });
if (!res.ok) return false;
Expand Down
40 changes: 38 additions & 2 deletions src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FitAddon, init, Terminal } from 'ghostty-web';
import { applyDecscusr } from './cursor';
import { isDuplicateDrag, rewriteHoverMotion } from './mouse';

interface KeyboardBinding {
key: string;
Expand Down Expand Up @@ -91,8 +92,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', scheduleFit);

const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let ws: WebSocket;
Expand Down Expand Up @@ -232,7 +248,27 @@ window.addEventListener(
);

// Forward terminal keystrokes and input to the PTY over WebSocket.
// 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(
'mousemove',
(e: MouseEvent) => {
isHoverMove = term.hasMouseTracking() && e.buttons === 0;
if (isHoverMove) lastDragSeq = ''; // reset dedup on button release
},
{ capture: true },
);
Comment thread
jesse23 marked this conversation as resolved.
term.onData((data: string) => {
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 (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);
}
Expand Down
68 changes: 68 additions & 0 deletions src/client/mouse.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
60 changes: 60 additions & 0 deletions src/client/mouse.ts
Original file line number Diff line number Diff line change
@@ -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
* <LeftDrag>, 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 <LeftDrag>
* 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;
}
8 changes: 7 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,13 @@ 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 {
Expand Down
Loading
Loading