Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3683906
fix: apply graceful shutdown to HTTP stop path
Mar 23, 2026
c00048f
fix: update session close behavior and document browser restrictions
Mar 23, 2026
4906500
feat: implement configuration management with loading and saving func…
Mar 23, 2026
050e976
feat: add scrollbackBuffer to configuration schema and loading logic
Mar 23, 2026
566e07b
fix: rename scrollbackBuffer to scrollback in config interface and de…
Mar 23, 2026
629fd6e
feat: update configuration to support JSONC format with comments
Mar 23, 2026
b9bc62a
fix: update theme comments in configuration to clarify terminal color…
Mar 23, 2026
8d07db8
feat: implement configuration file support with user-defined settings…
Mar 23, 2026
f4dcc9e
fix: handle error when saving default config in loadConfig function
Mar 23, 2026
1360530
feat: implement hot config reload for appearance settings on tab reload
Mar 23, 2026
ede68b5
refactor: remove config parameter from createWebSocketServer function
Mar 23, 2026
208e4e7
feat: enhance configuration handling with hot reload support and impr…
Mar 23, 2026
a106666
feat: add coverage testing script to package.json and ignore lcov.inf…
Mar 23, 2026
114be36
feat: add tests for setLastUsedId function and coverage report
Mar 23, 2026
2f7ab15
feat: update configuration format to plain JSON and remove strip-json…
Mar 23, 2026
2a4a7e3
feat: implement server restart command and enhance stop logic in CLI
Mar 24, 2026
1289c4b
feat: add timeout parameter to stopServer function and implement test…
Mar 24, 2026
71f0a1b
feat: refactor shutdown logic and session management in server and we…
Mar 24, 2026
3ac7dfb
feat: refactor server start and openBrowser functions to accept custo…
Mar 24, 2026
da4c1aa
feat: modify openBrowser function to skip opening URLs in test enviro…
Mar 24, 2026
c08b123
feat: update default theme colors to match Campbell palette
Mar 24, 2026
66ac90a
feat: update default font size and family in configuration
Mar 24, 2026
aee3c47
feat: add no-arg entry point and help command to CLI; enhance websock…
Mar 24, 2026
6951dd5
feat: enhance websocket messages with styled output and update sessio…
Mar 24, 2026
a2d3d53
feat: enhance websocket error handling with styled messages
Mar 24, 2026
3357369
docs: add ADR 010, 011 and update client/cli specs
Mar 24, 2026
a063f6e
feat: update session title format and add favicon to HTML output
Mar 24, 2026
fd77c9e
feat: update favicon SVG for improved visual appearance
Mar 24, 2026
72d07dd
fix: address copilot review comments
Mar 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ package.json.bak

# Logs
*.log
lcov.info

# AI tooling (local-only agent state)
.agents/
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
<img src="assets/social-preview.png" width="600">

# webtty

A web TTY for running CLI/TUI applications in a browser tab, across platforms.
Terminal UI in the browser. Run CLI/TUI applications in a browser tab, across platforms.

```sh
npx webtty run # start server + open a terminal in the browser
npx webtty ls # list sessions
npx webtty help # show all commands
```

## Debugging

Expand Down
4 changes: 4 additions & 0 deletions assets/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/social-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 12 additions & 6 deletions docs/adrs/002.cli.start-stop.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# ADR 002: CLI — `webtty start` / `webtty stop`
# ADR 002: CLI — `webtty start` / `webtty stop` / `webtty restart`

**SPEC:** [cli](../specs/cli.md)
**Status:** Accepted
Expand Down Expand Up @@ -26,9 +26,15 @@ Add a CLI entry point (`src/cli.ts`) and a `POST /api/server/stop` endpoint to `
- Polls `GET /api/sessions` until it responds (or times out), then prints `webtty started`. This ensures the server is actually ready before the CLI exits.

**`webtty stop`**:
- Sends `POST http://localhost:PORT/api/server/stop`.
- If the request succeeds: prints `webtty stopped`.
- If the request fails (connection refused): prints `webtty is not running`.
- Checks `GET /api/sessions` first — if connection refused, prints `webtty is not running` and exits 0.
- Otherwise sends `POST http://localhost:PORT/api/server/stop` and polls until the server is down.
- Prints `webtty stopped` on success, `webtty stop failed` and exits 1 if the server doesn't come down.

