diff --git a/.gitignore b/.gitignore index 88c7961..c23cc63 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ dist/ .claude/ +.playwright-mcp/ *.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e045de --- /dev/null +++ b/README.md @@ -0,0 +1,201 @@ +# cmux-remote + +Browser-based remote terminal UI for [cmux](https://github.com/manaflow-ai/cmux). Access and control your cmux terminal sessions from any device with a web browser. + +![Version](https://img.shields.io/badge/version-0.1.0-blue) + +## Features + +- **Real-time terminal streaming** — Adaptive polling with differential updates (patches, not full redraws) +- **Claude Code mode** — Auto-detects Claude Code sessions and renders with structured blocks (tool use, code, diffs, tables, selections) +- **Remote access via ngrok** — Secure tunnel with QR code for quick mobile access +- **Mobile-first UI** — Virtual keyboard, touch gestures, iOS PWA support +- **Multi-workspace** — Sidebar for switching between workspaces, panes, and surfaces +- **Browser surface support** — Screenshots for non-terminal surfaces with pinch-zoom +- **Scrollback history** — Auto-loads scrollback when scrolling up +- **Authentication** — Password-based auth with bcrypt + JWT, rate-limited login +- **Themes** — Dark/light mode with persistent preference + +## Installation + +### Global CLI + +```bash +npm install -g cmux-remote +``` + +### From source + +```bash +git clone https://github.com/user/cmux-remote.git +cd cmux-remote +npm install +npm run build +``` + +## Prerequisites + +- **cmux** must be installed and running (`cmux` binary in PATH, or set `CMUX_PATH`) +- **Node.js** >= 18 +- **ngrok** account (optional, for remote tunnel) — set `NGROK_AUTHTOKEN` env var + +## Usage + +```bash +# Start with defaults (port 9870, ngrok tunnel enabled, no auth) +cmux-remote + +# Set a password +cmux-remote -P mypassword + +# Local only (no tunnel) +cmux-remote --no-tunnel + +# Custom port with verbose logging +cmux-remote -p 3000 -v + +# All options +cmux-remote --help +``` + +### Options + +| Option | Default | Description | +|--------|---------|-------------| +| `-p, --port ` | `9870` | Local server port | +| `-P, --password ` | none | Access password (or `CMUX_REMOTE_PASSWORD` env) | +| `--no-tunnel` | tunnel on | Disable ngrok tunnel | +| `--tunnel-domain ` | none | Fixed ngrok domain (or `NGROK_DOMAIN` env) | +| `--poll-rate ` | `200` | Screen poll interval | +| `--theme ` | `dark` | Default theme (`dark` or `light`) | +| `-v, --verbose` | off | Verbose server logging | + +### Configuration file + +Create `~/.cmux-remote/config` for persistent settings: + +```ini +CMUX_REMOTE_PASSWORD=mypassword +NGROK_AUTHTOKEN=your_token +NGROK_DOMAIN=my-domain.ngrok-free.app +CMUX_PATH=/usr/local/bin/cmux +``` + +Environment variables set in the config file won't override existing env vars. + +## Architecture + +``` +Browser ←── WebSocket ──→ Node.js Server ←── CLI/Socket ──→ cmux daemon + │ │ + ├─ terminal-view.js ├─ screen-poller.ts (adaptive polling) + ├─ claude-parser.js ├─ input-handler.ts (key translation) + ├─ claude-renderer.js ├─ cmux-client.ts (cmux CLI wrapper) + └─ app.js (orchestrator) └─ session-manager.ts +``` + +### Server (`src/`) + +| Module | Description | +|--------|-------------| +| `index.ts` | CLI entry point (Commander.js), bootstraps all components | +| `server/app.ts` | Express 5 HTTP server, REST API, static files, CSP headers | +| `server/websocket.ts` | WebSocket server, client sessions, message routing | +| `server/auth.ts` | bcrypt password hashing, JWT tokens, rate limiting | +| `server/tunnel.ts` | ngrok tunnel management | +| `bridge/cmux-client.ts` | cmux CLI wrapper with concurrency limiting and caching | +| `bridge/screen-poller.ts` | Adaptive screen polling with diff-based updates | +| `bridge/input-handler.ts` | Translates browser keys to cmux protocol | +| `bridge/session-manager.ts` | Client auth state and surface subscriptions | +| `protocol/messages.ts` | Type-safe WebSocket message definitions | +| `utils/config.ts` | Configuration interface and defaults | +| `utils/text-differ.ts` | Line-based diff algorithm for incremental updates | + +### Client (`public/`) + +| File | Description | +|------|-------------| +| `js/app.js` | Main orchestrator — routing, WS lifecycle, surface switching | +| `js/terminal-view.js` | ANSI-to-HTML renderer with 256-color and 24-bit RGB support | +| `js/claude-parser.js` | Parses Claude Code output into structured blocks | +| `js/claude-renderer.js` | Renders Claude blocks (prompts, tools, code, diffs, tables) | +| `js/claude-keyboard.js` | Claude-specific keyboard shortcuts (Yes/No/Esc) | +| `js/websocket-client.js` | WebSocket client with auto-reconnection | +| `js/virtual-keyboard.js` | Mobile virtual keyboard with modifiers (Ctrl, Alt, Shift) | +| `js/sidebar.js` | Workspace/surface tree navigation | +| `js/gestures.js` | Touch swipe and gesture handling | +| `js/theme.js` | Dark/light theme persistence | +| `js/auth.js` | Login flow and JWT management | + +### Adaptive Polling + +The screen poller adjusts its rate based on activity: + +- **Fast** (200ms) — Active terminal with recent changes +- **Idle** (1-2s) — No changes for 3+ poll cycles +- **Deep idle** (2s) — 10+ seconds inactive + +Resets to fast mode on any user input. Sends full screen snapshots every 30 seconds; incremental diff patches otherwise. + +### Claude Code Mode + +When a surface runs Claude Code, the client automatically switches to a structured renderer: + +- **Prompts** with `❯` marker +- **Tool use** blocks with icon, name, and arguments +- **Tool results** in bordered boxes +- **Code blocks** with syntax-aware display +- **Diff blocks** with +/- coloring +- **Tables** parsed from box-drawing characters +- **Selection menus** with clickable options +- **Status bar** with mode pill (Plan/Code/Bypass) and git branch + +## Protocol + +Communication uses JSON over WebSocket. + +**Client → Server:** + +``` +auth { token } +subscribe { workspace, surface } +unsubscribe { surface } +send-text { workspace, surface, text } +send-key { workspace, surface, key } +list-workspaces +scroll-request { workspace, surface, lines } +``` + +**Server → Client:** + +``` +screen { surface, content, lines, scrollback? } +screen-diff { surface, patches[] } +workspaces { workspaces[] } +browser-screenshot { surface, imageData, mime } +auth-ok / auth-fail +error { message } +``` + +## Development + +```bash +# Development with auto-reload (no tunnel) +npm run dev:local + +# Development with tunnel +npm run dev + +# Run tests +npm test + +# Build for production +npm run build + +# Start production server +npm start +``` + +## License + +MIT diff --git a/public/index.html b/public/index.html index cfbd4c5..2e6d08e 100644 --- a/public/index.html +++ b/public/index.html @@ -113,16 +113,16 @@ - - + + - - + + - + diff --git a/public/js/app.js b/public/js/app.js index a415483..b16f73d 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -184,7 +184,7 @@ // Auto-load scrollback when user scrolls to the top terminalContainer.addEventListener('scroll', () => { - if (scrollbackPending) return; + if (scrollbackPending || !terminal.lines.length) return; if (terminalContainer.scrollTop < 50 && currentWorkspace && currentSurface) { inScrollMode = true; scrollbackLines += PAGE_LINES; @@ -294,15 +294,6 @@ settingsClose.addEventListener('click', closeSettings); settingsOverlay.addEventListener('click', closeSettings); - // Title helper — reuses sidebar's status-parsing logic - function _parseStatus(title) { - const workingRe = /^[\u2800-\u28FF\u23FA\u25CF\u2B24]\s*/; - const idleRe = /^[\u2700-\u27BF]\s*/; - if (workingRe.test(title)) return { state: 'working', title: title.replace(workingRe, '') }; - if (idleRe.test(title)) return { state: 'idle', title: title.replace(idleRe, '') }; - return { state: null, title }; - } - function updateTitle(wsRef, surfaceRef) { const el = document.getElementById('app-title'); if (!el) return; @@ -319,7 +310,7 @@ } } } - const status = _parseStatus(rawTitle); + const status = Sidebar.parseStatus(rawTitle); el.innerHTML = ''; if (status.state) { const dot = document.createElement('span'); diff --git a/public/js/claude-parser.js b/public/js/claude-parser.js index 1d16e1d..79a825e 100644 --- a/public/js/claude-parser.js +++ b/public/js/claude-parser.js @@ -285,7 +285,7 @@ class ClaudeParser { _isTableRowLine(line) { const s = this._stripAnsi(line).trimStart(); - return /^[│┃]/.test(s); + return /^[│┃║]/.test(s); } _isTableBorderLine(line) { diff --git a/public/js/claude-renderer.js b/public/js/claude-renderer.js index a89d747..fbc9b14 100644 --- a/public/js/claude-renderer.js +++ b/public/js/claude-renderer.js @@ -14,6 +14,10 @@ class ClaudeRenderer { this._onAction = fn; } + _stripAnsi(s) { + return s.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.)/g, ''); + } + render(doc) { const sig = this._signature(doc.blocks); if (sig !== this._lastSignature) { @@ -108,7 +112,7 @@ class ClaudeRenderer { // Pattern-based coloring for a single line (no ANSI codes from server) _renderLine(line) { - const stripped = line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); + const stripped = this._stripAnsi(line); let e = this._esc(stripped); // Dingbats — spinner/thinking characters (✻ ✳ ✢ ✦ etc.) e = e.replace(/([\u2700-\u27BF])/g, '$1'); @@ -120,7 +124,7 @@ class ClaudeRenderer { div.className = block.isActive ? 'cc-prompt cc-prompt-active' : 'cc-prompt'; const firstLine = block.lines[0]; - const stripped = firstLine.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); + const stripped = this._stripAnsi(firstLine); const markerMatch = stripped.match(/^(\s*\u276F\s*)/); const text = markerMatch ? firstLine.slice(markerMatch[1].length) : firstLine; @@ -212,16 +216,12 @@ class ClaudeRenderer { // Strip ╭/╰ border lines; for │ content lines, remove the │ markers // and trailing terminal padding so content is immune to terminal width changes - const stripAnsi = s => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); const contentLines = []; for (const line of block.lines) { - const s = stripAnsi(line).trimStart(); - if (/^[│┃]/.test(s)) { - // Remove leading ANSI codes + │ + optional space - let cleaned = line.replace(/^(\x1b\[[0-9;]*[A-Za-z])*[│┃]\s?/, ''); - // Remove trailing space + │ + trailing ANSI codes - cleaned = cleaned.replace(/\s*[│┃](\x1b\[[0-9;]*[A-Za-z])*\s*$/, ''); - // Remove terminal box padding (trailing spaces) + const s = this._stripAnsi(line).trimStart(); + if (/^[│┃║]/.test(s)) { + let cleaned = line.replace(/^(?:\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.))*[│┃║]\s?/, ''); + cleaned = cleaned.replace(/\s*[│┃║](?:\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.))*\s*$/, ''); contentLines.push(cleaned.trimEnd()); } // Skip ╭/╰ border lines @@ -236,9 +236,7 @@ class ClaudeRenderer { _renderThinking(block) { const div = document.createElement('div'); div.className = 'cc-thinking'; - // Strip ANSI escape codes — thinking spinner lines are often colorized - // eslint-disable-next-line no-control-regex - div.textContent = block.lines.join('\n').replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); + div.textContent = this._stripAnsi(block.lines.join('\n')); return div; } @@ -296,14 +294,13 @@ class ClaudeRenderer { const tbody = document.createElement('tbody'); let headerDone = false; - const stripAnsi = s => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); const isMiddleBorder = s => /^[├╠┣]/.test(s); const isBorderLine = s => /^[┌┐└┘├┤┬┴┼╔╗╚╝╠╣╦╩╬┏┓┗┛┣┫┳┻╋]/.test(s); - const isDataLine = s => /^[│┃]/.test(s); + const isDataLine = s => /^[│┃║]/.test(s); const lines = block.lines; for (let i = 0; i < lines.length; i++) { - const stripped = stripAnsi(lines[i]).trimStart(); + const stripped = this._stripAnsi(lines[i]).trimStart(); if (isBorderLine(stripped)) { if (isMiddleBorder(stripped)) headerDone = true; @@ -316,7 +313,7 @@ class ClaudeRenderer { let nextIsMiddleBorder = false; if (!headerDone) { for (let j = i + 1; j < lines.length; j++) { - const ns = stripAnsi(lines[j]).trimStart(); + const ns = this._stripAnsi(lines[j]).trimStart(); if (!ns) continue; if (isMiddleBorder(ns)) nextIsMiddleBorder = true; break; @@ -324,7 +321,7 @@ class ClaudeRenderer { } const isHeader = !headerDone && nextIsMiddleBorder; - const cells = stripped.split(/[│┃]/).slice(1, -1); + const cells = stripped.split(/[│┃║]/).slice(1, -1); const tr = document.createElement('tr'); for (const cell of cells) { diff --git a/public/js/sidebar.js b/public/js/sidebar.js index 11058ce..99b6bdd 100644 --- a/public/js/sidebar.js +++ b/public/js/sidebar.js @@ -97,7 +97,7 @@ class Sidebar { } const titleRaw = surface.title || surface.ref; - const status = this._parseStatus(titleRaw); + const status = Sidebar.parseStatus(titleRaw); const dot = document.createElement('span'); dot.className = 'surface-status'; @@ -122,7 +122,7 @@ class Sidebar { } } } - _parseStatus(title) { + static parseStatus(title) { // Working: Braille spinner (U+2800-U+28FF), ⏺ (U+23FA), ● (U+25CF), ⬤ (U+2B24) const workingRe = /^[\u2800-\u28FF\u23FA\u25CF\u2B24]\s*/; // Idle/waiting: dingbats range — ✳ ✻ ✢ ✦ etc. diff --git a/src/index.ts b/src/index.ts index 707668b..396a528 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,3 @@ -process.noDeprecation = true; - import { createServer } from 'node:http'; import { readFileSync, existsSync } from 'node:fs'; import { homedir } from 'node:os'; diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 85440c5..420eba2 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -153,7 +153,6 @@ export class WsServer { case 'scroll-request': { // Use capture-pane for scrollback history (read-screen only shows current visible area) const content = await this.cmux.capturePaneScrollback(msg.workspace, msg.surface, msg.lines); - console.log(`[ws] scroll-request: got ${content.split('\n').length} lines`); const lines = content.split('\n'); this.sendTo(clientId, { type: 'screen',