chore: clean up project, add README, consolidate duplicate code

- Remove debug artifacts (debug-surface15.png, .playwright-mcp/, dev-notes.md)
- Add .playwright-mcp/ to .gitignore
- Add comprehensive README.md with architecture docs
- Consolidate duplicate _parseStatus() into Sidebar.parseStatus() static method
- Consolidate duplicate ANSI stripping into ClaudeRenderer._stripAnsi()
- Fix ANSI regex to handle OSC sequences in renderer
- Add ║ (U+2551) to box-drawing character sets in parser/renderer
- Guard scrollback handler against empty terminal state
- Remove duplicate process.noDeprecation from src/index.ts
- Remove debug console.log from scroll-request handler

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
main
I Luk Kim 5 months ago
parent e411ab38db
commit a3a607d1f4

1
.gitignore vendored

@ -1,4 +1,5 @@
node_modules/ node_modules/
dist/ dist/
.claude/ .claude/
.playwright-mcp/
*.env *.env

@ -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 <port>` | `9870` | Local server port |
| `-P, --password <pass>` | none | Access password (or `CMUX_REMOTE_PASSWORD` env) |
| `--no-tunnel` | tunnel on | Disable ngrok tunnel |
| `--tunnel-domain <domain>` | none | Fixed ngrok domain (or `NGROK_DOMAIN` env) |
| `--poll-rate <ms>` | `200` | Screen poll interval |
| `--theme <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

@ -113,16 +113,16 @@
</div> </div>
</aside> </aside>
<script src="/js/websocket-client.js?v=2"></script> <script src="/js/websocket-client.js?v=3"></script>
<script src="/js/terminal-view.js?v=6"></script> <script src="/js/terminal-view.js?v=7"></script>
<script src="/js/virtual-keyboard.js?v=3"></script> <script src="/js/virtual-keyboard.js?v=3"></script>
<script src="/js/sidebar.js?v=5"></script> <script src="/js/sidebar.js?v=5"></script>
<script src="/js/gestures.js?v=2"></script> <script src="/js/gestures.js?v=2"></script>
<script src="/js/theme.js?v=4"></script> <script src="/js/theme.js?v=4"></script>
<script src="/js/claude-parser.js?v=9"></script> <script src="/js/claude-parser.js?v=10"></script>
<script src="/js/claude-renderer.js?v=9"></script> <script src="/js/claude-renderer.js?v=10"></script>
<script src="/js/claude-keyboard.js?v=4"></script> <script src="/js/claude-keyboard.js?v=4"></script>
<script src="/js/app.js?v=8"></script> <script src="/js/app.js?v=9"></script>
<script src="/js/debug-safe-area.js"></script> <script src="/js/debug-safe-area.js"></script>
<script src="/js/sw-unregister.js"></script> <script src="/js/sw-unregister.js"></script>
</body> </body>