**`webtty restart`**:
- If the server is running, calls the same stop logic (wait for down).
- Then calls the same start logic (spawn detached, poll until ready).
- Prints `webtty restarted`.
- If the server is not running, skips the stop step — restart from cold always works.

**`server.ts` change**: Add `POST /api/server/stop` — kills all PTY sessions, closes the WebSocket server, and calls `process.exit(0)`. The server owns its own shutdown on all platforms.

Expand Down Expand Up @@ -64,9 +70,9 @@ A `webtty.sh` that does `node dist/server.js &`.

## Consequences

**Good**: `npx webtty start` works from any directory. Server runs in background, terminal is free. `npx webtty stop` works identically on Mac, Linux, and Windows — no signals, no PID files, no platform-specific code. Server owns its cleanup. No new runtime dependencies.
**Good**: `npx webtty start` works from any directory. Server runs in background, terminal is free. `npx webtty stop` and `npx webtty restart` work identically on Mac, Linux, and Windows — no signals, no PID files, no platform-specific code. Server owns its cleanup. `stopServer()` is extracted into `http.ts` so `stop` and `restart` share the same wait-for-down logic. No new runtime dependencies.

**Bad**: If the server is hung, `webtty stop` fails silently — acceptable for this slice; hard-kill fallback deferred. Port is hardcoded at 2346 — will become configurable when the config file slice lands.
**Bad**: If the server is hung, `webtty stop` fails — acceptable for this slice; hard-kill fallback deferred. Port is hardcoded at 2346 — will become configurable when the config file slice lands.

## Implementation Notes

Expand Down
11 changes: 10 additions & 1 deletion docs/adrs/007.webtty.session-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,15 @@ When the PTY exits (e.g. user types `exit`), the server:
1. Deletes the session from the registry immediately
2. Closes all connected WebSockets with code `4001`

The client's existing `4001` handler (introduced in ADR 005 for deleted sessions) writes "Session removed." and calls `window.close()` after 2s. No delay is added before closing — the user explicitly typed `exit`, so immediate closure is the right behaviour. All open tabs for that session close together.
The client's existing `4001` handler (introduced in ADR 005 for deleted sessions) writes "Session removed." and calls `window.close()` after 500ms. No delay is added before closing — the user explicitly typed `exit`, so immediate closure is the right behaviour. All open tabs for that session close together.

Server stop (`webtty stop` or SIGINT) sends close code `1001` instead. The client writes "Server stopped." and also calls `window.close()` after 500ms.

**`window.close()` browser restriction**: browsers only permit `window.close()` on tabs that were opened via `window.open()` or duplicated from such a tab. Tabs opened by the OS `open`/`xdg-open` command or by the user typing a URL directly are treated as unowned — `window.close()` is silently ignored with a console warning. In practice this means:
- Tabs opened by `webtty run` and tabs duplicated from them → close automatically ✅
- Tabs opened by manually navigating to `localhost:2346` → show "Server stopped." / "Session removed." but remain open ⚠️

There is no JS workaround for this restriction. It is browser-enforced by design.

**Multi-tab: fanout to all connected clients**

Expand Down Expand Up @@ -102,4 +110,5 @@ Only the first/oldest connected tab can send input; additional tabs are viewers.
## Related Decisions

- [ADR 005 — UI Session Support](005.client.session-support.md): Established session persistence across WS disconnects and the `4001` close code
- [ADR 010 — Client UX polish](010.client.ux-polish.md): Redesigned the welcome banner and unified status message styling

