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 232c6a6..c0edffe 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,34 @@
-
+
+
+
# webtty
-Terminal UI in the browser. Run CLI/TUI applications in a browser tab, across platforms.
+[](https://www.npmjs.com/package/webtty)
+[](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)
```sh
-npx webtty # start server + open a terminal in the browser
-npx webtty ls # list sessions
-npx webtty help # show all commands
+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
```
-## Debugging
+> **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.
-```
+```sh
bun run build
bun --inspect run dist/server/index.js
# or
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/docs/adrs/013.client.cursor-style.md b/docs/adrs/013.client.cursor-style.md
new file mode 100644
index 0000000..69bb5fa
--- /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.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.
+
+## 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` and `cursorStyleBlink` are added alongside it
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/docs/awesome-web.md b/docs/awesome-web.md
new file mode 100644
index 0000000..2d00273
--- /dev/null
+++ b/docs/awesome-web.md
@@ -0,0 +1,139 @@
+# Awesome Web
+
+A personal guide to living in the browser.
+
+## Why the Browser
+
+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.
+
+**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
+
+### Browser Choice
+
+Pick one, stick with it. Cross-device sync matters more than features.
+
+| Browser | Why Pick It |
+|---------|------------|
+| **[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 |
+
+### 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
+
+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)
+- [Drive](https://drive.google.com)
+- [Sheets](https://sheets.google.com)
+- [Docs](https://docs.google.com)
+- [Slides](https://slides.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. 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)
+
+#### 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
+
+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. 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.
+
+**[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.
+
+| | `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
+
+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.
+
+Here's every known approach and how they compare:
+
+| Tool | Sessions | Windows | Notes |
+|------|----------|---------|-------|
+| **[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 |
+| **[Zellij](https://zellij.dev)** (web mode) | ✅ | ❌ | Full multiplexer with web mode, Linux/macOS only |
+
+### Terminal Software Recommendations
+
+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 |
+| **[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 |
+
+### Agentic CLI
+
+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 |
+|------|-------------|-------------|
+| **[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 |
+
+---
+
+The browser is no longer a limitation. It's where the best tools live now.
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/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) | ✅ |
diff --git a/docs/specs/config.md b/docs/specs/config.md
index 3ee722c..11c49fa 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,9 +92,10 @@ 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`, `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
@@ -110,7 +111,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 |
-| `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. |
@@ -158,7 +160,8 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter
"scrollback": 262144,
"cols": 80,
"rows": 24,
- "cursorBlink": true,
+ "cursorStyle": "bar",
+ "cursorStyleBlink": true,
"copyOnSelect": true,
"rightClickBehavior": "default",
"fontSize": 13,
@@ -195,7 +198,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`, `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) | ✅ |
+| 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/package.json b/package.json
index 397e485..d4f24ad 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"
},
@@ -22,13 +23,12 @@
"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"
},
"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/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/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"
}
diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts
index 6a39328..e669db2 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 () => {
@@ -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,16 +125,16 @@ 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 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/commands.ts b/src/cli/commands.ts
index d17cf4c..aa8a81a 100644
--- a/src/cli/commands.ts
+++ b/src/cli/commands.ts
@@ -1,205 +1,151 @@
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('at [id]')
- .alias('a')
- .alias('attach')
- .description('Attach to a new or existing session and open it')
- .action(async (id?: string) => {
- if (!(await isServerRunning())) {
- await startServer();
- }
+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;
- }
- } 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);
+ let sessionId: string;
+ 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;
+ }
- 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');
- 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 url = `${BASE_URL}/s/${sessionId}`;
+ console.log(url);
+ openBrowser(url);
+}
- 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);
- }
- });
+export async function cmdList(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('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 cmdRemove(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('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 cmdRename(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('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 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('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 cmdStart(): Promise {
+ if (await isServerRunning()) {
+ console.log('webtty is already running');
+ return;
+ }
+ await startServer();
+ console.log('webtty started');
+}
- program
- .command('help')
- .description('Show help — all commands and options')
- .action(() => {
- program.outputHelp();
- });
+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 fc10338..9591c29 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -1,83 +1,70 @@
-import { Command, type Help } from 'commander';
-import { registerCommands } from './commands';
+import { cmdConfig, cmdGo, cmdList, cmdRemove, cmdRename, cmdStart, cmdStop } from './commands';
-const CMDS_WITH_ARGS = new Set(['at', '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 $VISUAL, $EDITOR, or a default 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('Attach to main session and open it')}`,
- `${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(['at', 'main'], { from: 'user' });
-});
-
-program.parseAsync(process.argv);
+if (!cmd) {
+ await cmdGo();
+} else if (GO_ALIASES.has(cmd)) {
+ await cmdGo(rest[0]);
+} else {
+ switch (cmd) {
+ case 'ls':
+ case 'list':
+ await cmdList(rest[0]);
+ break;
+ case 'rm':
+ case 'remove':
+ await cmdRemove(rest[0]);
+ break;
+ case 'mv':
+ case 'move':
+ case 'rename':
+ await cmdRename(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);
+ }
+}
diff --git a/src/client/cursor.ts b/src/client/cursor.ts
new file mode 100644
index 0000000..d19e752
--- /dev/null
+++ b/src/client/cursor.ts
@@ -0,0 +1,35 @@
+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]);
+ 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);
+ }
+}
diff --git a/src/client/index.ts b/src/client/index.ts
index 6b3d6e7..df08d2d 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,7 +29,8 @@ interface ClientConfig {
rows: number;
fontSize: number;
fontFamily: string;
- cursorBlink: boolean;
+ cursorStyle: 'block' | 'bar' | 'underline';
+ cursorStyleBlink: boolean;
scrollback: number;
theme: Theme;
copyOnSelect: boolean;
@@ -45,7 +47,8 @@ await init();
const term = new Terminal({
cols: config.cols,
rows: config.rows,
- cursorBlink: config.cursorBlink,
+ cursorStyle: config.cursorStyle,
+ cursorBlink: config.cursorStyleBlink,
fontSize: config.fontSize,
fontFamily: config.fontFamily,
scrollback: Math.ceil(config.scrollback / 80),
@@ -79,6 +82,7 @@ function connect(): void {
};
ws.onmessage = (event: MessageEvent) => {
+ applyDecscusr(term, event.data);
term.write(event.data);
};
diff --git a/src/config.test.ts b/src/config.test.ts
index bed982d..cbe0731 100644
--- a/src/config.test.ts
+++ b/src/config.test.ts
@@ -58,7 +58,8 @@ 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.cursorStyle).toBe(DEFAULT_CONFIG.cursorStyle);
expect(config.scrollback).toBe(DEFAULT_CONFIG.scrollback);
expect(config.theme).toEqual(DEFAULT_CONFIG.theme);
});
@@ -120,9 +121,19 @@ 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 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', () => {
diff --git a/src/config.ts b/src/config.ts
index db7dd47..6df4a9d 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -38,7 +38,8 @@ export interface Config {
rows: number;
fontSize: number;
fontFamily: string;
- cursorBlink: boolean;
+ cursorStyle: 'block' | 'bar' | 'underline';
+ cursorStyleBlink: boolean;
copyOnSelect: boolean;
rightClickBehavior: RightClickBehavior;
logs: boolean;
@@ -90,7 +91,8 @@ export const DEFAULT_CONFIG: Config = {
rows: 24,
fontSize: 13,
fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace",
- cursorBlink: true,
+ cursorStyle: 'bar',
+ cursorStyleBlink: true,
copyOnSelect: true,
rightClickBehavior: 'default' as RightClickBehavior,
logs: false,
@@ -138,7 +140,11 @@ 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.cursorBlink === 'boolean' && { cursorBlink: p.cursorBlink }),
+ ...(typeof p.cursorStyle === 'string' &&
+ (p.cursorStyle === 'block' || p.cursorStyle === 'bar' || p.cursorStyle === 'underline') && {
+ cursorStyle: p.cursorStyle,
+ }),
+ ...(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 ff2479b..1108482 100644
--- a/src/server/routes.ts
+++ b/src/server/routes.ts
@@ -64,12 +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,
- cursorBlink: config.cursorBlink,
+ cursorStyle: config.cursorStyle,
+ cursorStyleBlink: config.cursorStyleBlink,
scrollback: config.scrollback,
theme: config.theme,
copyOnSelect: config.copyOnSelect,
diff --git a/src/server/static.test.ts b/src/server/static.test.ts
index 861492e..f72cfff 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', () => {
@@ -48,9 +49,24 @@ describe('ghosttyWebRootFromMain', () => {
describe('findGhosttyWeb', () => {
test('returns distPath and wasmPath when ghostty-web is installed', () => {
+ const { distPath, wasmPath } = findGhosttyWeb();
+ expect(fs.existsSync(path.join(distPath, 'ghostty-web.js'))).toBe(true);
+ 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', () => {
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);