Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
6d57808
docs: add SPEC and ADR for wtty bootstrap
jesse23 Mar 21, 2026
8d6ff71
docs: update hardcoded config and theme details in ADR 001
jesse23 Mar 21, 2026
c224892
chore: add runtime deps and simplify build for app
jesse23 Mar 21, 2026
0d0ae1c
chore: update build script and CI for server-only target
jesse23 Mar 21, 2026
e4890f9
feat: implement bootstrap server (ghostty-web port)
jesse23 Mar 21, 2026
4e6b86d
chore: change default port to 2346
jesse23 Mar 21, 2026
650fbcb
chore: replace start with dev and preview scripts
jesse23 Mar 21, 2026
0411e10
fix: use named spawn import from node-pty
jesse23 Mar 21, 2026
35d27f2
fix: correct FitAddon import and await term.open
jesse23 Mar 21, 2026
0f14944
fix: switch to default import for node-pty CJS compat
jesse23 Mar 21, 2026
19ef903
fix: defer fitAddon.fit and connect to requestAnimationFrame
jesse23 Mar 21, 2026
0a96ae9
fix: rewrite server as faithful TypeScript port of ghostty-web demo
jesse23 Mar 21, 2026
70e6915
fix: upgrade ghostty-web to next (0.4.0-next.14) to fix blank render
jesse23 Mar 21, 2026
7ec38b5
fix: match demo exact deps (ghostty-web@0.2.1, node-pty@1.1.0, ws@8.1…
jesse23 Mar 21, 2026
1180a14
fix: use ghostty-web@next which has init() export
jesse23 Mar 21, 2026
705ec6f
chore: change default port to 2346
jesse23 Mar 21, 2026
95378ef
fix: register term.onData before connect so DA responses reach PTY
jesse23 Mar 21, 2026
e36f89b
fix: faithful 1:1 TypeScript port of ghostty-web demo server
jesse23 Mar 21, 2026
c3d5647
fix: send PTY data as text frames (binary: false) to browser
jesse23 Mar 21, 2026
d8eaed2
fix: run server with node+tsx instead of bun (pty compat)
jesse23 Mar 21, 2026
00004df
feat: dual PTY adapter — Bun.Terminal on bun, node-pty on node
jesse23 Mar 21, 2026
be8fde2
chore: log PTY backend on startup
jesse23 Mar 21, 2026
f6d5403
chore: print pty backend at module load, not per-request
jesse23 Mar 21, 2026
2230ce8
refactor: split PTY into src/pty/bun.ts and src/pty/node.ts, dynamic …
jesse23 Mar 21, 2026
b833fc0
refactor: drop allowImportingTsExtensions, use extensionless imports
jesse23 Mar 21, 2026
525582a
refactor: move PtyProcess interface to src/pty/types.ts
jesse23 Mar 21, 2026
c8ee6ce
docs: update ADR 001 with dual PTY adapter and mark Accepted
jesse23 Mar 21, 2026
c83a30a
feat: remove title bar, full-screen terminal only
jesse23 Mar 21, 2026
7d41b49
chore: simplify dev:node to tsx src/server.ts
jesse23 Mar 21, 2026
31eca0e
fix: update preview script to use bun for running the server
jesse23 Mar 21, 2026
5b7a5ee
chore: update deps to latest
jesse23 Mar 21, 2026
eb474b6
chore: mark runtime deps as external in build, clean up scripts
jesse23 Mar 21, 2026
82d9b50
chore: remove PR approval requirement, CI checks sufficient
jesse23 Mar 21, 2026
c71aa5d
ci: add Copilot review workflow, restore 1 required approval
jesse23 Mar 21, 2026
629183b
ci: allow self-approval (disable require_code_owner_reviews)
jesse23 Mar 21, 2026
191dbdc
ci: restore require_code_owner_reviews
jesse23 Mar 21, 2026
bb80f9e
ci: remove unnecessary checkout from copilot-review workflow
jesse23 Mar 21, 2026
fe92eac
test: add unit tests for mimeType and ghosttyWebRootFromMain
jesse23 Mar 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 5 additions & 10 deletions .github/settings.yml
Original file line number Diff line number Diff line change
@@ -1,22 +1,17 @@
# GitHub repository settings reference.
# These rules must be applied manually in GitHub → Settings → Branches.
# These rules must be applied manually via: gh api repos/jesse23/wtty/branches/main/protection
# They are documented here so the intended configuration is version-controlled.
#
# To apply: Settings → Branches → Add branch ruleset → target "main"
#
# Branch protection for: main
#
# required_status_checks:
# strict: true # branch must be up to date before merge
# contexts:
# - lint
# - build
# - test
# strict: true
# contexts: [lint, build, test]
#
# required_pull_request_reviews:
# required_approving_review_count: 1
# dismiss_stale_reviews: true # re-review required after new push
# require_code_owner_reviews: true # enforces CODEOWNERS (jesse23 only)
# dismiss_stale_reviews: true
# require_code_owner_reviews: true
#
# allow_force_pushes: false
# allow_deletions: false
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,4 @@ jobs:
with:
bun-version: 1.3.10
- run: bun install --frozen-lockfile
- run: bun test
- run: bun run test
18 changes: 18 additions & 0 deletions .github/workflows/copilot-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Copilot Review

on:
pull_request:
branches:
- main

jobs:
copilot-review:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Request Copilot review
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr edit ${{ github.event.pull_request.number }} --add-reviewer "Copilot"
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
Expand Down
115 changes: 102 additions & 13 deletions bun.lock

Large diffs are not rendered by default.

70 changes: 70 additions & 0 deletions docs/adrs/001.wtty.bootstrap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# ADR 001: Bootstrap — Port ghostty-web demo

**SPEC:** wtty
**Status:** Accepted
**Date:** 2026-03-21

---

## Context

The first slice of wtty is a direct port of the `ghostty-web` demo. The demo already proves the full round-trip — browser renders a terminal, user types, a real PTY responds — so rather than building from scratch, we take that working implementation and make it the foundation of wtty. Everything else (config, multi-session, CLI) is deferred.

## Decision

Port `ghostty-web/demo` into a single entry point (`src/server.ts`). The server serves everything on one port — HTML, assets, and WebSocket — with all config hardcoded inline.

**Server (`src/server.ts`):**
- HTTP server on port `2346`
- Serves the terminal HTML inline as a template string at `/`
- Serves `/dist/` assets (ghostty-web JS + WASM) from the installed `ghostty-web` package via `require.resolve`
- WebSocket endpoint at `/ws?cols=&rows=` — spawns one PTY per connection
- PTY: auto-detects shell (`$SHELL` on macOS/Linux, `cmd.exe` on Windows), cwd `$HOME`
- WebSocket message framing: raw string for PTY input; JSON `{ type: "resize", cols, rows }` for resize
- On PTY exit: sends exit message and closes WebSocket

**PTY layer (`src/pty/`):**
- `types.ts` — `PtyProcess` interface shared by both adapters
- `bun.ts` — `Bun.Terminal` adapter (Bun v1.3.5+, native, no native addon)
- `node.ts` — `@lydell/node-pty` adapter (Node.js)
- `index.ts` — detects runtime via `process.versions.bun`, dynamically imports the correct adapter

**Browser (inline HTML template in `src/server.ts`):**
- Full-viewport terminal with macOS-style title bar, no surrounding chrome
- `ghostty-web` (`init` + `Terminal` + `FitAddon`) as the terminal renderer
- Hardcoded config: `fontSize: 14`, `fontFamily: 'FiraMono Nerd Font, Menlo, Monaco, Courier New, monospace'`
- Theme: Dracula (official) — `background: #282A36`, `foreground: #F8F8F2`, `cursor: #F8F8F2`, `selection: #44475A`, `black: #21222C`, `red: #FF5555`, `green: #50FA7B`, `yellow: #F1FA8C`, `blue: #BD93F9`, `purple: #FF79C6`, `cyan: #8BE9FD`, `white: #F8F8F2`, `brightBlack: #6272A4`, `brightRed: #FF6E6E`, `brightGreen: #69FF94`, `brightYellow: #FFFFA5`, `brightBlue: #D6ACFF`, `brightPurple: #FF92DF`, `brightCyan: #A4FFFF`, `brightWhite: #FFFFFF`
- `FitAddon.fit()` + `observeResize()` for auto-resize
- WebSocket connects to same origin at `/ws`
- On resize: sends JSON resize message
- On close: reconnects after 2s

**Dependencies:**
- `@lydell/node-pty@1.1.0` — cross-platform PTY for Node.js (fork with prebuilt binaries, no node-gyp)
- `ws@8.18.3` — WebSocket server
- `ghostty-web@0.4.0-next` — WASM terminal emulator, ported directly from the demo
Comment thread
jesse23 marked this conversation as resolved.

## Considered Options

**Option A: xterm.js instead of ghostty-web**
xterm.js is the industry standard (used by ttyd, VS Code, wetty). More documentation and addons. Rejected for this slice — ghostty-web is already the working reference, uses the same `Terminal`/`FitAddon` API, and porting it is the fastest path to a running terminal.

**Option B: Separate frontend build (Vite/esbuild)**
Would enable TypeScript in the browser and hot reload. Deferred — the demo uses plain `<script type="module">` and so does this port. A build step can be introduced when the frontend grows beyond a single file.

**Option C: Serve `index.html` as a file on disk**
Keeps HTML separate from server code. Rejected — inline template keeps everything in one deployable file, consistent with the ghostty-web/demo pattern.

**Option D: `Bun.Terminal` only (drop node-pty)**
`Bun.Terminal` is native to Bun, zero-dependency, and faster. Rejected as the sole option — would make Bun a hard runtime requirement for users. The dual-adapter pattern keeps Node.js as a valid runtime.

**Option E: `node-pty` (original microsoft/node-pty)**
The original package requires `node-gyp` compilation on install. `@lydell/node-pty` is a maintained fork with prebuilt binaries that eliminates this friction while keeping full API compatibility.

## Consequences

- Full round-trip working on both Bun and Node.js runtimes
- `bun run dev` uses `Bun.Terminal` (native); `npm run dev:node` uses `node-pty`
- No config file yet — all values hardcoded; acceptable for this slice
- No auth — localhost-only, same security posture as ghostty-web/demo
- Frontend is plain HTML/JS — no TypeScript in browser until a build step is added
15 changes: 6 additions & 9 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,19 @@ Two document types, two purposes:
| Document | Pattern | Example |
|----------|---------|---------|
| SPEC | `{short-title}.md` | `my-new-module.md` |
| ADR | `{release-id}/{sequence}.{spec-title}.{adr-title}.md` | `2612/001.monorepo-infra.bun-runtime.md` |
| ADR | `{sequence}.{spec-title}.{adr-title}.md` | `001.server.websocket-protocol.md` |

```
docs/
├── specs/ # living architecture overviews
│ └── {short-title}.md
└── adrs/ # decision records grouped by release
└── {release-id}/ # e.g. 2612, 2706
└── {seq}.{spec-title}.{adr-title}.md
└── adrs/ # decision records, flat sequence
└── {seq}.{spec-title}.{adr-title}.md
```

**Sequencing**: ADR numbers are 3-digit, sequential *within a release folder*, starting at `001`. They do not reset per SPEC — a single global sequence per release avoids conflicts when multiple SPECs produce ADRs simultaneously.
**Sequencing**: ADR numbers are 3-digit, sequential across the entire repo, starting at `001`. They do not reset per SPEC — a single global sequence avoids conflicts when multiple SPECs produce ADRs simultaneously.

**SPEC-title prefix**: The SPEC name embedded in each ADR filename is the modularization axis. It enables filtering by scope (`ls adrs/2612/*.auth.*`) and makes ownership clear at a glance.

**Release folder**: Each release cycle (e.g. `2512`, `2606`, `2612`) gets its own folder — no single folder grows unbounded. Historical decisions are preserved in-place.
**SPEC-title prefix**: The SPEC name embedded in each ADR filename is the modularization axis. It enables filtering by scope (`ls adrs/*.server.*`) and makes ownership clear at a glance.

---

Expand Down Expand Up @@ -115,7 +112,7 @@ If a developer wants to propose a SPEC or ADR independently first, they can subm
| Scenario | Action |
|----------|--------|
| New module or repo-level concern | Write a **SPEC** in `docs/specs/`, then ADRs for each decision |
| New incremental change you decide to make | Write an **ADR** in `docs/adrs/{release}/` + link from spec |
| New incremental change you decide to make | Write an **ADR** in `docs/adrs/` + link from spec |
| Implement a decided feature | Update SPEC's Features table `⬜` → `✅` |
| Change a previous decision | New ADR with `Superseded` status + update old ADR's status |
| Reject a considered approach | Revise into a new Proposed ADR with better options, if pursuing the topic further |
Expand Down
25 changes: 25 additions & 0 deletions docs/specs/cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# SPEC: CLI

**Author:** jesse23
**Last Updated:** 2026-03-21

---

## Description

The `wtty` CLI is a thin client that controls a running wtty server over HTTP. It handles two concerns: server lifecycle (start/stop/restart the daemon) and session management (create/list/kill sessions via the REST API).

The CLI communicates with the server exclusively over HTTP to localhost — no Unix sockets, no direct process management beyond the startup fork. A PID file (`~/.wtty/server.pid`) is the source of truth for whether a daemon is running; a `/health` HTTP check confirms it is actually responsive.

**Why HTTP over Unix socket?** wtty already speaks HTTP — reusing the same interface keeps the surface area minimal and makes the CLI trivially debuggable with `curl`. Unix sockets offer lower latency but the difference is imperceptible for CLI interactions.

**Why Commander.js?** Zero dependencies, 18ms startup, TypeScript-native, used by Vue CLI and Vite. Yargs and oclif are heavier and provide features (plugin systems, config files) that are unnecessary here.

**Why PID file + health check vs port probe alone?** A port probe can give false positives (another process on the same port). PID file + signal 0 check + HTTP health check gives reliable daemon detection with graceful handling of stale PID files.

## Features

| Feature | Description | ADR | Done? |
|---------|-------------|-----|-------|
| Server lifecycle | `wtty start`, `wtty stop`, `wtty restart`, `wtty status` — daemon control via PID file + HTTP | — | ⬜ |
| Session management | `wtty session create/list/kill` — thin wrappers over the session REST API | — | ⬜ |
24 changes: 24 additions & 0 deletions docs/specs/ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# SPEC: UI

**Author:** jesse23
**Last Updated:** 2026-03-21

---

## Description

The wtty web UI is served by the wtty server and runs entirely in the browser. It has two surfaces: a terminal view (full-viewport, one session per tab) and a session manager (list, create, open, kill sessions, and control the server).

The UI has no build step in the initial slices — plain HTML + `<script type="module">` importing `ghostty-web` assets served by the server itself. A bundler can be introduced later if the UI grows beyond a handful of files.

**Why ghostty-web over xterm.js?** ghostty-web is the reference implementation for this project, already available locally, and shares the same `Terminal` / `FitAddon` API shape as xterm.js. xterm.js is the safer long-term bet (wider ecosystem, VS Code backing) but ghostty-web is sufficient for the initial slices and avoids an early dependency decision.

**Why no framework (React/Vue) yet?** The session manager UI is simple enough (a table + buttons) that a framework adds more complexity than it removes. Revisit when the UI grows.

## Features

| Feature | Description | ADR | Done? |
|---------|-------------|-----|-------|
| Terminal view | Full-viewport terminal using `ghostty-web`, auto-fit, reconnect on disconnect | [001](../adrs/001.wtty.bootstrap.md) | ⬜ |
| Session manager | List sessions, open in new tab, create new session, kill session | — | ⬜ |
| Server control | Restart and stop server from the UI (calls server control API) | — | ⬜ |
31 changes: 31 additions & 0 deletions docs/specs/wtty.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# SPEC: wtty

**Author:** jesse23
**Last Updated:** 2026-03-21

---

## Description

wtty is a web TTY that lets you run CLI/TUI applications in a browser tab, on any platform. Point a browser at a running wtty server and you get a full terminal — colors, cursor, resize, keyboard input — backed by a real PTY on the host machine.

The goal is the same as ttyd and GoTTY: zero client-side installation, full TUI support, cross-platform. wtty uses `ghostty-web` as the terminal renderer (WASM-backed, same API as xterm.js) and `@lydell/node-pty` for cross-platform PTY support.

**Why a single-port design?** Simplifies reverse proxy setup (nginx, ngrok, Cloudflare Tunnel) — one upstream, no separate port for assets vs API vs WebSocket. Same pattern used by ttyd and ghostty-web/demo.

**Why `ws` over Socket.IO?** Minimal footprint, no client-side library requirement, sufficient for raw PTY streaming. Socket.IO's rooms/namespaces are unnecessary overhead for this use case.

**Persona:** Developers who want shell or TUI app access from any browser tab.

## Features

| Feature | Description | ADR | Done? |
|---------|-------------|-----|-------|
| Bootstrap | Port `ghostty-web` demo into wtty — full-screen terminal in a browser tab, single server, hardcoded config | [001](../adrs/001.wtty.bootstrap.md) | ⬜ |
| Config file | Load shell, port, font, theme from a config file (`~/.wtty/config.json`) | — | ⬜ |
| Named sessions | Session registry keyed by ID; create/locate via `/ws?session=<id>`; PTY survives WebSocket disconnect | — | ⬜ |
| Session REST API | `GET /api/sessions`, `POST /api/sessions`, `DELETE /api/sessions/:id` | — | ⬜ |
| Server control API | `POST /api/server/restart`, `POST /api/server/stop` | — | ⬜ |
| Health endpoint | `GET /health` — returns 200 + uptime/version; used by CLI for daemon lifecycle checks | — | ⬜ |
| UI | Browser interface for terminal, session management, and server control | — | ⬜ |
| CLI | `wtty` binary for server lifecycle and session management | — | ⬜ |
20 changes: 15 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,30 @@
"version": "0.0.0",
"description": "Web TTY for running CLI/TUI applications in a browser tab, across platforms",
"private": true,
"type": "module",
"scripts": {
"preinstall": "bun scripts/check-pkg-manager.ts",
"dev": "bun run src/server.ts",
"dev:node": "tsx src/server.ts",
"preview": "bun run dist/server.js",
"preview:node": "node dist/server.js",
"lint": "tsc --noEmit -p tsconfig.lint.json && biome check .",
"lint:fix": "tsc --noEmit -p tsconfig.lint.json && bunx biome check --write .",
"build": "bun run build:dts && bun run build:src",
"build:dts": "tsc -p tsconfig.dts.json",
"build:src": "bun run scripts/build.ts",
"build": "bun run scripts/build.ts",
"test": "bun test",
"clean": "rimraf dist node_modules"
},
"dependencies": {
"@lydell/node-pty": "1.2.0-beta.3",
"ghostty-web": "0.4.0-next.14.g6a1a50d",
"ws": "8.20.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.4",
"@types/bun": "1.3.9",
"@biomejs/biome": "2.4.8",
"@types/bun": "1.3.11",
"@types/ws": "8.18.1",
"rimraf": "6.1.3",
"tsx": "4.21.0",
"typescript": "5.9.3"
}
}
17 changes: 4 additions & 13 deletions scripts/build.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,14 @@
#!/usr/bin/env bun