71 changes: 71 additions & 0 deletions docs/adrs/008.webtty.config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# ADR 008: webtty — Config File

**SPEC:** [config](../specs/config.md)
**Status:** Accepted
**Date:** 2026-03-23

---

## Context

All webtty settings are currently hardcoded or read from environment variables only. Users have no persistent way to change port, shell, terminal appearance, or theme without modifying source or wrapping the binary in a shell script. ADR 002 explicitly deferred this: "Port is hardcoded at 2346 — will become configurable when the config file slice lands."

Three concerns drive the design:

1. **Where is the file?** It must follow platform conventions and be easy to find.
2. **What format?** Must be human-editable; comments are essential for a config file users edit directly.
3. **What is the load/merge strategy?** Adding new keys in future versions must not break existing configs.

## Decision

**File location**: `~/.config/webtty/config.json` — follows the XDG Base Directory convention used by most modern CLI tools (opencode, lazygit, starship, etc.). Created automatically on first run with only `port` and `host` set.

**Format**: Plain JSON. The file is written by `saveConfig` using `JSON.stringify` and read with `JSON.parse`. Comments are not supported — the schema table in the [config spec](../specs/config.md) serves as documentation. This removes the `strip-json-comments` dependency and keeps the parser a zero-config standard library call.

**Load strategy**: Merge partial config over defaults — unknown keys are ignored, missing keys fall back to built-in defaults. This means adding new config keys in future versions is non-breaking: an old `config.jsonc` with only `port` still works correctly with a binary that added `theme`.

**Env overrides**: `PORT` overrides `config.port` at runtime. Applied after file load, never written back to the file.

**Terminal config delivery**: Server-side config values that affect the browser terminal (cols, rows, fontSize, fontFamily, cursorBlink, scrollback lines, theme) are injected into the HTML template returned by `GET /s/:id`. The client reads them from the rendered page — no separate API endpoint needed.

**`scrollback`**: A single value in bytes (`256 * 1024` default). Controls both the server-side PTY replay ring buffer (`session.ts`) and the client-side terminal line buffer (derived as `Math.ceil(scrollback / 80)` — assumes ~80 bytes/line average). One knob, one mental model.

## Considered Options

**Option A: `.webtty/config.json` in home directory**

- **Pros**: Simple, familiar to Node.js users.
- **Cons**: Clutters `~` with a dot-directory. XDG convention (`~/.config/`) is now the standard for CLI tools.

**Option B: TOML format**

- **Pros**: More readable for nested structures (theme colors).
- **Cons**: Requires a TOML parser dependency (~50 KB). JSON is sufficient and consistent with the existing JSON surface (REST API, `package.json`).

**Option C: Separate env var for every setting**

- **Pros**: No new file format.
- **Cons**: Env vars are session-scoped, not persistent. Unwieldy for nested settings like theme colors. Users expect a config file for terminal emulators.

**Option D: Bun-native JSONC only**

- **Pros**: Zero bytes added to bundle.
- **Cons**: Breaks Node.js compatibility. webtty explicitly supports both runtimes.

**Option E: JSONC via `strip-json-comments`**

- **Pros**: Users can annotate their config with comments.
- **Cons**: Adds a dependency for a cosmetic feature. The schema table in the spec is a better reference than inline comments in a generated file. Dropped in favour of plain JSON.

## Consequences

- First run creates `~/.config/webtty/config.json` with `port` and `host` set — users have a ready-to-edit file immediately
- `port`, `host`, `shell`, `term`, `scrollback`, `cols`, `rows`, `fontSize`, `fontFamily`, `cursorBlink`, and `theme` are all user-configurable
- Adding new config keys in future versions is non-breaking
- No additional production dependencies — `JSON.parse` is sufficient
- Terminal appearance config is injected server-side into the HTML — no client-side config fetch needed

## Related Decisions

