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. +[![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) ```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);