@ -184,7 +184,7 @@
// Auto-load scrollback when user scrolls to the top // Auto-load scrollback when user scrolls to the top
terminalContainer.addEventListener('scroll', () => { terminalContainer.addEventListener('scroll', () => {
if (scrollbackPending) return; if (scrollbackPending || !terminal.lines.length) return;
if (terminalContainer.scrollTop < 50 && currentWorkspace && currentSurface) { if (terminalContainer.scrollTop < 50 && currentWorkspace && currentSurface) {
inScrollMode = true; inScrollMode = true;
scrollbackLines += PAGE_LINES; scrollbackLines += PAGE_LINES;
@ -294,15 +294,6 @@
settingsClose.addEventListener('click', closeSettings); settingsClose.addEventListener('click', closeSettings);
settingsOverlay.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) { function updateTitle(wsRef, surfaceRef) {
const el = document.getElementById('app-title'); const el = document.getElementById('app-title');
if (!el) return; if (!el) return;
@ -319,7 +310,7 @@
} }
} }
} }
const status = _parseStatus(rawTitle); const status = Sidebar.parseStatus(rawTitle);
el.innerHTML = ''; el.innerHTML = '';
if (status.state) { if (status.state) {
const dot = document.createElement('span'); const dot = document.createElement('span');

@ -285,7 +285,7 @@ class ClaudeParser {
_isTableRowLine(line) { _isTableRowLine(line) {
const s = this._stripAnsi(line).trimStart(); const s = this._stripAnsi(line).trimStart();
return /^[│┃]/.test(s); return /^[│┃]/.test(s);
} }
_isTableBorderLine(line) { _isTableBorderLine(line) {

@ -14,6 +14,10 @@ class ClaudeRenderer {
this._onAction = fn; this._onAction = fn;
} }
_stripAnsi(s) {
return s.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.)/g, '');
}
render(doc) { render(doc) {
const sig = this._signature(doc.blocks); const sig = this._signature(doc.blocks);
if (sig !== this._lastSignature) { if (sig !== this._lastSignature) {
@ -108,7 +112,7 @@ class ClaudeRenderer {
// Pattern-based coloring for a single line (no ANSI codes from server) // Pattern-based coloring for a single line (no ANSI codes from server)
_renderLine(line) { _renderLine(line) {
const stripped = line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); const stripped = this._stripAnsi(line);
let e = this._esc(stripped); let e = this._esc(stripped);
// Dingbats — spinner/thinking characters (✻ ✳ ✢ ✦ etc.) // Dingbats — spinner/thinking characters (✻ ✳ ✢ ✦ etc.)
e = e.replace(/([\u2700-\u27BF])/g, '<span style="color:var(--yellow)">$1</span>'); e = e.replace(/([\u2700-\u27BF])/g, '<span style="color:var(--yellow)">$1</span>');
@ -120,7 +124,7 @@ class ClaudeRenderer {
div.className = block.isActive ? 'cc-prompt cc-prompt-active' : 'cc-prompt'; div.className = block.isActive ? 'cc-prompt cc-prompt-active' : 'cc-prompt';
const firstLine = block.lines[0]; 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 markerMatch = stripped.match(/^(\s*\u276F\s*)/);
const text = markerMatch ? firstLine.slice(markerMatch[1].length) : firstLine; const text = markerMatch ? firstLine.slice(markerMatch[1].length) : firstLine;
@ -212,16 +216,12 @@ class ClaudeRenderer {
// Strip ╭/╰ border lines; for │ content lines, remove the │ markers // Strip ╭/╰ border lines; for │ content lines, remove the │ markers
// and trailing terminal padding so content is immune to terminal width changes // 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 = []; const contentLines = [];
for (const line of block.lines) { for (const line of block.lines) {
const s = stripAnsi(line).trimStart(); const s = this._stripAnsi(line).trimStart();
if (/^[│┃]/.test(s)) { if (/^[│┃║]/.test(s)) {
// Remove leading ANSI codes + │ + optional space let cleaned = line.replace(/^(?:\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.))*[│┃║]\s?/, '');
let cleaned = line.replace(/^(\x1b\[[0-9;]*[A-Za-z])*[│┃]\s?/, ''); cleaned = cleaned.replace(/\s*[│┃║](?:\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.))*\s*$/, '');
// Remove trailing space + │ + trailing ANSI codes
cleaned = cleaned.replace(/\s*[│┃](\x1b\[[0-9;]*[A-Za-z])*\s*$/, '');
// Remove terminal box padding (trailing spaces)
contentLines.push(cleaned.trimEnd()); contentLines.push(cleaned.trimEnd());
} }
// Skip ╭/╰ border lines // Skip ╭/╰ border lines
@ -236,9 +236,7 @@ class ClaudeRenderer {
_renderThinking(block) { _renderThinking(block) {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'cc-thinking'; div.className = 'cc-thinking';
// Strip ANSI escape codes — thinking spinner lines are often colorized div.textContent = this._stripAnsi(block.lines.join('\n'));
// eslint-disable-next-line no-control-regex
div.textContent = block.lines.join('\n').replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
return div; return div;
} }
@ -296,14 +294,13 @@ class ClaudeRenderer {
const tbody = document.createElement('tbody'); const tbody = document.createElement('tbody');
let headerDone = false; let headerDone = false;
const stripAnsi = s => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
const isMiddleBorder = s => /^[]/.test(s); const isMiddleBorder = s => /^[]/.test(s);
const isBorderLine = s => /^[]/.test(s); const isBorderLine = s => /^[]/.test(s);
const isDataLine = s => /^[]/.test(s); const isDataLine = s => /^[]/.test(s);
const lines = block.lines; const lines = block.lines;
for (let i = 0; i < lines.length; i++) { 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 (isBorderLine(stripped)) {
if (isMiddleBorder(stripped)) headerDone = true; if (isMiddleBorder(stripped)) headerDone = true;
@ -316,7 +313,7 @@ class ClaudeRenderer {
let nextIsMiddleBorder = false; let nextIsMiddleBorder = false;
if (!headerDone) { if (!headerDone) {
for (let j = i + 1; j < lines.length; j++) { 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 (!ns) continue;
if (isMiddleBorder(ns)) nextIsMiddleBorder = true; if (isMiddleBorder(ns)) nextIsMiddleBorder = true;
break; break;
@ -324,7 +321,7 @@ class ClaudeRenderer {
} }
const isHeader = !headerDone && nextIsMiddleBorder; const isHeader = !headerDone && nextIsMiddleBorder;
const cells = stripped.split(/[│┃]/).slice(1, -1); const cells = stripped.split(/[│┃]/).slice(1, -1);
const tr = document.createElement('tr'); const tr = document.createElement('tr');
for (const cell of cells) { for (const cell of cells) {

@ -97,7 +97,7 @@ class Sidebar {
} }
const titleRaw = surface.title || surface.ref; const titleRaw = surface.title || surface.ref;
const status = this._parseStatus(titleRaw); const status = Sidebar.parseStatus(titleRaw);
const dot = document.createElement('span'); const dot = document.createElement('span');
dot.className = 'surface-status'; 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) // Working: Braille spinner (U+2800-U+28FF), ⏺ (U+23FA), ● (U+25CF), ⬤ (U+2B24)
const workingRe = /^[\u2800-\u28FF\u23FA\u25CF\u2B24]\s*/; const workingRe = /^[\u2800-\u28FF\u23FA\u25CF\u2B24]\s*/;
// Idle/waiting: dingbats range — ✳ ✻ ✢ ✦ etc. // Idle/waiting: dingbats range — ✳ ✻ ✢ ✦ etc.

@ -1,5 +1,3 @@
process.noDeprecation = true;
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { readFileSync, existsSync } from 'node:fs'; import { readFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os'; import { homedir } from 'node:os';

@ -153,7 +153,6 @@ export class WsServer {
case 'scroll-request': { case 'scroll-request': {
// Use capture-pane for scrollback history (read-screen only shows current visible area) // 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); 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'); const lines = content.split('\n');
this.sendTo(clientId, { this.sendTo(clientId, {
type: 'screen', type: 'screen',

Loading…
Cancel
Save