- [ADR 002 — CLI start/stop](002.cli.start-stop.md): explicitly deferred config file; port hardcoded at 2346
- [ADR 007 — Session client](007.webtty.session-client.md): terminal defaults (cols, rows, theme) previously hardcoded in `client.ts`
51 changes: 51 additions & 0 deletions docs/adrs/009.webtty.config-hot-reload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# ADR 009: webtty — Config Hot Reload

**SPEC:** [config](../specs/config.md)
**Status:** Accepted
**Date:** 2026-03-23

---

## Context

Config is currently loaded once at server startup and held in memory for the lifetime of the process. Users who edit `~/.config/webtty/config.json` must restart the server to see their changes — even for purely cosmetic settings like font size or theme colors.

A full hot-reload (e.g. `fs.watch` triggering a live update mid-session) is impractical: the terminal is already initialized in the browser with the old values, and pushing new font/theme settings to a live xterm instance requires non-trivial client-side logic. However, a tab reload already reconstructs the terminal from scratch — the browser fetches `GET /s/:id`, the server renders fresh HTML with config injected, and the client re-initializes xterm. This is a natural reload boundary.

## Decision

**Re-read `config.json` on every `GET /s/:id` request** instead of using the startup-cached value. No file watcher, no WebSocket push, no client changes.

- `loadConfig()` is called inside the `GET /s/:id` handler, not at module load time.
- `port` and `host` remain read once at startup — they are bound to the OS socket and cannot change without a restart.
- `shell` and `term` are re-read when a new PTY is spawned (first WebSocket connection to a session that has no running shell). An already-running session is unaffected.
- All appearance settings (`cols`, `rows`, `fontSize`, `fontFamily`, `cursorBlink`, `scrollback`, `theme`) take effect immediately on the next tab reload.

## Considered Options

**Option A: `fs.watch` + WebSocket push to live terminal**

- **Pros**: Truly live — no reload needed.
- **Cons**: Requires client-side logic to apply new theme/font to a running xterm instance. xterm's `Terminal.options` setter works for some properties but not all (e.g. `fontFamily` requires a full re-render). Significant complexity for marginal benefit — users editing config will naturally reload anyway.

**Option B: Re-read on every request (chosen)**

- **Pros**: Zero client changes. Zero file watcher infrastructure. Works with the existing reload boundary. The extra `fs.readFileSync` per page load is negligible (config file is ~3 KB, loaded once per tab open).
- **Cons**: Config changes don't apply to a live tab without a reload — acceptable given the use case.

**Option C: Keep startup-only load, add `POST /api/config/reload` endpoint**

- **Pros**: Explicit reload, avoids per-request I/O.
- **Cons**: Requires the user to manually hit an endpoint or use a CLI command. Tab reload is already the natural trigger; adding an extra step is worse UX.

## Consequences

- Editing `~/.config/webtty/config.json` and reloading the browser tab picks up all appearance changes immediately — no server restart needed.
- `shell`, `term`, `colorTerm`, and `scrollback` are re-read when a new PTY is spawned — no server restart needed for shell changes either.
- `port` / `host` changes still require a server restart (expected; the socket is already bound).
- `loadConfig()` adds one `fs.readFileSync` + JSON parse per tab load and per new PTY spawn — negligible cost.
- `createWebSocketServer` no longer accepts a `config` parameter — it calls `loadConfig()` itself at PTY spawn time. `index.ts` retains a startup-cached config only for `port`/`host` (server bind).

## Related Decisions

- [ADR 008 — Config file](008.webtty.config.md): established config file location, format, and load strategy
103 changes: 103 additions & 0 deletions docs/adrs/010.client.ux-polish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# ADR 010: webtty — Client UX polish (banner, status messages)

**SPEC:** [client](../specs/client.md)
**Status:** Accepted
**Date:** 2026-03-24

---

## Context

The original welcome banner was ported verbatim from the `ghostty-web` demo — a colorful multi-line box with tutorial-style copy ("You have a real shell session with full PTY support. Try: ls, cd, top, vim..."). The disconnect messages used ad-hoc inline ANSI codes with inconsistent colors (red for session removal, yellow for server stop).

