From 5e1973981c871f33a07b371a786b109f6f13b71c Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 14:50:45 -0400 Subject: [PATCH 01/23] feat: rename 'at' command to 'go' for session management and update related documentation --- README.md | 7 +++---- src/cli/commands.test.ts | 12 ++++++------ src/cli/commands.ts | 6 ++++-- src/cli/index.ts | 6 +++--- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 232c6a6..84314d1 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,11 @@ Terminal UI in the browser. Run CLI/TUI applications in a browser tab, across platforms. ```sh -npx webtty # start server + open a terminal in the browser -npx webtty ls # list sessions -npx webtty help # show all commands +npx webtty # open main session in the browser +npx webtty go [id] # open a specific session by id ``` -## Debugging +## Development Build emits source maps (`dist/**/*.js.map`), so you can debug against the built output directly — no minification, original TypeScript line numbers preserved. diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index 6a39328..657eb8a 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -116,8 +116,8 @@ describe('cli — session management', () => { expect(stdout).toContain('no sessions'); }); - test('run creates a session and prints url', async () => { - const { stdout, exitCode } = await runCli(port, 'at', 'my-session'); + test('go creates a session and prints url', async () => { + const { stdout, exitCode } = await runCli(port, 'go', 'my-session'); expect(exitCode).toBe(0); expect(stdout).toContain(`/s/my-session`); @@ -125,14 +125,14 @@ describe('cli — session management', () => { expect(res.status).toBe(200); }); - test('run with existing id reuses session without error', async () => { - const { stdout, exitCode } = await runCli(port, 'at', 'my-session'); + test('go with existing id reuses session without error', async () => { + const { stdout, exitCode } = await runCli(port, 'go', 'my-session'); expect(exitCode).toBe(0); expect(stdout).toContain(`/s/my-session`); }); - test('run without id creates session with auto-generated id', async () => { - const { stdout, exitCode } = await runCli(port, 'at'); + test('go without id creates session with auto-generated id', async () => { + const { stdout, exitCode } = await runCli(port, 'go'); expect(exitCode).toBe(0); expect(stdout).toMatch(/\/s\/[a-f0-9]{8}/); }); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index d17cf4c..d282fa1 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -7,10 +7,12 @@ import { BASE_URL, isServerRunning, openBrowser, startServer, stopServer } from export function registerCommands(program: Command): void { program - .command('at [id]') + .command('go [id]') .alias('a') + .alias('run') + .alias('open') .alias('attach') - .description('Attach to a new or existing session and open it') + .description('Open a new or existing session in the browser') .action(async (id?: string) => { if (!(await isServerRunning())) { await startServer(); diff --git a/src/cli/index.ts b/src/cli/index.ts index fc10338..675c1a9 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,7 +1,7 @@ import { Command, type Help } from 'commander'; import { registerCommands } from './commands'; -const CMDS_WITH_ARGS = new Set(['at', 'rm', 'ls', 'mv']); +const CMDS_WITH_ARGS = new Set(['go', 'rm', 'ls', 'mv']); const CMD_NAME_WIDTH = 'mv'.length; const program = new Command(); @@ -36,7 +36,7 @@ program const usageWidth = 'webtty [command]'.length; const usageBlock = [ helper.styleTitle('Usage:'), - `${indent}${helper.styleUsage('webtty'.padEnd(usageWidth))} ${helper.styleCommandDescription('Attach to main session and open it')}`, + `${indent}${helper.styleUsage('webtty'.padEnd(usageWidth))} ${helper.styleCommandDescription('Open main session in the browser')}`, `${indent}${helper.styleUsage('webtty [command]'.padEnd(usageWidth))} ${helper.styleCommandDescription('Execute a specific command')}`, '', ]; @@ -77,7 +77,7 @@ program registerCommands(program); program.action(async () => { - await program.parseAsync(['at', 'main'], { from: 'user' }); + await program.parseAsync(['go', 'main'], { from: 'user' }); }); program.parseAsync(process.argv); From e197cc407dd834607246288717256ca212ec52c9 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 15:33:54 -0400 Subject: [PATCH 02/23] feat: remove 'commander' dependency and refactor command handling in CLI --- bun.lock | 3 - package.json | 1 - src/cli/commands.test.ts | 2 +- src/cli/commands.ts | 335 +++++++++++++++++---------------------- src/cli/index.ts | 141 ++++++++-------- 5 files changed, 211 insertions(+), 271 deletions(-) diff --git a/bun.lock b/bun.lock index 80b7d72..5a5ec30 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,6 @@ "name": "webtty", "dependencies": { "@lydell/node-pty": "1.2.0-beta.3", - "commander": "14.0.0", "ghostty-web": "0.4.0-next.14.g6a1a50d", "ws": "8.20.0", }, @@ -214,8 +213,6 @@ "color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], - "commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="], - "compare-func": ["compare-func@2.0.0", "", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="], "config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="], diff --git a/package.json b/package.json index 397e485..911a0a0 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ }, "dependencies": { "@lydell/node-pty": "1.2.0-beta.3", - "commander": "14.0.0", "ghostty-web": "0.4.0-next.14.g6a1a50d", "ws": "8.20.0" }, diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index 657eb8a..033ba11 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -53,7 +53,7 @@ describe('cli — lifecycle', () => { test('unknown command exits with error', async () => { const { stderr, exitCode } = await runCli(port, 'unknown'); expect(exitCode).toBe(1); - expect(stderr).toContain('error'); + expect(stderr).toContain('unknown command'); }); test('start launches the server', async () => { diff --git a/src/cli/commands.ts b/src/cli/commands.ts index d282fa1..adffc90 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -1,207 +1,166 @@ import * as childProcess from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import type { Command } from 'commander'; import { configDir } from '../config'; import { BASE_URL, isServerRunning, openBrowser, startServer, stopServer } from './http'; -export function registerCommands(program: Command): void { - program - .command('go [id]') - .alias('a') - .alias('run') - .alias('open') - .alias('attach') - .description('Open a new or existing session in the browser') - .action(async (id?: string) => { - if (!(await isServerRunning())) { - await startServer(); - } - - let sessionId: string; - if (id) { - const check = await fetch(`${BASE_URL}/api/sessions/${encodeURIComponent(id)}`); - if (check.status === 200) { - sessionId = id; - } else { - const res = await fetch(`${BASE_URL}/api/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id }), - }); - if (!res.ok) { - const body = (await res.json()) as { error?: string }; - console.error(`webtty: ${body.error ?? `failed to create session (${res.status})`}`); - process.exit(1); - } - const session = (await res.json()) as { id: string }; - sessionId = session.id; - } - } else { - const res = await fetch(`${BASE_URL}/api/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: '{}', - }); - if (!res.ok) { - const body = (await res.json()) as { error?: string }; - console.error(`webtty: ${body.error ?? `failed to create session (${res.status})`}`); - process.exit(1); - } - const session = (await res.json()) as { id: string }; - sessionId = session.id; - } - - const url = `${BASE_URL}/s/${sessionId}`; - console.log(url); - openBrowser(url); - }); +export async function cmdGo(id?: string): Promise { + if (!(await isServerRunning())) { + await startServer(); + } - program - .command('ls [id]') - .alias('list') - .description('List all sessions, or filter by id substring') - .action(async (filter?: string) => { - let res: Response; - try { - res = await fetch(`${BASE_URL}/api/sessions`); - } catch { - console.log('webtty is not running'); + let sessionId: string; + if (id) { + const check = await fetch(`${BASE_URL}/api/sessions/${encodeURIComponent(id)}`); + if (check.status === 200) { + sessionId = id; + } else { + const res = await fetch(`${BASE_URL}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string }; + console.error(`webtty: ${body.error ?? `failed to create session (${res.status})`}`); process.exit(1); } - const all = (await res.json()) as Array<{ - id: string; - connected: boolean; - createdAt: number; - }>; - const sessions = filter ? all.filter((s) => s.id.includes(filter)) : all; - if (sessions.length === 0) { - console.log('no sessions'); - return; - } - console.log('id\t\t\tconnected\tcreated'); - for (const s of sessions) { - const created = new Date(s.createdAt).toLocaleString(); - console.log(`${s.id}\t\t\t${s.connected}\t\t${created}`); - } + const session = (await res.json()) as { id: string }; + sessionId = session.id; + } + } else { + const res = await fetch(`${BASE_URL}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', }); + if (!res.ok) { + const body = (await res.json()) as { error?: string }; + console.error(`webtty: ${body.error ?? `failed to create session (${res.status})`}`); + process.exit(1); + } + const session = (await res.json()) as { id: string }; + sessionId = session.id; + } - program - .command('rm [id]') - .alias('remove') - .description('Destroy a session') - .action(async (id?: string) => { - if (!id) { - console.error('webtty: rm requires a session id'); - process.exit(1); - } - let res: Response; - try { - res = await fetch(`${BASE_URL}/api/sessions/${encodeURIComponent(id)}`, { - method: 'DELETE', - }); - } catch { - console.log('webtty is not running'); - process.exit(1); - } - if (res.status === 204) { - console.log(`removed ${id}`); - if (res.headers.get('x-sessions-remaining') === '0') { - await stopServer(); - console.log('no sessions remaining — webtty stopped'); - } - } else if (res.status === 404) { - console.error(`session ${id} not found`); - process.exit(1); - } else { - console.error(`webtty rm failed (status: ${res.status})`); - process.exit(1); - } - }); + const url = `${BASE_URL}/s/${sessionId}`; + console.log(url); + openBrowser(url); +} - program - .command('mv [id] [new-id]') - .alias('move') - .alias('rename') - .description('Rename a session') - .action(async (id?: string, newId?: string) => { - if (!id || !newId) { - console.error('webtty: rename requires two arguments: [id] [new-id]'); - process.exit(1); - } - let res: Response; - try { - res = await fetch(`${BASE_URL}/api/sessions/${encodeURIComponent(id)}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: newId }), - }); - } catch { - console.log('webtty is not running'); - process.exit(1); - } - if (res.ok) { - console.log(`renamed ${id} → ${newId}`); - } else if (res.status === 404) { - console.error(`session ${id} not found`); - process.exit(1); - } else { - const body = (await res.json()) as { error?: string }; - console.error(`webtty: ${body.error ?? `rename failed (${res.status})`}`); - process.exit(1); - } - }); +export async function cmdLs(filter?: string): Promise { + let res: Response; + try { + res = await fetch(`${BASE_URL}/api/sessions`); + } catch { + console.log('webtty is not running'); + process.exit(1); + } + const all = (await res.json()) as Array<{ + id: string; + connected: boolean; + createdAt: number; + }>; + const sessions = filter ? all.filter((s) => s.id.includes(filter)) : all; + if (sessions.length === 0) { + console.log('no sessions'); + return; + } + console.log('id\t\t\tconnected\tcreated'); + for (const s of sessions) { + const created = new Date(s.createdAt).toLocaleString(); + console.log(`${s.id}\t\t\t${s.connected}\t\t${created}`); + } +} - program - .command('stop') - .description('Stop the webtty server') - .action(async () => { - if (!(await isServerRunning())) { - console.log('webtty is not running'); - return; - } - const ok = await stopServer(); - if (ok) { - console.log('webtty stopped'); - } else { - console.error('webtty stop failed'); - process.exit(1); - } +export async function cmdRm(id?: string): Promise { + if (!id) { + console.error('webtty: rm requires a session id'); + process.exit(1); + } + let res: Response; + try { + res = await fetch(`${BASE_URL}/api/sessions/${encodeURIComponent(id)}`, { + method: 'DELETE', }); + } catch { + console.log('webtty is not running'); + process.exit(1); + } + if (res.status === 204) { + console.log(`removed ${id}`); + if (res.headers.get('x-sessions-remaining') === '0') { + await stopServer(); + console.log('no sessions remaining — webtty stopped'); + } + } else if (res.status === 404) { + console.error(`session ${id} not found`); + process.exit(1); + } else { + console.error(`webtty rm failed (status: ${res.status})`); + process.exit(1); + } +} - program - .command('start') - .description('Start the webtty server') - .action(async () => { - if (await isServerRunning()) { - console.log('webtty is already running'); - return; - } - await startServer(); - console.log('webtty started'); +export async function cmdMv(id?: string, newId?: string): Promise { + if (!id || !newId) { + console.error('webtty: rename requires two arguments: [id] [new-id]'); + process.exit(1); + } + let res: Response; + try { + res = await fetch(`${BASE_URL}/api/sessions/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: newId }), }); + } catch { + console.log('webtty is not running'); + process.exit(1); + } + if (res.ok) { + console.log(`renamed ${id} → ${newId}`); + } else if (res.status === 404) { + console.error(`session ${id} not found`); + process.exit(1); + } else { + const body = (await res.json()) as { error?: string }; + console.error(`webtty: ${body.error ?? `rename failed (${res.status})`}`); + process.exit(1); + } +} - program - .command('config') - .description('Open the config file in $EDITOR') - .action(() => { - const dir = configDir(); - const configPath = path.join(dir, 'config.json'); - fs.mkdirSync(dir, { recursive: true }); - if (!fs.existsSync(configPath)) { - fs.writeFileSync(configPath, '{}\n', 'utf8'); - } - const editor = - process.env.VISUAL ?? - process.env.EDITOR ?? - (process.platform === 'win32' ? 'notepad' : 'vi'); - childProcess.spawnSync(editor, [configPath], { stdio: 'inherit' }); - }); +export async function cmdStop(): Promise { + if (!(await isServerRunning())) { + console.log('webtty is not running'); + return; + } + const ok = await stopServer(); + if (ok) { + console.log('webtty stopped'); + } else { + console.error('webtty stop failed'); + process.exit(1); + } +} - program - .command('help') - .description('Show help — all commands and options') - .action(() => { - program.outputHelp(); - }); +export async function cmdStart(): Promise { + if (await isServerRunning()) { + console.log('webtty is already running'); + return; + } + await startServer(); + console.log('webtty started'); +} + +export function cmdConfig(): void { + const dir = configDir(); + const configPath = path.join(dir, 'config.json'); + fs.mkdirSync(dir, { recursive: true }); + if (!fs.existsSync(configPath)) { + fs.writeFileSync(configPath, '{}\n', 'utf8'); + } + const editor = + process.env.VISUAL ?? process.env.EDITOR ?? (process.platform === 'win32' ? 'notepad' : 'vi'); + childProcess.spawnSync(editor, [configPath], { stdio: 'inherit' }); } diff --git a/src/cli/index.ts b/src/cli/index.ts index 675c1a9..5e5981c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,83 +1,68 @@ -import { Command, type Help } from 'commander'; -import { registerCommands } from './commands'; +import { cmdConfig, cmdGo, cmdLs, cmdMv, cmdRm, cmdStart, cmdStop } from './commands'; -const CMDS_WITH_ARGS = new Set(['go', 'rm', 'ls', 'mv']); -const CMD_NAME_WIDTH = 'mv'.length; +const GO_ALIASES = new Set(['go', 'a', 'run', 'attach', 'open']); -const program = new Command(); -program - .name('webtty') - .description('Launch Terminal UI in the browser.') - .configureHelp({ - styleTitle(str: string): string { - return str.replace(/:$/, '').toUpperCase(); - }, - subcommandTerm(cmd: Command): string { - const args = cmd.registeredArguments - .map((arg) => (arg.required ? `<${arg.name()}>` : `[${arg.name()}]`)) - .join(' '); - const name = CMDS_WITH_ARGS.has(cmd.name()) ? cmd.name().padEnd(CMD_NAME_WIDTH) : cmd.name(); - return args ? `${name} ${args}` : name; - }, - formatHelp(cmd: Command, helper: Help): string { - const helpWidth = helper.helpWidth ?? 80; - const termWidth = helper.padWidth(cmd, helper); +function printHelp(): void { + const indent = ' '; + const col = 18; // width of the widest term: "mv " + const row = (term: string, desc: string) => `${indent}${term.padEnd(col)} ${desc}`; - const callFormatItem = (term: string, description: string) => - helper.formatItem(term, termWidth, description, helper); + console.log( + [ + 'Launch Terminal UI in the browser.', + '', + 'USAGE', + row('webtty', 'Open main session in the browser'), + row('webtty [command]', 'Execute a specific command'), + '', + 'COMMANDS', + row('go [id]', 'Open a new or existing session in the browser'), + row('ls [id]', 'List all sessions, or filter by id substring'), + row('rm ', 'Destroy a session'), + row('mv ', 'Rename a session'), + row('stop', 'Stop the webtty server'), + row('start', 'Start the webtty server'), + row('config', 'Open the config file in $EDITOR'), + row('help', 'Show this help message'), + ].join('\n'), + ); +} - const description = helper.commandDescription(cmd); - const descriptionBlock = - description.length > 0 - ? ['', helper.boxWrap(helper.styleCommandDescription(description), helpWidth), ''] - : []; +const [, , cmd, ...rest] = process.argv; - const indent = ' '; - const usageWidth = 'webtty [command]'.length; - const usageBlock = [ - helper.styleTitle('Usage:'), - `${indent}${helper.styleUsage('webtty'.padEnd(usageWidth))} ${helper.styleCommandDescription('Open main session in the browser')}`, - `${indent}${helper.styleUsage('webtty [command]'.padEnd(usageWidth))} ${helper.styleCommandDescription('Execute a specific command')}`, - '', - ]; - - const commandGroups = ( - helper as unknown as { - groupItems: ( - a: readonly Command[], - b: Command[], - c: (s: Command) => string, - ) => Map; - } - ).groupItems( - cmd.commands, - helper.visibleCommands(cmd), - (sub: Command) => - (sub as unknown as { helpGroup: () => string }).helpGroup?.() || 'Commands:', - ); - const commandsBlock: string[] = []; - commandGroups.forEach((commands: Command[], group: string) => { - const commandList = commands.map((sub: Command) => - callFormatItem( - helper.styleSubcommandTerm(helper.subcommandTerm(sub)), - helper.styleSubcommandDescription(helper.subcommandDescription(sub)), - ), - ); - commandsBlock.push( - ...( - helper as unknown as { formatItemList: (h: string, i: string[], hp: Help) => string[] } - ).formatItemList(group, commandList, helper), - ); - }); - - return [...descriptionBlock, ...usageBlock, ...commandsBlock].join('\n'); - }, - }); - -registerCommands(program); - -program.action(async () => { - await program.parseAsync(['go', 'main'], { from: 'user' }); -}); - -program.parseAsync(process.argv); +if (!cmd || GO_ALIASES.has(cmd)) { + await cmdGo(cmd && GO_ALIASES.has(cmd) ? rest[0] : undefined); +} else { + switch (cmd) { + case 'ls': + case 'list': + await cmdLs(rest[0]); + break; + case 'rm': + case 'remove': + await cmdRm(rest[0]); + break; + case 'mv': + case 'move': + case 'rename': + await cmdMv(rest[0], rest[1]); + break; + case 'stop': + await cmdStop(); + break; + case 'start': + await cmdStart(); + break; + case 'config': + cmdConfig(); + break; + case 'help': + case '--help': + case '-h': + printHelp(); + break; + default: + console.error(`webtty: unknown command '${cmd}'\nRun \`webtty help\` for usage.`); + process.exit(1); + } +} From f01f8d6d4aecae8435126d4a0f41503942cd7f79 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 16:20:58 -0400 Subject: [PATCH 03/23] feat: rename CLI commands for consistency and clarity --- src/cli/commands.ts | 6 +++--- src/cli/index.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/cli/commands.ts b/src/cli/commands.ts index adffc90..a49cdc8 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -48,7 +48,7 @@ export async function cmdGo(id?: string): Promise { openBrowser(url); } -export async function cmdLs(filter?: string): Promise { +export async function cmdList(filter?: string): Promise { let res: Response; try { res = await fetch(`${BASE_URL}/api/sessions`); @@ -73,7 +73,7 @@ export async function cmdLs(filter?: string): Promise { } } -export async function cmdRm(id?: string): Promise { +export async function cmdRemove(id?: string): Promise { if (!id) { console.error('webtty: rm requires a session id'); process.exit(1); @@ -102,7 +102,7 @@ export async function cmdRm(id?: string): Promise { } } -export async function cmdMv(id?: string, newId?: string): Promise { +export async function cmdRename(id?: string, newId?: string): Promise { if (!id || !newId) { console.error('webtty: rename requires two arguments: [id] [new-id]'); process.exit(1); diff --git a/src/cli/index.ts b/src/cli/index.ts index 5e5981c..14e1943 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,4 +1,4 @@ -import { cmdConfig, cmdGo, cmdLs, cmdMv, cmdRm, cmdStart, cmdStop } from './commands'; +import { cmdConfig, cmdGo, cmdList, cmdRename, cmdRemove, cmdStart, cmdStop } from './commands'; const GO_ALIASES = new Set(['go', 'a', 'run', 'attach', 'open']); @@ -36,16 +36,16 @@ if (!cmd || GO_ALIASES.has(cmd)) { switch (cmd) { case 'ls': case 'list': - await cmdLs(rest[0]); + await cmdList(rest[0]); break; case 'rm': case 'remove': - await cmdRm(rest[0]); + await cmdRemove(rest[0]); break; case 'mv': case 'move': case 'rename': - await cmdMv(rest[0], rest[1]); + await cmdRename(rest[0], rest[1]); break; case 'stop': await cmdStop(); From a17e2f3d8d72258129a6b4343d5577f0b59fe38d Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 18:06:18 -0400 Subject: [PATCH 04/23] feat: implement dynamic cursor style handling via DECSCUSR sequences --- README.md | 8 +++- docs/adrs/013.client.cursor-style.md | 61 ++++++++++++++++++++++++++++ docs/specs/config.md | 7 +++- src/client/cursor.ts | 33 +++++++++++++++ src/client/index.ts | 4 ++ src/config.ts | 6 +++ src/server/routes.ts | 1 + 7 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 docs/adrs/013.client.cursor-style.md create mode 100644 src/client/cursor.ts diff --git a/README.md b/README.md index 84314d1..340fc43 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,12 @@ Terminal UI in the browser. Run CLI/TUI applications in a browser tab, across platforms. ```sh -npx webtty # open main session in the browser -npx webtty go [id] # open a specific session by id +bunx webtty # open main session in the browser +bunx webtty go [id] # open a specific session by id + +# or with npx +npx webtty +npx webtty go [id] ``` ## Development diff --git a/docs/adrs/013.client.cursor-style.md b/docs/adrs/013.client.cursor-style.md new file mode 100644 index 0000000..897857e --- /dev/null +++ b/docs/adrs/013.client.cursor-style.md @@ -0,0 +1,61 @@ +# ADR 013: Client — DECSCUSR cursor style via PTY intercept + +**SPEC:** [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-03-25 + +--- + +## Context + +ghostty-web does not implement DECSCUSR (CSI Ps SP q) — the standard escape sequence for cursor shape and blink control (ECMA-48 / DEC). Applications like vim, neovim, and fish emit DECSCUSR to switch the cursor between bar (insert mode), block (normal mode), and underline, with optional blinking. + +The root cause is in ghostty-web's `GhosttyTerminal.getCursor()` (lib/ghostty.ts), which hardcodes `style: 'block'` with a TODO comment rather than reading the value from the WASM render state. The Ghostty WASM binary does process DECSCUSR correctly — `RenderState.Cursor.visual_style` is updated — but the JS wrapper never reads it back and never calls `renderer.setCursorStyle()` based on PTY output. + +The consequence: `cursorStyle` in config sets the initial shape at startup, but apps cannot change it at runtime. With `cursorStyle: 'bar'` (the preferred default), vim's normal mode cursor stays a bar instead of switching to block. + +## Decision + +Intercept DECSCUSR sequences in `src/client/cursor.ts` before passing data to `term.write()`. On each WebSocket message, scan for the pattern `ESC [ Ps SP q`, decode `Ps`, and update `term.options.cursorStyle` and `term.options.cursorBlink` directly. ghostty-web's options proxy forwards these immediately to the renderer via `renderer.setCursorStyle()` and `renderer.setCursorBlink()`. + +**DECSCUSR Ps mapping:** + +| Ps | Style | Blink | +|----|-------|-------| +| 0 | block | yes (default reset) | +| 1 | block | yes | +| 2 | block | no | +| 3 | underline | yes | +| 4 | underline | no | +| 5 | bar | yes | +| 6 | bar | no | + +**Config interaction:** `config.cursorStyle` and `config.cursorBlink` set the initial values at Terminal construction. DECSCUSR overrides them at runtime. The two compose cleanly: config is the default, apps switch dynamically as needed. + +The intercept lives in `src/client/cursor.ts`, isolated from the WebSocket and terminal wiring in `index.ts`. It is removed when ghostty-web implements DECSCUSR natively. + +## Considered Options + +**Option A: Patch ghostty-web** + +ghostty-web would need to call `ghostty_render_state_get` with key `cursor_visual_style` (data key 10) after each `write()`, diff the result against the last known style, and call `renderer.setCursorStyle()` on change. This is the correct long-term fix but requires a PR to an external repo and a version bump. The client-side intercept is an equivalent workaround that can be removed once upstream ships it. + +**Option B: Parse DECSCUSR in the server WebSocket handler** + +Rejected — the server is a dumb pipe. Cursor state is a client rendering concern. Moving it to the server would couple rendering logic to the PTY transport. + +**Option C: Leave cursor shape as static config only** + +Rejected — vim in normal mode showing a bar cursor is confusing. The shell default and application overrides are a standard terminal UX expectation. + +## Consequences + +- vim, neovim, and fish normal mode show a block cursor; insert mode shows a bar. Blink state follows the app's preference. +- `config.cursorStyle` still works as the startup default — apps that don't emit DECSCUSR use whatever the user configured. +- The intercept adds one regex scan per WebSocket message. DECSCUSR sequences are rare (only on mode change), so the scan almost always yields zero matches and exits immediately. +- When ghostty-web adds native DECSCUSR support, `cursor.ts` and the `applyDecscusr` call in `index.ts` can be deleted with no other changes. + +## Related Decisions + +- [ADR 010 — Client UX polish](010.client.ux-polish.md): established the WebSocket message handling in `index.ts` that this intercept hooks into +- [ADR 008 — Config](008.webtty.config.md): established `cursorBlink` as a config key; `cursorStyle` is added alongside it diff --git a/docs/specs/config.md b/docs/specs/config.md index 3ee722c..c645ce0 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -92,7 +92,7 @@ spawn PTY with fresh: shell, term, colorTerm, scrollback - **Env overrides**: `PORT` overrides `config.port` at runtime. Applied after file load, never written back. - **Hot config reload**: - `port` / `host` — locked at startup (server socket already bound; restart required). - - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorBlink`, `scrollback`, `theme`, `copyOnSelect`, `rightClickBehavior` — re-read on every tab reload. + - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorBlink`, `scrollback`, `theme`, `copyOnSelect`, `rightClickBehavior` — re-read on every tab reload. - `shell`, `term`, `colorTerm`, `scrollback` — re-read when a new PTY is spawned (i.e. first connection to a session that has no running shell). - An already-running session is never affected mid-flight. @@ -110,6 +110,7 @@ All keys are optional — omit any key to use the default value. | `scrollback` | number | `262144` | PTY history buffer in bytes; used for server-side replay on reload/reconnect | | `cols` | number | `80` | Initial terminal width in columns | | `rows` | number | `24` | Initial terminal height in rows | +| `cursorStyle` | string | `"bar"` | Default cursor shape: `"bar"` (vertical line), `"block"`, or `"underline"`. Applications can override at runtime via DECSCUSR escape sequences. | | `cursorBlink` | boolean | `true` | Whether the cursor blinks | | `copyOnSelect` | boolean | `true` | Auto-copy selection to clipboard on mouseup (kitty / Windows Terminal style) | | `rightClickBehavior` | string | `"default"` | Right-click behavior: `"copyPaste"` copies selection + clears it if selection exists, otherwise native menu; `"default"` always shows native context menu. Invalid values fall back to `"default"` | @@ -158,6 +159,7 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter "scrollback": 262144, "cols": 80, "rows": 24, + "cursorStyle": "bar", "cursorBlink": true, "copyOnSelect": true, "rightClickBehavior": "default", @@ -195,7 +197,8 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter |---------|-------------|-----|-------| | Config lifecycle | First-run write, merge with defaults, env overrides, hot-reload on tab reload | [ADR 008](../adrs/008.webtty.config.md) | ✅ | | Server settings | `port`, `host` — locked at startup; `shell`, `term`, `colorTerm` — applied per new PTY | [ADR 008](../adrs/008.webtty.config.md) | ✅ | -| Terminal appearance | `cols`, `rows`, `fontSize`, `fontFamily`, `cursorBlink`, `scrollback`, `theme` — re-read on tab reload | [ADR 008](../adrs/008.webtty.config.md) | ✅ | +| Terminal appearance | `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorBlink`, `scrollback`, `theme` — re-read on tab reload | [ADR 008](../adrs/008.webtty.config.md) | ✅ | | Hot config reload | Appearance re-read on tab reload; shell/PTY settings re-read on new PTY spawn; `port`/`host` locked for server lifetime | [ADR 009](../adrs/009.webtty.config-hot-reload.md) | ✅ | | Copy behavior | `copyOnSelect` + `rightClickBehavior` — configurable clipboard copy matching VS Code / kitty conventions | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Server logs | `logs: true` appends server stdout/stderr to `~/.config/webtty/server.log` | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | +| Cursor style | `cursorStyle` sets the default cursor shape; DECSCUSR sequences from apps override at runtime | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | diff --git a/src/client/cursor.ts b/src/client/cursor.ts new file mode 100644 index 0000000..ce562ad --- /dev/null +++ b/src/client/cursor.ts @@ -0,0 +1,33 @@ +import type { Terminal } from 'ghostty-web'; + +// ghostty-web does not yet read cursor style from the WASM render state after +// write() — getCursor() hardcodes style: 'block' (see TODO in ghostty-web source). +// As a workaround, we intercept DECSCUSR sequences (CSI Ps SP q) from PTY output +// and apply them directly via the options proxy, which forwards to the renderer. +// +// DECSCUSR codes (ECMA-48 / DEC): +// 0, 1 — blinking block (0 = default) +// 2 — steady block +// 3 — blinking underline +// 4 — steady underline +// 5 — blinking bar +// 6 — steady bar +// +// config.cursorStyle sets the initial shape at startup; PTY sequences override +// it at runtime. The two compose cleanly: config is your default, apps (vim, +// fish normal mode, etc.) switch dynamically as needed. + +const ESC = '\x1b'; +const DECSCUSR = new RegExp(`${ESC}\\[(\\d*) q`, 'g'); + +export function applyDecscusr(term: Terminal, data: string): void { + DECSCUSR.lastIndex = 0; + let match = DECSCUSR.exec(data); + while (match !== null) { + const ps = match[1] === '' ? 0 : Number(match[1]); + term.options.cursorStyle = + ps === 0 || ps === 1 || ps === 2 ? 'block' : ps === 3 || ps === 4 ? 'underline' : 'bar'; + term.options.cursorBlink = ps === 0 || ps === 1 || ps === 3 || ps === 5; + match = DECSCUSR.exec(data); + } +} diff --git a/src/client/index.ts b/src/client/index.ts index 6b3d6e7..048ff2c 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,4 +1,5 @@ import { FitAddon, init, Terminal } from 'ghostty-web'; +import { applyDecscusr } from './cursor'; interface Theme { background?: string; @@ -28,6 +29,7 @@ interface ClientConfig { rows: number; fontSize: number; fontFamily: string; + cursorStyle: 'block' | 'bar' | 'underline'; cursorBlink: boolean; scrollback: number; theme: Theme; @@ -45,6 +47,7 @@ await init(); const term = new Terminal({ cols: config.cols, rows: config.rows, + cursorStyle: config.cursorStyle, cursorBlink: config.cursorBlink, fontSize: config.fontSize, fontFamily: config.fontFamily, @@ -79,6 +82,7 @@ function connect(): void { }; ws.onmessage = (event: MessageEvent) => { + applyDecscusr(term, event.data); term.write(event.data); }; diff --git a/src/config.ts b/src/config.ts index db7dd47..0d842a8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -38,6 +38,7 @@ export interface Config { rows: number; fontSize: number; fontFamily: string; + cursorStyle: 'block' | 'bar' | 'underline'; cursorBlink: boolean; copyOnSelect: boolean; rightClickBehavior: RightClickBehavior; @@ -90,6 +91,7 @@ export const DEFAULT_CONFIG: Config = { rows: 24, fontSize: 13, fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace", + cursorStyle: 'bar' as 'block' | 'bar' | 'underline', cursorBlink: true, copyOnSelect: true, rightClickBehavior: 'default' as RightClickBehavior, @@ -138,6 +140,10 @@ export function loadConfig(): Config { ...(typeof p.rows === 'number' && { rows: p.rows }), ...(typeof p.fontSize === 'number' && { fontSize: p.fontSize }), ...(typeof p.fontFamily === 'string' && { fontFamily: p.fontFamily }), + ...(typeof p.cursorStyle === 'string' && + (p.cursorStyle === 'block' || p.cursorStyle === 'bar' || p.cursorStyle === 'underline') && { + cursorStyle: p.cursorStyle, + }), ...(typeof p.cursorBlink === 'boolean' && { cursorBlink: p.cursorBlink }), ...(typeof p.copyOnSelect === 'boolean' && { copyOnSelect: p.copyOnSelect }), ...(typeof p.rightClickBehavior === 'string' && { diff --git a/src/server/routes.ts b/src/server/routes.ts index ff2479b..0d6d9dd 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -69,6 +69,7 @@ export async function handleRequest( rows: config.rows, fontSize: config.fontSize, fontFamily: config.fontFamily, + cursorStyle: config.cursorStyle, cursorBlink: config.cursorBlink, scrollback: config.scrollback, theme: config.theme, From 50a83ae8feecf4a2ebac002c38ac1d0f5707d2f3 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 19:52:29 -0400 Subject: [PATCH 05/23] feat: update cursor configuration to use cursorStyleBlink instead of cursorBlink --- docs/adrs/013.client.cursor-style.md | 6 +++--- docs/specs/config.md | 12 ++++++------ src/client/index.ts | 4 ++-- src/config.test.ts | 8 ++++---- src/config.ts | 6 +++--- src/server/routes.ts | 3 ++- 6 files changed, 20 insertions(+), 19 deletions(-) diff --git a/docs/adrs/013.client.cursor-style.md b/docs/adrs/013.client.cursor-style.md index 897857e..1af6ef8 100644 --- a/docs/adrs/013.client.cursor-style.md +++ b/docs/adrs/013.client.cursor-style.md @@ -16,7 +16,7 @@ The consequence: `cursorStyle` in config sets the initial shape at startup, but ## Decision -Intercept DECSCUSR sequences in `src/client/cursor.ts` before passing data to `term.write()`. On each WebSocket message, scan for the pattern `ESC [ Ps SP q`, decode `Ps`, and update `term.options.cursorStyle` and `term.options.cursorBlink` directly. ghostty-web's options proxy forwards these immediately to the renderer via `renderer.setCursorStyle()` and `renderer.setCursorBlink()`. +Intercept DECSCUSR sequences in `src/client/cursor.ts` before passing data to `term.write()`. On each WebSocket message, scan for the pattern `ESC [ Ps SP q`, decode `Ps`, and update `term.options.cursorStyle` and `term.options.cursorStyleBlink` directly. ghostty-web's options proxy forwards these immediately to the renderer via `renderer.setCursorStyle()` and `renderer.setCursorBlink()`. **DECSCUSR Ps mapping:** @@ -30,7 +30,7 @@ Intercept DECSCUSR sequences in `src/client/cursor.ts` before passing data to `t | 5 | bar | yes | | 6 | bar | no | -**Config interaction:** `config.cursorStyle` and `config.cursorBlink` set the initial values at Terminal construction. DECSCUSR overrides them at runtime. The two compose cleanly: config is the default, apps switch dynamically as needed. +**Config interaction:** `config.cursorStyle` and `config.cursorStyleBlink` set the initial values at Terminal construction. DECSCUSR overrides them at runtime. The two compose cleanly: config is the default, apps switch dynamically as needed. The intercept lives in `src/client/cursor.ts`, isolated from the WebSocket and terminal wiring in `index.ts`. It is removed when ghostty-web implements DECSCUSR natively. @@ -58,4 +58,4 @@ Rejected — vim in normal mode showing a bar cursor is confusing. The shell def ## Related Decisions - [ADR 010 — Client UX polish](010.client.ux-polish.md): established the WebSocket message handling in `index.ts` that this intercept hooks into -- [ADR 008 — Config](008.webtty.config.md): established `cursorBlink` as a config key; `cursorStyle` is added alongside it +- [ADR 008 — Config](008.webtty.config.md): established `cursorStyleBlink` as a config key; `cursorStyle` is added alongside it diff --git a/docs/specs/config.md b/docs/specs/config.md index c645ce0..df5608a 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -63,7 +63,7 @@ loadConfig() — re-read file from disk │ ▼ render HTML with fresh appearance settings injected: -cols, rows, fontSize, fontFamily, cursorBlink, scrollback, theme +cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollback, theme ``` ### New PTY spawn (first WebSocket connection to a session) @@ -92,7 +92,7 @@ spawn PTY with fresh: shell, term, colorTerm, scrollback - **Env overrides**: `PORT` overrides `config.port` at runtime. Applied after file load, never written back. - **Hot config reload**: - `port` / `host` — locked at startup (server socket already bound; restart required). - - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorBlink`, `scrollback`, `theme`, `copyOnSelect`, `rightClickBehavior` — re-read on every tab reload. + - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `theme`, `copyOnSelect`, `rightClickBehavior` — re-read on every tab reload. `cursorStyle` and `cursorStyleBlink` set the startup defaults; apps override them at runtime via DECSCUSR. - `shell`, `term`, `colorTerm`, `scrollback` — re-read when a new PTY is spawned (i.e. first connection to a session that has no running shell). - An already-running session is never affected mid-flight. @@ -110,8 +110,8 @@ All keys are optional — omit any key to use the default value. | `scrollback` | number | `262144` | PTY history buffer in bytes; used for server-side replay on reload/reconnect | | `cols` | number | `80` | Initial terminal width in columns | | `rows` | number | `24` | Initial terminal height in rows | -| `cursorStyle` | string | `"bar"` | Default cursor shape: `"bar"` (vertical line), `"block"`, or `"underline"`. Applications can override at runtime via DECSCUSR escape sequences. | -| `cursorBlink` | boolean | `true` | Whether the cursor blinks | +| `cursorStyle` | string | `"bar"` | Default cursor shape: `"bar"` (vertical line), `"block"`, or `"underline"`. Apps override at runtime via DECSCUSR — this is the startup default only. | +| `cursorStyleBlink` | boolean | `true` | Default blink state. Apps override at runtime via DECSCUSR — this is the startup default only. | | `copyOnSelect` | boolean | `true` | Auto-copy selection to clipboard on mouseup (kitty / Windows Terminal style) | | `rightClickBehavior` | string | `"default"` | Right-click behavior: `"copyPaste"` copies selection + clears it if selection exists, otherwise native menu; `"default"` always shows native context menu. Invalid values fall back to `"default"` | | `logs` | boolean | `false` | Write server stdout/stderr to `~/.config/webtty/server.log`. Appends on each start. Default `false` — server runs silently. | @@ -160,7 +160,7 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter "cols": 80, "rows": 24, "cursorStyle": "bar", - "cursorBlink": true, + "cursorStyleBlink": true, "copyOnSelect": true, "rightClickBehavior": "default", "fontSize": 13, @@ -197,7 +197,7 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter |---------|-------------|-----|-------| | Config lifecycle | First-run write, merge with defaults, env overrides, hot-reload on tab reload | [ADR 008](../adrs/008.webtty.config.md) | ✅ | | Server settings | `port`, `host` — locked at startup; `shell`, `term`, `colorTerm` — applied per new PTY | [ADR 008](../adrs/008.webtty.config.md) | ✅ | -| Terminal appearance | `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorBlink`, `scrollback`, `theme` — re-read on tab reload | [ADR 008](../adrs/008.webtty.config.md) | ✅ | +| Terminal appearance | `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `theme` — re-read on tab reload | [ADR 008](../adrs/008.webtty.config.md) | ✅ | | Hot config reload | Appearance re-read on tab reload; shell/PTY settings re-read on new PTY spawn; `port`/`host` locked for server lifetime | [ADR 009](../adrs/009.webtty.config-hot-reload.md) | ✅ | | Copy behavior | `copyOnSelect` + `rightClickBehavior` — configurable clipboard copy matching VS Code / kitty conventions | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Server logs | `logs: true` appends server stdout/stderr to `~/.config/webtty/server.log` | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | diff --git a/src/client/index.ts b/src/client/index.ts index 048ff2c..df08d2d 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -30,7 +30,7 @@ interface ClientConfig { fontSize: number; fontFamily: string; cursorStyle: 'block' | 'bar' | 'underline'; - cursorBlink: boolean; + cursorStyleBlink: boolean; scrollback: number; theme: Theme; copyOnSelect: boolean; @@ -48,7 +48,7 @@ const term = new Terminal({ cols: config.cols, rows: config.rows, cursorStyle: config.cursorStyle, - cursorBlink: config.cursorBlink, + cursorBlink: config.cursorStyleBlink, fontSize: config.fontSize, fontFamily: config.fontFamily, scrollback: Math.ceil(config.scrollback / 80), diff --git a/src/config.test.ts b/src/config.test.ts index bed982d..fc1d932 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -58,7 +58,7 @@ describe('loadConfig — first run', () => { expect(config.cols).toBe(DEFAULT_CONFIG.cols); expect(config.rows).toBe(DEFAULT_CONFIG.rows); expect(config.fontSize).toBe(DEFAULT_CONFIG.fontSize); - expect(config.cursorBlink).toBe(DEFAULT_CONFIG.cursorBlink); + expect(config.cursorStyleBlink).toBe(DEFAULT_CONFIG.cursorStyleBlink); expect(config.scrollback).toBe(DEFAULT_CONFIG.scrollback); expect(config.theme).toEqual(DEFAULT_CONFIG.theme); }); @@ -120,9 +120,9 @@ describe('loadConfig — reads and merges', () => { expect(config.fontFamily).toBe('Menlo'); }); - test('overrides cursorBlink when set to false', () => { - writeConfig(JSON.stringify({ cursorBlink: false })); - expect(loadConfig().cursorBlink).toBe(false); + test('overrides cursorStyleBlink when set to false', () => { + writeConfig(JSON.stringify({ cursorStyleBlink: false })); + expect(loadConfig().cursorStyleBlink).toBe(false); }); test('overrides cols and rows when set in file', () => { diff --git a/src/config.ts b/src/config.ts index 0d842a8..fbb382a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -39,7 +39,7 @@ export interface Config { fontSize: number; fontFamily: string; cursorStyle: 'block' | 'bar' | 'underline'; - cursorBlink: boolean; + cursorStyleBlink: boolean; copyOnSelect: boolean; rightClickBehavior: RightClickBehavior; logs: boolean; @@ -92,7 +92,7 @@ export const DEFAULT_CONFIG: Config = { fontSize: 13, fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace", cursorStyle: 'bar' as 'block' | 'bar' | 'underline', - cursorBlink: true, + cursorStyleBlink: true, copyOnSelect: true, rightClickBehavior: 'default' as RightClickBehavior, logs: false, @@ -144,7 +144,7 @@ export function loadConfig(): Config { (p.cursorStyle === 'block' || p.cursorStyle === 'bar' || p.cursorStyle === 'underline') && { cursorStyle: p.cursorStyle, }), - ...(typeof p.cursorBlink === 'boolean' && { cursorBlink: p.cursorBlink }), + ...(typeof p.cursorStyleBlink === 'boolean' && { cursorStyleBlink: p.cursorStyleBlink }), ...(typeof p.copyOnSelect === 'boolean' && { copyOnSelect: p.copyOnSelect }), ...(typeof p.rightClickBehavior === 'string' && { rightClickBehavior: (p.rightClickBehavior === 'copyPaste' diff --git a/src/server/routes.ts b/src/server/routes.ts index 0d6d9dd..1108482 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -64,13 +64,14 @@ export async function handleRequest( if (req.method === 'GET' && pathname === '/api/config') { const config = loadConfig(); + // Whitelist client-safe keys — avoid exposing server-side config (shell, host, logs, etc.) const clientConfig = { cols: config.cols, rows: config.rows, fontSize: config.fontSize, fontFamily: config.fontFamily, cursorStyle: config.cursorStyle, - cursorBlink: config.cursorBlink, + cursorStyleBlink: config.cursorStyleBlink, scrollback: config.scrollback, theme: config.theme, copyOnSelect: config.copyOnSelect, From adba3b5b3a85776ec220ce47aa32f12d29ca0c5b Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 20:27:58 -0400 Subject: [PATCH 06/23] Add create-live-spec skill and SPEC template - Introduced the create-live-spec skill for generating SPEC documents. - Defined the workflow for creating a new SPEC, including gathering intent, deriving short titles, and generating the SPEC file. - Added a SPEC template to standardize the format of new SPEC documents, including placeholders for title, author, description, features, and status. --- LICENSE | 21 ++++++++++++++++++ README.md | 4 ++-- {assets => docs/assets}/icon.svg | 0 {assets => docs/assets}/social-preview.png | Bin .../skills}/create-live-spec/SKILL.md | 0 .../create-live-spec/assets/spec-template.md | 0 package.json | 1 + skills-lock.json | 2 +- 8 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 LICENSE rename {assets => docs/assets}/icon.svg (100%) rename {assets => docs/assets}/social-preview.png (100%) rename {skills => docs/skills}/create-live-spec/SKILL.md (100%) rename {skills => docs/skills}/create-live-spec/assets/spec-template.md (100%) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a07d914 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 webtty contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 340fc43..555e3dc 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ - + # webtty -Terminal UI in the browser. Run CLI/TUI applications in a browser tab, across platforms. +Terminal UI in the browser. Run CLI/TUI applications in a browser tab, across platforms. Powered by [ghostty-web](https://github.com/coder/ghostty-web). ```sh bunx webtty # open main session in the browser diff --git a/assets/icon.svg b/docs/assets/icon.svg similarity index 100% rename from assets/icon.svg rename to docs/assets/icon.svg diff --git a/assets/social-preview.png b/docs/assets/social-preview.png similarity index 100% rename from assets/social-preview.png rename to docs/assets/social-preview.png diff --git a/skills/create-live-spec/SKILL.md b/docs/skills/create-live-spec/SKILL.md similarity index 100% rename from skills/create-live-spec/SKILL.md rename to docs/skills/create-live-spec/SKILL.md diff --git a/skills/create-live-spec/assets/spec-template.md b/docs/skills/create-live-spec/assets/spec-template.md similarity index 100% rename from skills/create-live-spec/assets/spec-template.md rename to docs/skills/create-live-spec/assets/spec-template.md diff --git a/package.json b/package.json index 911a0a0..62b3035 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "webtty", "version": "0.0.0-development", "description": "Web TTY for running CLI/TUI applications in a browser tab, across platforms", + "license": "MIT", "bin": { "webtty": "dist/cli/index.js" }, diff --git a/skills-lock.json b/skills-lock.json index 5624456..02d5603 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -7,7 +7,7 @@ "computedHash": "bffeafc71791924809ca9e24bfe3f18e87b2a060dbe5484468b4ff06c8e693b2" }, "create-live-spec": { - "source": "./skills/create-live-spec", + "source": "./docs/skills/create-live-spec", "sourceType": "local", "computedHash": "9c83b94d12109ef8823e227b0e9e5d58a48eab636cee7da11a1b446098dcb474" } From 42ae47fb4e6d7132e3467bd9d0b59277349e339e Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 20:44:32 -0400 Subject: [PATCH 07/23] feat: add comprehensive guide on using the browser as a computing environment --- docs/awesome-web.md | 88 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/awesome-web.md diff --git a/docs/awesome-web.md b/docs/awesome-web.md new file mode 100644 index 0000000..828d4ad --- /dev/null +++ b/docs/awesome-web.md @@ -0,0 +1,88 @@ +# Awesome Web + +A personal guide to living in the browser. + +## Why the Browser + +The browser is where you already spend your time. One window manager, sync across devices, no install friction. The web platform caught up — most apps you need actually run well in it now. + +You don't need a native app for everything. Seriously. The browser handles email, documents, spreadsheets, code editing, terminals, and design tools. Pick a browser, set it up right, and you've got a complete computing environment that works on any device. + +## Best Practices + +### Browser Choice + +Pick one, stick with it. Cross-device sync matters more than features. + +| Browser | Why Pick It | +|---------|------------| +| **Vivaldi** | Most customizable, power users, tab stacking | +| **Edge** | Best Windows integration, solid dev tools, built-in sidebar apps | +| **Chrome** | Widest compatibility, best DevTools | +| **Firefox** | Privacy-first, strong extension ecosystem | + +### Productivity Suite + +#### Google Workspace + +Google was the first to push the browser-first model seriously. Docs, Sheets, Slides, Meet, Gmail — all web-native from the start. Still the gold standard for real-time collaboration. + +If you're starting fresh or don't have org constraints, Google Workspace is the easiest path. Everything syncs, everything works offline, and sharing is built in. + +#### Microsoft 365 + +Office Online has caught up. Word, Excel, PowerPoint in the browser are now good enough for most tasks. OneDrive integration is seamless on Windows. If your org is on M365, lean into it — Teams, Outlook, and the Office apps all work in the browser. + +### Password Manager + +**Bitwarden** — open source, browser extension works everywhere, self-hostable if you want. **1Password** is the premium alternative with better UX. Both work purely in the browser with no native app required. + +Pick one and stop managing passwords in your head. + +### IDE + +VS Code has a server mode (code-server, vscode.dev, GitHub Codespaces). Run the server anywhere, connect from any browser. Full LSP, extensions, terminal — same editor, no install on the client. + +If you're on GitHub, Codespaces is the easiest path. For self-hosted, code-server on a VPS or local machine works well. You get the full VS Code experience without touching your local machine's filesystem. + +### Terminal + +Three approaches to a browser terminal: + +| Tool | Sessions | Multi-tab | Windows | Notes | +|------|----------|-----------|---------|-------| +| **ttyd** | ❌ | ❌ | ✅ | Simple, one shell per URL, no state | +| **Zellij web mode** | ✅ | ✅ | ❌ | Full multiplexer, but Linux/macOS only | +| **webtty** | ✅ | ✅ | ✅ | Lightweight, session-aware, cross-platform | + +ttyd is fine if you just need a quick shell in the browser. Zellij web mode is powerful but doesn't run on Windows. webtty is the middle ground — sessions, reconnect, multiple terminals, works everywhere. + +### Terminal Software Recommendations + +What to run inside your browser terminal. These are the tools that make terminal work actually pleasant: + +**Shell** +- **fish** — sensible defaults, autosuggestions, no config required to be useful + +**Editor** +- **NvChad** (Neovim) — full IDE feel in the terminal, built-in LSP and syntax highlighting + +**File Manager** +- **yazi** — fast, terminal file manager with preview + +**Git** +- **gitui** — terminal UI for git, better than memorizing flags + +**Multiplexer / Layout** +- **Zellij** — terminal workspace with layouts; pairs well with webtty for managing multiple sessions + +**Prompt** +- **starship** — fast, minimal, works with any shell + +**AI** +- **OpenCode** — open-source AI coding agent in the terminal +- **Claude Code** — Anthropic's CLI coding assistant + +--- + +The browser is no longer a limitation. It's where the best tools live now. From bcb952dcff964b13939b9d82f0a55ba1fa2bc9cd Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 21:06:25 -0400 Subject: [PATCH 08/23] feat: enhance browser recommendations and productivity tools in the guide --- docs/awesome-web.md | 48 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/docs/awesome-web.md b/docs/awesome-web.md index 828d4ad..8340fee 100644 --- a/docs/awesome-web.md +++ b/docs/awesome-web.md @@ -16,34 +16,60 @@ Pick one, stick with it. Cross-device sync matters more than features. | Browser | Why Pick It | |---------|------------| -| **Vivaldi** | Most customizable, power users, tab stacking | -| **Edge** | Best Windows integration, solid dev tools, built-in sidebar apps | -| **Chrome** | Widest compatibility, best DevTools | -| **Firefox** | Privacy-first, strong extension ecosystem | +| **[Vivaldi](https://vivaldi.com)** | Most customizable — hide the address bar entirely for a minimal, distraction-free UI | +| **[Arc](https://arc.net)** | Minimal by default — no tab bar, no address bar, sidebar-first | +| **[Zen](https://zen-browser.app)** | Same minimal philosophy as Arc, open source | +| **[Edge](https://microsoft.com/edge)** | Enable vertical tab bar to collapse the top area to a single line | +| **[Chrome](https://google.com/chrome)** | Enable vertical tab bar to collapse the top area to a single line | ### Productivity Suite #### Google Workspace -Google was the first to push the browser-first model seriously. Docs, Sheets, Slides, Meet, Gmail — all web-native from the start. Still the gold standard for real-time collaboration. +Google was the first to push the browser-first model seriously. All web-native from the start, still the gold standard for real-time collaboration. + +- [Gmail](https://mail.google.com) +- [Docs](https://docs.google.com) +- [Sheets](https://sheets.google.com) +- [Slides](https://slides.google.com) +- [Drive](https://drive.google.com) +- [Meet](https://meet.google.com) +- [Calendar](https://calendar.google.com) If you're starting fresh or don't have org constraints, Google Workspace is the easiest path. Everything syncs, everything works offline, and sharing is built in. #### Microsoft 365 -Office Online has caught up. Word, Excel, PowerPoint in the browser are now good enough for most tasks. OneDrive integration is seamless on Windows. If your org is on M365, lean into it — Teams, Outlook, and the Office apps all work in the browser. +Office Online has caught up. Word, Excel, PowerPoint in the browser are now good enough for most tasks. If your org is on M365, lean into it — everything works in the browser. -### Password Manager +- [Outlook](https://outlook.live.com) +- [Word](https://word.office.com) +- [Excel](https://excel.office.com) +- [PowerPoint](https://powerpoint.office.com) +- [OneDrive](https://onedrive.live.com) +- [Teams](https://teams.microsoft.com) -**Bitwarden** — open source, browser extension works everywhere, self-hostable if you want. **1Password** is the premium alternative with better UX. Both work purely in the browser with no native app required. +### Password Manager -Pick one and stop managing passwords in your head. +**[KeeWeb](https://keeweb.info)** — KeePass-compatible, open source, works as an offline web app with no install. Syncs your `.kdbx` file via Dropbox, Google Drive, OneDrive, or your own server. Desktop apps available too if you want them. ### IDE -VS Code has a server mode (code-server, vscode.dev, GitHub Codespaces). Run the server anywhere, connect from any browser. Full LSP, extensions, terminal — same editor, no install on the client. +VS Code has three browser modes — they're different products, often confused: + +**[VS Code `serve-web`](https://code.visualstudio.com/docs/remote/vscode-server)** — Run `code serve-web` on your machine, open the URL in any browser on your network. Fully self-hosted, no Microsoft infrastructure involved. Full VS Code with terminal, extensions, and debugger. Best for local network access from a tablet or secondary device. + +**[code-server](https://github.com/coder/code-server)** — Open source, self-hosted VS Code server by Coder. Same idea as `serve-web` but community-driven, more deployment options, and multi-user capable. Total control over your setup. + +**[vscode.dev](https://vscode.dev)** — Runs entirely in your browser, no server needed. Zero setup, works on any device. Opens GitHub repos directly (`vscode.dev/github//`). No terminal, no debugger, and many extensions don't work because there's no backend to run them on. -If you're on GitHub, Codespaces is the easiest path. For self-hosted, code-server on a VPS or local machine works well. You get the full VS Code experience without touching your local machine's filesystem. +| | `serve-web` | code-server | vscode.dev | +|--|-------------|-------------|------------| +| Terminal | ✅ | ✅ | ❌ | +| Self-hosted | ✅ | ✅ | ❌ | +| Extensions | ✅ full | ✅ full | ⚠️ limited | +| Setup | Easy | Medium | None | +| Best for | Local network | Self-hosted teams | Quick browsing | ### Terminal From 9399a83451f115f7498be3327811b40508b9047b Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 21:14:31 -0400 Subject: [PATCH 09/23] feat: update terminal tool descriptions for clarity and accuracy --- docs/awesome-web.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/awesome-web.md b/docs/awesome-web.md index 8340fee..7929724 100644 --- a/docs/awesome-web.md +++ b/docs/awesome-web.md @@ -77,9 +77,9 @@ Three approaches to a browser terminal: | Tool | Sessions | Multi-tab | Windows | Notes | |------|----------|-----------|---------|-------| -| **ttyd** | ❌ | ❌ | ✅ | Simple, one shell per URL, no state | -| **Zellij web mode** | ✅ | ✅ | ❌ | Full multiplexer, but Linux/macOS only | | **webtty** | ✅ | ✅ | ✅ | Lightweight, session-aware, cross-platform | +| **ttyd** | ❌ | ❌ | ✅ | Simple, one shell per URL; session terminates when WebSocket connection is lost | +| **Zellij** (web mode) | ✅ | ✅ | ❌ | Full multiplexer with web mode support, but Linux/macOS only | ttyd is fine if you just need a quick shell in the browser. Zellij web mode is powerful but doesn't run on Windows. webtty is the middle ground — sessions, reconnect, multiple terminals, works everywhere. From a76ff38141b5477885875800f1e5b12f101df134 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 21:50:52 -0400 Subject: [PATCH 10/23] feat: refine browser guide content for clarity and completeness --- docs/awesome-web.md | 78 ++++++++++++++++++++++++--------------------- 1 file changed, 41 insertions(+), 37 deletions(-) diff --git a/docs/awesome-web.md b/docs/awesome-web.md index 7929724..69de4c5 100644 --- a/docs/awesome-web.md +++ b/docs/awesome-web.md @@ -4,9 +4,11 @@ A personal guide to living in the browser. ## Why the Browser -The browser is where you already spend your time. One window manager, sync across devices, no install friction. The web platform caught up — most apps you need actually run well in it now. +The browser is where you already spend your time. One window, sync across devices, no install friction. The web platform caught up — most apps you need run well in it now. -You don't need a native app for everything. Seriously. The browser handles email, documents, spreadsheets, code editing, terminals, and design tools. Pick a browser, set it up right, and you've got a complete computing environment that works on any device. +**The principle**: if a web version exists and it's good enough, use it. Not because native is bad, but because staying in the browser means fewer windows, fewer context switches, and a setup that works the same everywhere — your main machine, a work laptop, a tablet, or a borrowed computer. + +You don't need a native app for everything. ## Best Practices @@ -22,6 +24,10 @@ Pick one, stick with it. Cross-device sync matters more than features. | **[Edge](https://microsoft.com/edge)** | Enable vertical tab bar to collapse the top area to a single line | | **[Chrome](https://google.com/chrome)** | Enable vertical tab bar to collapse the top area to a single line | +### Password Manager + +**[KeeWeb](https://keeweb.info)** — KeePass-compatible, open source, works as an offline web app with no install. Syncs your `.kdbx` file via Dropbox, Google Drive, OneDrive, or your own server. Desktop apps available too if you want them. + ### Productivity Suite #### Google Workspace @@ -49,15 +55,11 @@ Office Online has caught up. Word, Excel, PowerPoint in the browser are now good - [OneDrive](https://onedrive.live.com) - [Teams](https://teams.microsoft.com) -### Password Manager - -**[KeeWeb](https://keeweb.info)** — KeePass-compatible, open source, works as an offline web app with no install. Syncs your `.kdbx` file via Dropbox, Google Drive, OneDrive, or your own server. Desktop apps available too if you want them. - ### IDE VS Code has three browser modes — they're different products, often confused: -**[VS Code `serve-web`](https://code.visualstudio.com/docs/remote/vscode-server)** — Run `code serve-web` on your machine, open the URL in any browser on your network. Fully self-hosted, no Microsoft infrastructure involved. Full VS Code with terminal, extensions, and debugger. Best for local network access from a tablet or secondary device. +**[VS Code `serve-web`](https://code.visualstudio.com/docs/remote/vscode-server)** — Run `code serve-web` on your machine, open the URL in any browser. Fully self-hosted, no Microsoft infrastructure. Full VS Code with terminal, extensions, and debugger — the browser-first way to run your editor. **[code-server](https://github.com/coder/code-server)** — Open source, self-hosted VS Code server by Coder. Same idea as `serve-web` but community-driven, more deployment options, and multi-user capable. Total control over your setup. @@ -73,41 +75,43 @@ VS Code has three browser modes — they're different products, often confused: ### Terminal -Three approaches to a browser terminal: +Great native terminals exist — [Ghostty](https://ghostty.org), [Alacritty](https://alacritty.org), [WezTerm](https://wezfurlong.org/wezterm), [Windows Terminal](https://aka.ms/terminal) — but a browser terminal keeps you in one window, makes sessions just URLs, and removes the context switch between editor and terminal. On Windows especially, the native multiplexer story is weak — no tmux, limited Zellij support — and the browser fills that gap naturally. -| Tool | Sessions | Multi-tab | Windows | Notes | -|------|----------|-----------|---------|-------| -| **webtty** | ✅ | ✅ | ✅ | Lightweight, session-aware, cross-platform | -| **ttyd** | ❌ | ❌ | ✅ | Simple, one shell per URL; session terminates when WebSocket connection is lost | -| **Zellij** (web mode) | ✅ | ✅ | ❌ | Full multiplexer with web mode support, but Linux/macOS only | +Here's every known approach and how they compare: -ttyd is fine if you just need a quick shell in the browser. Zellij web mode is powerful but doesn't run on Windows. webtty is the middle ground — sessions, reconnect, multiple terminals, works everywhere. +| Tool | Sessions | Windows | Notes | +|------|----------|---------|-------| +| **[webtty](https://github.com/jess23/webtty)** (current repo) | ✅ | ✅ | Lightweight, session-aware, cross-platform | +| **[VibeTunnel](https://github.com/amantus-ai/vibetunnel)** | ✅ | ❌ | macOS/Linux, built for AI agent monitoring, native menu bar app + `vt` command wrapper | +| **[ttyd](https://github.com/tsl0922/ttyd)** | ❌ | ✅ | One shell per URL; session terminates when the connection drops | +| **[GoTTY](https://github.com/yudai/gotty)** | ❌ | ❌ | Lightweight Go tool, abandoned since 2017 | +| **[Zellij](https://zellij.dev)** (web mode) | ✅ | ❌ | Full multiplexer with web mode, Linux/macOS only | ### Terminal Software Recommendations -What to run inside your browser terminal. These are the tools that make terminal work actually pleasant: - -**Shell** -- **fish** — sensible defaults, autosuggestions, no config required to be useful - -**Editor** -- **NvChad** (Neovim) — full IDE feel in the terminal, built-in LSP and syntax highlighting - -**File Manager** -- **yazi** — fast, terminal file manager with preview - -**Git** -- **gitui** — terminal UI for git, better than memorizing flags - -**Multiplexer / Layout** -- **Zellij** — terminal workspace with layouts; pairs well with webtty for managing multiple sessions - -**Prompt** -- **starship** — fast, minimal, works with any shell - -**AI** -- **OpenCode** — open-source AI coding agent in the terminal -- **Claude Code** — Anthropic's CLI coding assistant +Good pieces for a solid terminal workflow: + +| Name | Type | Description | +|------|------|-------------| +| **fish** | Shell | Sensible defaults, autosuggestions, no config required to be useful | +| **NvChad** (Neovim) | Editor | Full IDE feel in the terminal, built-in LSP and syntax highlighting | +| **yazi** | File Manager | Fast terminal file manager with preview | +| **gitui** | Git | Terminal UI for git, better than memorizing flags | +| **Zellij** | Multiplexer | Terminal workspace with layouts; pairs well with webtty for multiple sessions | +| **starship** | Prompt | Fast, minimal, works with any shell | + +### AI Agents + +These tools do more than write code — they plan, execute commands, manage files, search the web, and work through multi-step tasks autonomously. Think of them less as assistants and more as a second pair of hands that runs in your terminal. + +| Name | Subscription | Description | +|------|-------------|-------------| +| **[OpenCode](https://github.com/sst/opencode)** | GitHub Copilot | Open-source terminal AI agent, provider-agnostic | +| **[Claude Code](https://docs.anthropic.com/claude-code)** | Claude Pro ($20/mo) or Max ($100/$200/mo) | Anthropic's terminal agent — strong at reasoning and long multi-step tasks | +| **[GitHub Copilot CLI](https://docs.github.com/en/copilot)** | Free ($0) / Pro ($10/mo) / Pro+ ($39/mo) | GitHub-native terminal agent with `/plan`, `/fleet` for parallel execution | +| **[Gemini CLI](https://github.com/google-gemini/gemini-cli)** | Free (1k req/day) / Google One AI Premium | Google's open-source terminal agent, generous free tier, 1M token context | +| **[Codex CLI](https://github.com/openai/codex)** | ChatGPT Plus/Pro/Team | OpenAI's terminal agent, lightweight, runs locally | +| **[Aider](https://aider.chat)** | GitHub Copilot | Lightweight terminal pair programmer, excellent git integration | --- From 7bb35d0ae68283992bbc684505ac6333df142bc7 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 21:53:14 -0400 Subject: [PATCH 11/23] feat: enhance terminal tool descriptions with links and additional options --- docs/awesome-web.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/awesome-web.md b/docs/awesome-web.md index 69de4c5..434c24a 100644 --- a/docs/awesome-web.md +++ b/docs/awesome-web.md @@ -93,12 +93,15 @@ Good pieces for a solid terminal workflow: | Name | Type | Description | |------|------|-------------| -| **fish** | Shell | Sensible defaults, autosuggestions, no config required to be useful | -| **NvChad** (Neovim) | Editor | Full IDE feel in the terminal, built-in LSP and syntax highlighting | -| **yazi** | File Manager | Fast terminal file manager with preview | -| **gitui** | Git | Terminal UI for git, better than memorizing flags | -| **Zellij** | Multiplexer | Terminal workspace with layouts; pairs well with webtty for multiple sessions | -| **starship** | Prompt | Fast, minimal, works with any shell | +| **[fish](https://fishshell.com)** | Shell | Sensible defaults, autosuggestions, no config required to be useful | +| **[Clink](https://chrisant996.github.io/clink)** | Shell (Windows) | Powerful Bash-style line editing and completions for Windows cmd.exe | +| **[MSYS2](https://www.msys2.org)** | Shell (Windows) | Unix-like shell environment on Windows with pacman package manager | +| **[NvChad](https://nvchad.com)** (Neovim) | Editor | Full IDE feel in the terminal, built-in LSP and syntax highlighting. Note: has unresolved lagging issues | +| **[vim](https://www.vim.org)** | Editor | Self-customized vim is more efficient for vibe coding — no framework overhead | +| **[yazi](https://yazi-rs.github.io)** | File Manager | Fast terminal file manager with preview | +| **[gitui](https://github.com/extrawurst/gitui)** | Git | Terminal UI for git, better than memorizing flags | +| **[Zellij](https://zellij.dev)** | Multiplexer | Terminal workspace with layouts; pairs well with webtty for multiple sessions | +| **[starship](https://starship.rs)** | Prompt | Fast, minimal, works with any shell | ### AI Agents From 9c49ab52eae4deb353ce3a8271f3b68504a8092a Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 22:23:34 -0400 Subject: [PATCH 12/23] feat: update README and awesome-web guide for improved clarity and organization --- README.md | 4 +++- docs/awesome-web.md | 36 +++++++++++++++++++++++++++--------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 555e3dc..6718dba 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Terminal UI in the browser. Run CLI/TUI applications in a browser tab, across platforms. Powered by [ghostty-web](https://github.com/coder/ghostty-web). +- [Why webtty?](docs/awesome-web.md#terminal) + ```sh bunx webtty # open main session in the browser bunx webtty go [id] # open a specific session by id @@ -17,7 +19,7 @@ npx webtty go [id] Build emits source maps (`dist/**/*.js.map`), so you can debug against the built output directly — no minification, original TypeScript line numbers preserved. -``` +```sh bun run build bun --inspect run dist/server/index.js # or diff --git a/docs/awesome-web.md b/docs/awesome-web.md index 434c24a..dfb1fda 100644 --- a/docs/awesome-web.md +++ b/docs/awesome-web.md @@ -35,10 +35,10 @@ Pick one, stick with it. Cross-device sync matters more than features. Google was the first to push the browser-first model seriously. All web-native from the start, still the gold standard for real-time collaboration. - [Gmail](https://mail.google.com) -- [Docs](https://docs.google.com) +- [Drive](https://drive.google.com) - [Sheets](https://sheets.google.com) +- [Docs](https://docs.google.com) - [Slides](https://slides.google.com) -- [Drive](https://drive.google.com) - [Meet](https://meet.google.com) - [Calendar](https://calendar.google.com) @@ -49,11 +49,21 @@ If you're starting fresh or don't have org constraints, Google Workspace is the Office Online has caught up. Word, Excel, PowerPoint in the browser are now good enough for most tasks. If your org is on M365, lean into it — everything works in the browser. - [Outlook](https://outlook.live.com) +- [Teams](https://teams.microsoft.com) +- [OneDrive](https://onedrive.live.com) - [Word](https://word.office.com) - [Excel](https://excel.office.com) - [PowerPoint](https://powerpoint.office.com) -- [OneDrive](https://onedrive.live.com) -- [Teams](https://teams.microsoft.com) + +#### AI Assistants + +The major AI assistants all live in the browser — no install needed. + +- [M365 Copilot](https://microsoft365.com/copilot) +- [Claude](https://claude.ai) +- [ChatGPT](https://chatgpt.com) +- [Gemini](https://gemini.google.com) +- [Grok](https://grok.com) ### IDE @@ -94,18 +104,27 @@ Good pieces for a solid terminal workflow: | Name | Type | Description | |------|------|-------------| | **[fish](https://fishshell.com)** | Shell | Sensible defaults, autosuggestions, no config required to be useful | +| **[starship](https://starship.rs)** | Shell | Fast, minimal shell prompt, works with any shell | | **[Clink](https://chrisant996.github.io/clink)** | Shell (Windows) | Powerful Bash-style line editing and completions for Windows cmd.exe | | **[MSYS2](https://www.msys2.org)** | Shell (Windows) | Unix-like shell environment on Windows with pacman package manager | +| **[Zellij](https://zellij.dev)** | Multiplexer | Terminal workspace with layouts; pairs well with webtty for multiple sessions | | **[NvChad](https://nvchad.com)** (Neovim) | Editor | Full IDE feel in the terminal, built-in LSP and syntax highlighting. Note: has unresolved lagging issues | | **[vim](https://www.vim.org)** | Editor | Self-customized vim is more efficient for vibe coding — no framework overhead | | **[yazi](https://yazi-rs.github.io)** | File Manager | Fast terminal file manager with preview | | **[gitui](https://github.com/extrawurst/gitui)** | Git | Terminal UI for git, better than memorizing flags | -| **[Zellij](https://zellij.dev)** | Multiplexer | Terminal workspace with layouts; pairs well with webtty for multiple sessions | -| **[starship](https://starship.rs)** | Prompt | Fast, minimal, works with any shell | +| **[lazygit](https://github.com/jesseduffield/lazygit)** | Git | Alternative git TUI, more opinionated workflow | +| **[delta](https://github.com/dandavison/delta)** | Git | Syntax-highlighting pager for git diffs — configure as `core.pager` in gitconfig | +| **[fzf](https://github.com/junegunn/fzf)** | Search | Fuzzy finder for files, history, and anything else piped to it | +| **[fd](https://github.com/sharkdp/fd)** | Search | Fast, user-friendly alternative to `find` | +| **[ripgrep](https://github.com/BurntSushi/ripgrep)** | Search | Blazing fast grep — respects `.gitignore` by default | +| **[eza](https://eza.rocks)** | Utility | Modern `ls` replacement with icons, git status, and tree view | +| **[bottom](https://github.com/ClementTsang/bottom)** | Utility | Cross-platform system monitor with a TUI | +| **[glow](https://github.com/charmbracelet/glow)** | Utility | Render markdown in the terminal with style | +| **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** | Utility | Download video/audio from YouTube and hundreds of other sites | -### AI Agents +### Agentic CLI -These tools do more than write code — they plan, execute commands, manage files, search the web, and work through multi-step tasks autonomously. Think of them less as assistants and more as a second pair of hands that runs in your terminal. +CLI tools that go beyond code completion — they plan, execute commands, manage files, search the web, and work through multi-step tasks autonomously in your terminal. | Name | Subscription | Description | |------|-------------|-------------| @@ -114,7 +133,6 @@ These tools do more than write code — they plan, execute commands, manage file | **[GitHub Copilot CLI](https://docs.github.com/en/copilot)** | Free ($0) / Pro ($10/mo) / Pro+ ($39/mo) | GitHub-native terminal agent with `/plan`, `/fleet` for parallel execution | | **[Gemini CLI](https://github.com/google-gemini/gemini-cli)** | Free (1k req/day) / Google One AI Premium | Google's open-source terminal agent, generous free tier, 1M token context | | **[Codex CLI](https://github.com/openai/codex)** | ChatGPT Plus/Pro/Team | OpenAI's terminal agent, lightweight, runs locally | -| **[Aider](https://aider.chat)** | GitHub Copilot | Lightweight terminal pair programmer, excellent git integration | --- From a05943bce7178704398629a9de15a223196c0119 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 22:24:48 -0400 Subject: [PATCH 13/23] feat: add help command instructions for bunx and npx usage in README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 6718dba..b2928eb 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,12 @@ Terminal UI in the browser. Run CLI/TUI applications in a browser tab, across pl ```sh bunx webtty # open main session in the browser bunx webtty go [id] # open a specific session by id +bunx webtty help # show all commands # or with npx npx webtty npx webtty go [id] +npx webtty help ``` ## Development From 3b3b76195bc71b90f206a44c02bea28c81cafb10 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 22:28:57 -0400 Subject: [PATCH 14/23] feat: update 'go' command behavior to open main session by default --- src/cli/commands.test.ts | 4 ++-- src/cli/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index 033ba11..e669db2 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -131,10 +131,10 @@ describe('cli — session management', () => { expect(stdout).toContain(`/s/my-session`); }); - test('go without id creates session with auto-generated id', async () => { + test('go without id opens main session', async () => { const { stdout, exitCode } = await runCli(port, 'go'); expect(exitCode).toBe(0); - expect(stdout).toMatch(/\/s\/[a-f0-9]{8}/); + expect(stdout).toContain('/s/main'); }); test('ls shows created sessions', async () => { diff --git a/src/cli/index.ts b/src/cli/index.ts index 14e1943..9d6eeb6 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,4 +1,4 @@ -import { cmdConfig, cmdGo, cmdList, cmdRename, cmdRemove, cmdStart, cmdStop } from './commands'; +import { cmdConfig, cmdGo, cmdList, cmdRemove, cmdRename, cmdStart, cmdStop } from './commands'; const GO_ALIASES = new Set(['go', 'a', 'run', 'attach', 'open']); @@ -31,7 +31,7 @@ function printHelp(): void { const [, , cmd, ...rest] = process.argv; if (!cmd || GO_ALIASES.has(cmd)) { - await cmdGo(cmd && GO_ALIASES.has(cmd) ? rest[0] : undefined); + await cmdGo(cmd && GO_ALIASES.has(cmd) ? (rest[0] ?? 'main') : 'main'); } else { switch (cmd) { case 'ls': From 9e5047305db37f5160885252df8de4acc792e67a Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 22:39:18 -0400 Subject: [PATCH 15/23] feat: add CI badge and npm version badge to README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index b2928eb..b2d5b2a 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ # webtty +[![npm version](https://img.shields.io/npm/v/webtty)](https://www.npmjs.com/package/webtty) +[![CI](https://github.com/jesse23/webtty/actions/workflows/ci.yml/badge.svg)](https://github.com/jesse23/webtty/actions/workflows/ci.yml) + Terminal UI in the browser. Run CLI/TUI applications in a browser tab, across platforms. Powered by [ghostty-web](https://github.com/coder/ghostty-web). - [Why webtty?](docs/awesome-web.md#terminal) From 8feb5eb63033afedec7896bdadc413c9f4adb764 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 22:48:15 -0400 Subject: [PATCH 16/23] feat: update CLI commands to replace 'at' with 'go' for session management --- docs/specs/cli.md | 8 ++++---- docs/specs/client.md | 9 +++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/specs/cli.md b/docs/specs/cli.md index 1ca8c7b..4edab65 100644 --- a/docs/specs/cli.md +++ b/docs/specs/cli.md @@ -15,13 +15,13 @@ The CLI communicates with the server exclusively over HTTP — no Unix sockets, | Command | Description | |---------|-------------| -| `webtty at [id]` | Start server if not running; attach to session (creates if new, reuses if exists); open in browser. Aliases: `a`, `attach` | +| `webtty go [id]` | Start server if not running; attach to session (creates if new, reuses if exists); open in browser. Aliases: `a`, `run`, `attach`, `open` | | `webtty ls [id]` | `GET /api/sessions` — list sessions; if `[id]` given, filter by substring match. Alias: `list` | | `webtty rm [id]` | `DELETE /api/sessions/:id` — destroy session and its PTY; stops server if last session. Alias: `remove` | | `webtty mv [id] [new-id]` | `PATCH /api/sessions/:id` — rename a session. Aliases: `move`, `rename` | | `webtty stop` | `POST /api/server/stop` — server cleans up and exits | | `webtty start` | Fork server, wait for `GET /api/sessions` to respond | -| `webtty` | No-arg entry point — start server if not running, then delegate to `webtty at main` | +| `webtty` | No-arg entry point — start server if not running, then delegate to `webtty go main` | | `webtty config` | Open `~/.config/webtty/config.json` in `$VISUAL` (falls back to `$EDITOR`, then `vi` on Unix / `notepad` on Windows) | | `webtty help` | Show help — all commands | @@ -30,7 +30,7 @@ The CLI communicates with the server exclusively over HTTP — no Unix sockets, `webtty` with no arguments: 1. Start the server if not already running -2. Delegate to `webtty at main` — create or reuse the `main` session and open it in the browser +2. Delegate to `webtty go main` — create or reuse the `main` session and open it in the browser This is the canonical quickstart: `npx webtty` or `bunx webtty` goes from zero to a browser terminal in one command. @@ -55,7 +55,7 @@ The command exits when the editor exits. | Feature | Description | ADR | Done? | |---------|-------------|-----|-------| | Server lifecycle | `webtty start` / `stop` — start and stop the server | [ADR 002](../adrs/002.cli.start-stop.md) | ✅ | -| Session management | `webtty at` / `ls` / `rm` / `mv` — attach, list, destroy, and rename sessions | [ADR 006](../adrs/006.cli.session-management.md) | ✅ | +| Session management | `webtty go` / `ls` / `rm` / `mv` — attach, list, destroy, and rename sessions | [ADR 006](../adrs/006.cli.session-management.md) | ✅ | | No-arg entry point | `webtty` — start server and open `main` session in browser | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Help and config | `webtty help` — show all commands; `webtty config` — open config in `$VISUAL`/`$EDITOR`/`vi` | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Help formatting | Description first, all-caps headings, aligned params, frequency-ordered commands, annotated usage lines | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | diff --git a/docs/specs/client.md b/docs/specs/client.md index 091f173..37fb55a 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -42,7 +42,7 @@ src/client/ 1. Reads `sessionId` from `window.location.pathname` (`/s/main` → `main`) 2. Fetches `GET /api/config` to get terminal config 3. Sets `document.title = sessionId + ' | webtty'` -4. Initialises a `ghostty-web` `Terminal` with config values (cols, rows, fontSize, fontFamily, cursorBlink, scrollback, theme, copyOnSelect, rightClickBehavior) +4. Initialises a `ghostty-web` `Terminal` with config values (cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollback, theme, copyOnSelect, rightClickBehavior) 5. Connects to `ws:///ws/:id?cols=&rows=` over WebSocket 6. Fits the terminal to the viewport and observes resize events via `FitAddon` 7. Sends a `{ type: 'resize', cols, rows }` JSON message on open and on every terminal resize @@ -54,7 +54,7 @@ src/client/ ```ts { - cols, rows, fontSize, fontFamily, cursorBlink, scrollback, + cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollback, theme, copyOnSelect, rightClickBehavior } ``` @@ -121,10 +121,11 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Feature | Description | ADR | Done? | |---------|-------------|-----|-------| -| Static asset build | Browser TS compiled by `Bun.build()`; HTML/CSS copied to `dist/`; zero inline script | [ADR 012](../adrs/012.client.static-assets.md) | ⬜ | +| Static asset build | Browser TS compiled by `Bun.build()`; HTML/CSS copied to `dist/`; zero inline script | [ADR 012](../adrs/012.client.static-assets.md) | ✅ | | Terminal view | Full-viewport terminal using `ghostty-web`, auto-fit, WebSocket reconnect on disconnect | [ADR 001](../adrs/001.webtty.bootstrap.md) | ✅ | -| Config endpoint | `GET /api/config` — serves client-relevant config keys; replaces server-side template injection | [ADR 012](../adrs/012.client.static-assets.md) | ⬜ | +| Config endpoint | `GET /api/config` — serves client-relevant config keys; replaces server-side template injection | [ADR 012](../adrs/012.client.static-assets.md) | ✅ | | Session support | `GET /s/:id` opens a named session; `GET /` redirects to last-used or creates `main` | [ADR 005](../adrs/005.client.session-support.md) | ✅ | | Multi-client | Multiple tabs can attach to the same session; scrollback replayed on reconnect; tab closes when PTY exits | [ADR 007](../adrs/007.webtty.session-client.md) | ✅ | | Welcome banner and status messages | `[ webtty ]`-styled banner on first connect; consistent status messages for disconnect, error, and server stop | [ADR 010](../adrs/010.client.ux-polish.md) | ✅ | | Copy behavior | `copyOnSelect` + `rightClickBehavior` — two independent configurable copy modes | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | +| Cursor style | `cursorStyle` / `cursorStyleBlink` defaults; DECSCUSR from PTY overrides at runtime via client-side intercept | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | From abad79084869503196683cfbe9da8b8c17bdf243 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 23:18:05 -0400 Subject: [PATCH 17/23] feat: update README for Windows usage and adjust webtty command in package.json --- README.md | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b2d5b2a..4b839fe 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ npx webtty go [id] npx webtty help ``` +> **Windows**: use `npx` — `bunx` is not supported on Windows because `Bun.spawn({ terminal })` does not implement PTY on Windows yet. + ## Development Build emits source maps (`dist/**/*.js.map`), so you can debug against the built output directly — no minification, original TypeScript line numbers preserved. diff --git a/package.json b/package.json index 62b3035..d4f24ad 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "build": "bun run scripts/build.ts", "server": "bun run dist/server/index.js", "server:node": "node dist/server/index.js", - "webtty": "bun run dist/cli/index.js", + "webtty": "bun -- dist/cli/index.js", "prepack": "bun scripts/clean-pkg-scripts.ts strip", "postpack": "bun scripts/clean-pkg-scripts.ts restore" }, From 41f233714f9baf891454b9d083b75c20ad0d48f1 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 23:24:57 -0400 Subject: [PATCH 18/23] fix: address copilot review comments --- docs/adrs/013.client.cursor-style.md | 4 ++-- docs/specs/config.md | 1 + src/cli/commands.ts | 25 +++++-------------------- src/cli/index.ts | 8 +++++--- src/config.test.ts | 11 +++++++++++ src/config.ts | 2 +- 6 files changed, 25 insertions(+), 26 deletions(-) diff --git a/docs/adrs/013.client.cursor-style.md b/docs/adrs/013.client.cursor-style.md index 1af6ef8..69bb5fa 100644 --- a/docs/adrs/013.client.cursor-style.md +++ b/docs/adrs/013.client.cursor-style.md @@ -16,7 +16,7 @@ The consequence: `cursorStyle` in config sets the initial shape at startup, but ## Decision -Intercept DECSCUSR sequences in `src/client/cursor.ts` before passing data to `term.write()`. On each WebSocket message, scan for the pattern `ESC [ Ps SP q`, decode `Ps`, and update `term.options.cursorStyle` and `term.options.cursorStyleBlink` directly. ghostty-web's options proxy forwards these immediately to the renderer via `renderer.setCursorStyle()` and `renderer.setCursorBlink()`. +Intercept DECSCUSR sequences in `src/client/cursor.ts` before passing data to `term.write()`. On each WebSocket message, scan for the pattern `ESC [ Ps SP q`, decode `Ps`, and update `term.options.cursorStyle` and `term.options.cursorBlink` directly. ghostty-web's options proxy forwards these immediately to the renderer via `renderer.setCursorStyle()` and `renderer.setCursorBlink()`. **DECSCUSR Ps mapping:** @@ -58,4 +58,4 @@ Rejected — vim in normal mode showing a bar cursor is confusing. The shell def ## Related Decisions - [ADR 010 — Client UX polish](010.client.ux-polish.md): established the WebSocket message handling in `index.ts` that this intercept hooks into -- [ADR 008 — Config](008.webtty.config.md): established `cursorStyleBlink` as a config key; `cursorStyle` is added alongside it +- [ADR 008 — Config](008.webtty.config.md): established `cursorBlink` as a config key; `cursorStyle` and `cursorStyleBlink` are added alongside it diff --git a/docs/specs/config.md b/docs/specs/config.md index df5608a..11c49fa 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -95,6 +95,7 @@ spawn PTY with fresh: shell, term, colorTerm, scrollback - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `theme`, `copyOnSelect`, `rightClickBehavior` — re-read on every tab reload. `cursorStyle` and `cursorStyleBlink` set the startup defaults; apps override them at runtime via DECSCUSR. - `shell`, `term`, `colorTerm`, `scrollback` — re-read when a new PTY is spawned (i.e. first connection to a session that has no running shell). - An already-running session is never affected mid-flight. + - Historical note: ADR 008/009/012 describe an earlier config flow that used a `cursorBlink` key and different HTML injection mechanics. Those ADRs are considered historical; this spec's `cursorStyle` / `cursorStyleBlink` behavior is authoritative. ## Schema diff --git a/src/cli/commands.ts b/src/cli/commands.ts index a49cdc8..aa8a81a 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -4,35 +4,20 @@ import path from 'node:path'; import { configDir } from '../config'; import { BASE_URL, isServerRunning, openBrowser, startServer, stopServer } from './http'; -export async function cmdGo(id?: string): Promise { +export async function cmdGo(id = 'main'): Promise { if (!(await isServerRunning())) { await startServer(); } let sessionId: string; - if (id) { - const check = await fetch(`${BASE_URL}/api/sessions/${encodeURIComponent(id)}`); - if (check.status === 200) { - sessionId = id; - } else { - const res = await fetch(`${BASE_URL}/api/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id }), - }); - if (!res.ok) { - const body = (await res.json()) as { error?: string }; - console.error(`webtty: ${body.error ?? `failed to create session (${res.status})`}`); - process.exit(1); - } - const session = (await res.json()) as { id: string }; - sessionId = session.id; - } + const check = await fetch(`${BASE_URL}/api/sessions/${encodeURIComponent(id)}`); + if (check.status === 200) { + sessionId = id; } else { const res = await fetch(`${BASE_URL}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: '{}', + body: JSON.stringify({ id }), }); if (!res.ok) { const body = (await res.json()) as { error?: string }; diff --git a/src/cli/index.ts b/src/cli/index.ts index 9d6eeb6..9591c29 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -22,7 +22,7 @@ function printHelp(): void { row('mv ', 'Rename a session'), row('stop', 'Stop the webtty server'), row('start', 'Start the webtty server'), - row('config', 'Open the config file in $EDITOR'), + row('config', 'Open the config file in $VISUAL, $EDITOR, or a default editor'), row('help', 'Show this help message'), ].join('\n'), ); @@ -30,8 +30,10 @@ function printHelp(): void { const [, , cmd, ...rest] = process.argv; -if (!cmd || GO_ALIASES.has(cmd)) { - await cmdGo(cmd && GO_ALIASES.has(cmd) ? (rest[0] ?? 'main') : 'main'); +if (!cmd) { + await cmdGo(); +} else if (GO_ALIASES.has(cmd)) { + await cmdGo(rest[0]); } else { switch (cmd) { case 'ls': diff --git a/src/config.test.ts b/src/config.test.ts index fc1d932..cbe0731 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -59,6 +59,7 @@ describe('loadConfig — first run', () => { expect(config.rows).toBe(DEFAULT_CONFIG.rows); expect(config.fontSize).toBe(DEFAULT_CONFIG.fontSize); expect(config.cursorStyleBlink).toBe(DEFAULT_CONFIG.cursorStyleBlink); + expect(config.cursorStyle).toBe(DEFAULT_CONFIG.cursorStyle); expect(config.scrollback).toBe(DEFAULT_CONFIG.scrollback); expect(config.theme).toEqual(DEFAULT_CONFIG.theme); }); @@ -125,6 +126,16 @@ describe('loadConfig — reads and merges', () => { expect(loadConfig().cursorStyleBlink).toBe(false); }); + test('overrides cursorStyle when set to a valid value', () => { + writeConfig(JSON.stringify({ cursorStyle: 'underline' })); + expect(loadConfig().cursorStyle).toBe('underline'); + }); + + test('falls back cursorStyle to default for invalid value', () => { + writeConfig(JSON.stringify({ cursorStyle: 'bogus' })); + expect(loadConfig().cursorStyle).toBe(DEFAULT_CONFIG.cursorStyle); + }); + test('overrides cols and rows when set in file', () => { writeConfig(JSON.stringify({ cols: 120, rows: 40 })); const config = loadConfig(); diff --git a/src/config.ts b/src/config.ts index fbb382a..6df4a9d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -91,7 +91,7 @@ export const DEFAULT_CONFIG: Config = { rows: 24, fontSize: 13, fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace", - cursorStyle: 'bar' as 'block' | 'bar' | 'underline', + cursorStyle: 'bar', cursorStyleBlink: true, copyOnSelect: true, rightClickBehavior: 'default' as RightClickBehavior, From da7dab8735fb9a8a5309e05440b613537e7678ab Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 23:44:46 -0400 Subject: [PATCH 19/23] fix: center align social preview image in README --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4b839fe..c0edffe 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ - +

+ +

# webtty From ca88e2834572cf3e14f808e2f8bd0057c0a2328c Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 23:47:50 -0400 Subject: [PATCH 20/23] fix: bundle ghostty-web assets into dist for npx compatibility --- scripts/build.ts | 21 ++++++++++++++++++--- src/server/static.ts | 8 ++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/scripts/build.ts b/scripts/build.ts index c22080b..35c1e5c 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,8 +1,9 @@ -#!/usr/bin/env bun - import fs from 'node:fs'; +import { createRequire } from 'node:module'; import path from 'node:path'; +const require = createRequire(import.meta.url); + const serverResult = await Bun.build({ entrypoints: ['./src/server/index.ts', './src/cli/index.ts'], outdir: './dist', @@ -42,5 +43,19 @@ fs.writeFileSync(clientOut, clientJs.replace(/"ghostty-web"/g, '"/dist/ghostty-w fs.copyFileSync(path.resolve('./src/client/client.html'), path.resolve('./dist/client.html')); fs.copyFileSync(path.resolve('./src/client/index.css'), path.resolve('./dist/client.css')); -const totalFiles = serverResult.outputs.length + clientResult.outputs.length + 2; +// Copy ghostty-web assets into dist/ so they ship with the package. +// Without this, `npx webtty` fails — the package is extracted to a temp +// directory with no node_modules, so require.resolve('ghostty-web') throws. +const ghosttyWebMain = require.resolve('ghostty-web') as string; +const ghosttyWebRoot = ghosttyWebMain.replace(/[/\\]dist[/\\].*$/, ''); +fs.copyFileSync( + path.join(ghosttyWebRoot, 'dist', 'ghostty-web.js'), + path.resolve('./dist/ghostty-web.js'), +); +fs.copyFileSync( + path.join(ghosttyWebRoot, 'ghostty-vt.wasm'), + path.resolve('./dist/ghostty-vt.wasm'), +); + +const totalFiles = serverResult.outputs.length + clientResult.outputs.length + 4; console.log(`✓ Build complete (${totalFiles} files)`); diff --git a/src/server/static.ts b/src/server/static.ts index 9d3e8be..1b848bf 100644 --- a/src/server/static.ts +++ b/src/server/static.ts @@ -31,6 +31,14 @@ export function ghosttyWebRootFromMain(mainPath: string): string { } export function findGhosttyWeb(): { distPath: string; wasmPath: string } { + // Prefer assets bundled into dist/ — present when installed via npx/npm. + const bundledDist = path.join(__dirname, '..', '..', 'dist'); + const bundledWasm = path.join(bundledDist, 'ghostty-vt.wasm'); + if (fs.existsSync(path.join(bundledDist, 'ghostty-web.js')) && fs.existsSync(bundledWasm)) { + return { distPath: bundledDist, wasmPath: bundledWasm }; + } + + // Fall back to node_modules — present during local development. try { const ghosttyWebMain = require.resolve('ghostty-web') as string; const ghosttyWebRoot = ghosttyWebRootFromMain(ghosttyWebMain); From 375ecac820ff3ee0e052cdc3e352e6ae2cf3e9a6 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 25 Mar 2026 23:52:32 -0400 Subject: [PATCH 21/23] fix: update static test to match bundled dist path --- src/server/static.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/server/static.test.ts b/src/server/static.test.ts index 861492e..fde3ac7 100644 --- a/src/server/static.test.ts +++ b/src/server/static.test.ts @@ -1,5 +1,6 @@ import { describe, expect, mock, spyOn, test } from 'bun:test'; import fs from 'node:fs'; +import path from 'node:path'; import { findGhosttyWeb, ghosttyWebRootFromMain, mimeType, serveFile } from './static'; describe('mimeType', () => { @@ -49,7 +50,7 @@ describe('ghosttyWebRootFromMain', () => { describe('findGhosttyWeb', () => { test('returns distPath and wasmPath when ghostty-web is installed', () => { const { distPath, wasmPath } = findGhosttyWeb(); - expect(distPath).toContain('ghostty-web'); + expect(fs.existsSync(path.join(distPath, 'ghostty-web.js'))).toBe(true); expect(wasmPath).toContain('ghostty-vt.wasm'); }); From d4686b7ff10655b9417c1a05eb3a0c9b6a87df04 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Thu, 26 Mar 2026 00:04:10 -0400 Subject: [PATCH 22/23] test: restore 100% coverage for static.ts fallback path --- src/server/static.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/server/static.test.ts b/src/server/static.test.ts index fde3ac7..f72cfff 100644 --- a/src/server/static.test.ts +++ b/src/server/static.test.ts @@ -54,6 +54,21 @@ describe('findGhosttyWeb', () => { expect(wasmPath).toContain('ghostty-vt.wasm'); }); + test('falls back to node_modules when bundled assets are missing', () => { + const realExistsSync = fs.existsSync.bind(fs); + const existsSpy = spyOn(fs, 'existsSync').mockImplementation((p) => { + const s = String(p); + if (s.includes('ghostty-web.js') && !s.includes('node_modules')) return false; + if (s.includes('ghostty-vt.wasm') && !s.includes('node_modules')) return false; + return realExistsSync(s); + }); + + const { distPath, wasmPath } = findGhosttyWeb(); + expect(distPath).toContain('ghostty-web'); + expect(wasmPath).toContain('ghostty-vt.wasm'); + existsSpy.mockRestore(); + }); + test('exits when ghostty-web files are missing', () => { const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(false); const exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); From 3938b44e444b24ab4a46c632040a0cc781990d97 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Thu, 26 Mar 2026 00:05:42 -0400 Subject: [PATCH 23/23] fix: ignore unknown DECSCUSR Ps values, fix awesome-web.md typo --- docs/awesome-web.md | 2 +- src/client/cursor.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/awesome-web.md b/docs/awesome-web.md index dfb1fda..2d00273 100644 --- a/docs/awesome-web.md +++ b/docs/awesome-web.md @@ -91,7 +91,7 @@ Here's every known approach and how they compare: | Tool | Sessions | Windows | Notes | |------|----------|---------|-------| -| **[webtty](https://github.com/jess23/webtty)** (current repo) | ✅ | ✅ | Lightweight, session-aware, cross-platform | +| **[webtty](https://github.com/jesse23/webtty)** (current repo) | ✅ | ✅ | Lightweight, session-aware, cross-platform | | **[VibeTunnel](https://github.com/amantus-ai/vibetunnel)** | ✅ | ❌ | macOS/Linux, built for AI agent monitoring, native menu bar app + `vt` command wrapper | | **[ttyd](https://github.com/tsl0922/ttyd)** | ❌ | ✅ | One shell per URL; session terminates when the connection drops | | **[GoTTY](https://github.com/yudai/gotty)** | ❌ | ❌ | Lightweight Go tool, abandoned since 2017 | diff --git a/src/client/cursor.ts b/src/client/cursor.ts index ce562ad..d19e752 100644 --- a/src/client/cursor.ts +++ b/src/client/cursor.ts @@ -25,9 +25,11 @@ export function applyDecscusr(term: Terminal, data: string): void { let match = DECSCUSR.exec(data); while (match !== null) { const ps = match[1] === '' ? 0 : Number(match[1]); - term.options.cursorStyle = - ps === 0 || ps === 1 || ps === 2 ? 'block' : ps === 3 || ps === 4 ? 'underline' : 'bar'; - term.options.cursorBlink = ps === 0 || ps === 1 || ps === 3 || ps === 5; + if (!Number.isNaN(ps) && ps >= 0 && ps <= 6) { + term.options.cursorStyle = + ps === 0 || ps === 1 || ps === 2 ? 'block' : ps === 3 || ps === 4 ? 'underline' : 'bar'; + term.options.cursorBlink = ps === 0 || ps === 1 || ps === 3 || ps === 5; + } match = DECSCUSR.exec(data); } }