import { Glob } from 'bun';

const entrypoints: string[] = [];
const glob = new Glob('**/*.ts');

for await (const file of glob.scan('./src')) {
if (!file.endsWith('.d.ts') && !file.endsWith('.test.ts')) {
entrypoints.push(`./src/${file}`);
}
}
export {};

const result = await Bun.build({
entrypoints,
entrypoints: ['./src/server.ts'],
outdir: './dist',
root: './src',
target: 'node',
format: 'esm',
sourcemap: 'external',
external: ['./*', '../*'],
external: ['@lydell/node-pty', 'ws', 'ghostty-web'],
});

if (!result.success) {
Expand Down
6 changes: 0 additions & 6 deletions src/index.test.ts

This file was deleted.

3 changes: 0 additions & 3 deletions src/index.ts

This file was deleted.

41 changes: 41 additions & 0 deletions src/pty/bun.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { homedir } from 'node:os';
import type { PtyProcess } from './types';

export function spawn(shell: string, cols: number, rows: number): PtyProcess {
let onDataCb: ((data: string) => void) | undefined;
let onExitCb: ((e: { exitCode: number }) => void) | undefined;

const proc = Bun.spawn([shell], {
terminal: {
cols,
rows,
data(_term: unknown, data: Uint8Array) {
onDataCb?.(Buffer.from(data).toString('utf8'));
},
},
cwd: homedir(),
env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' },
});

proc.exited.then((exitCode) => {
onExitCb?.({ exitCode: exitCode ?? 0 });
});

return {
onData(cb) {
onDataCb = cb;
},
onExit(cb) {
onExitCb = cb;
},
write(data) {
proc.terminal?.write(data);
},
resize(cols, rows) {
proc.terminal?.resize(cols, rows);
},
kill() {
proc.kill();
},
};
}
8 changes: 8 additions & 0 deletions src/pty/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export type { PtyProcess } from './types';

const isBun = !!process.versions.bun;
console.log(`pty: ${isBun ? 'Bun.Terminal' : 'node-pty'}`);

const { spawn: _spawn } = await (isBun ? import('./bun') : import('./node'));

export const spawn = _spawn;
Loading
Loading