Three problems:

1. **Banner feels like a demo artifact** — the content is aimed at first-time ghostty-web users, not webtty users. It does not identify the product or give actionable guidance.
2. **No visual identity** — nothing in the UI surfaces the `webtty` name or how to get help.
3. **Inconsistent status messages** — each WS event had its own inline color and wording with no shared style.

## Decision

### Welcome banner

A structured box with two content lines separated by a blank line:

```
╔══════════════════════════════════════════════════════╗
║ ║
║ [ webtty ] Terminal UI in the browser ║
║ ║
║ Run `bunx webtty help` for more information. ║
║ ║
╚══════════════════════════════════════════════════════╝
```

**Line 1 — identity + slogan:**
- `[` `]` — dim (ANSI `\x1b[2m`) so they frame without competing
- `webtty` — bold yellow (`\x1b[1;33m`), the only high-contrast element in the banner
- slogan `Terminal UI in the browser` — dim, recedes behind the name

**Line 2 — help:**
- `Run` and `for more information.` — dim plain
- `` `bunx webtty help` `` — foreground italic (`\x1b[3m`), the actionable part

**Border:** bright cyan (`\x1b[1;36m`) double-line box.

**Package runner detection:** `process.execPath.includes('bun')` at server startup — shows `bunx` under Bun, `npx` under Node.

The banner is shown only on first connect (when `session.pty` is `null`) and written into `session.scrollback` so it appears on replay. Unchanged from ADR 007.

### Status messages

All WS lifecycle events that write to the terminal share a single helper:

```
[ webtty ] <message>
```

- `[ webtty ]` — dim brackets, bold yellow name (same identity as the banner)
- Message text — dim italic (`\x1b[2m\x1b[3m`)

| Close code | Message | Action |
|------------|---------|--------|
| `4001` | `Session removed.` | `window.close()` after 500ms |
| `1001` | `Server stopped.` | `window.close()` after 500ms |
| other | `Connection lost. Reconnecting in 2s...` | reconnect after 2s |
| `onerror` | `WebSocket error.` | — |

### `onopen` / `onerror` / `console` policy

- `ws.onopen` — no terminal message, no `console.log`. The shell prompt appearing is self-evident.
- `ws.onerror` — `term.write(msg(...))` only. No `console.error`.
- `ws.onclose` — `term.write(msg(...))` only. No `console.log`.

No user-facing event writes to both `term.write` and `console` — terminal output is the single source of truth for the user.

## Considered Options

**Option A: No banner (like SSH / macOS Terminal)**

Rejected — webtty is a web app with a CLI. First-time users need to know where to get help. A minimal banner with the app name and a single help hint is enough.

**Option B: Keep the ghostty-web demo banner**

Rejected — tutorial copy ("Try: ls, cd, top, vim") is condescending for users who know what a terminal is. The product name and help command are more useful.

**Option C: Session ID in the banner**

Rejected — the session ID is already in the browser tab title (`webtty — <id>`). Repeating it in the terminal adds noise.

**Option D: Per-event colors for status messages (red for errors, yellow for warnings)**

Rejected — color-coded severity for these three events adds visual noise with little signal gain. The `[ webtty ]` prefix already distinguishes these messages from shell output. Consistent dim italic is quieter and more readable.

## Consequences

- The banner surfaces the product name and a single actionable help command — no tutorial noise.
- All status messages share the same `[ webtty ]` identity pattern — consistent with the banner.
- The package runner (`bunx` vs `npx`) is detected at server startup — correct for the user's environment without configuration.
- `onerror` events now surface in the terminal, previously they were silent to the user (console only).

## Related Decisions

- [ADR 007 — Session client](007.webtty.session-client.md): established banner placement (first connect only), scrollback inclusion, and WS close code semantics
Loading
Loading