feat: initial commit with UX improvements

Add full project including 12 UX improvements: wider gesture edge zone (50px),
prefers-reduced-motion support, additional shortcut keys (^A ^E ^R ^W ^U) and
PageDown, Escape to close sidebar, haptic feedback, key repeat for arrow keys,
sidebar loading/empty state, login loading state, reconnect status indicator,
terminal dim on disconnect, QR modal focus management, and desktop keyboard toggle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 5 months ago
commit 8bcd7eacd8

4
.gitignore vendored

@ -0,0 +1,4 @@
node_modules/
dist/
.claude/
*.env

@ -0,0 +1,2 @@
#!/usr/bin/env node
import '../dist/index.js';

3590
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,38 @@
{
"name": "cmux-remote",
"version": "0.1.0",
"description": "Browser-based remote terminal control for cmux",
"type": "module",
"bin": {
"cmux-remote": "./bin/cmux-remote.js"
},
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"dev:local": "tsx src/index.ts --no-tunnel",
"start": "node dist/index.js",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"bcrypt": "^5.1.0",
"commander": "^13.0.0",
"express": "^5.0.0",
"jsonwebtoken": "^9.0.0",
"nanoid": "^5.0.0",
"qrcode": "^1.5.4",
"qrcode-terminal": "^0.12.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "^22.0.0",
"@types/qrcode": "^1.5.6",
"@types/ws": "^8.5.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

@ -0,0 +1,204 @@
.virtual-keyboard {
flex-shrink: 0;
background: var(--bg-secondary);
backdrop-filter: blur(var(--blur-amount));
-webkit-backdrop-filter: blur(var(--blur-amount));
border-top: 1px solid var(--border-color);
padding: 8px 8px;
padding-bottom: max(8px, env(safe-area-inset-bottom));
}
.key-row {
display: flex;
gap: 5px;
margin-bottom: 5px;
align-items: center;
}
.key-row:last-child {
margin-bottom: 0;
}
/* ── Physical key style ──────────────────────────── */
.key {
flex: 0 0 auto;
min-width: 40px;
height: 36px;
border: 1px solid var(--key-border);
border-bottom: 2px solid var(--key-depth);
border-radius: var(--radius-xs);
background: var(--key-bg);
background-image: linear-gradient(180deg, var(--key-bg-highlight) 0%, transparent 60%);
color: var(--text-primary);
font-size: 0.77rem;
font-weight: 500;
font-family: inherit;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0 7px;
-webkit-user-select: none;
user-select: none;
transition: background var(--transition-fast), transform var(--transition-fast), border-color var(--transition-fast);
}
.key:active {
background: var(--key-active-bg);
background-image: none;
border-bottom-width: 1px;
transform: translateY(1px);
}
.key.modifier {
font-weight: 600;
font-size: 0.7rem;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--text-secondary);
}
.key.modifier.active {
background: var(--accent);
background-image: var(--accent-gradient);
color: #fff;
border-color: transparent;
border-bottom-color: var(--accent-dim);
box-shadow: 0 0 14px var(--accent-glow);
}
.key.modifier.locked {
background: var(--accent);
background-image: var(--accent-gradient);
color: #fff;
border-color: transparent;
border-bottom-color: var(--accent-dim);
box-shadow: 0 0 0 2px var(--accent-hover), 0 0 20px var(--accent-glow);
}
.key.shortcut {
font-family: 'SF Mono', 'JetBrains Mono', 'Menlo', monospace;
font-size: 0.7rem;
font-weight: 700;
color: var(--text-secondary);
min-width: 36px;
}
.key-spacer {
flex: 1;
}
/* ── Input row — pill style ──────────────────────── */
.input-row {
gap: 6px;
}
.text-input {
flex: 1;
height: 38px;
border: 1px solid var(--border-color);
border-radius: var(--radius-pill);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 0.9rem;
padding: 0 16px;
outline: none;
font-family: 'SF Mono', 'JetBrains Mono', 'Menlo', monospace;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.text-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.text-input::placeholder {
color: var(--text-dimmed);
}
.send-key {
width: 38px;
height: 38px;
min-width: unset;
padding: 0;
flex-shrink: 0;
border-radius: var(--radius-pill);
background: var(--accent);
background-image: var(--accent-gradient);
color: #fff;
border: none;
border-bottom: none;
font-size: 1.05rem;
font-weight: 700;
box-shadow: 0 2px 10px var(--accent-glow);
}
.send-key:active {
background-image: none;
background: var(--accent-dim);
transform: scale(0.92);
box-shadow: none;
}
/* ── Landscape ───────────────────────────────────── */
@media (max-height: 500px) {
.key {
height: 30px;
min-width: 34px;
font-size: 0.68rem;
}
.text-input, .send-key {
height: 30px;
}
.virtual-keyboard {
padding: 4px;
}
.key-row {
margin-bottom: 3px;
}
}
/* ── Desktop ─────────────────────────────────────── */
@media (min-width: 1024px) {
.virtual-keyboard {
padding: 10px 20px;
padding-bottom: max(10px, env(safe-area-inset-bottom));
}
.key {
min-width: 46px;
height: 34px;
}
.key:hover {
background-image: none;
background: var(--key-active-bg);
border-color: var(--accent);
}
.text-input {
max-width: 640px;
}
.send-key:hover {
filter: brightness(1.12);
box-shadow: 0 4px 16px var(--accent-glow);
}
}
.key-row.shortcut-keys { flex-wrap: wrap; }
.key-row.shortcut-keys .key.shortcut { min-width: 32px; padding: 0 5px; }
.keyboard-toggle { display: none; }
@media (min-width: 1024px) {
.keyboard-toggle {
display: flex; align-items: center; justify-content: center; gap: 6px;
width: 100%; height: 28px;
background: var(--bg-secondary); border: none;
border-top: 1px solid var(--border-color);
color: var(--text-dimmed); font-size: 0.72rem;
cursor: pointer; flex-shrink: 0;
}
.keyboard-toggle:hover { color: var(--text-secondary); background: var(--bg-tertiary); }
.virtual-keyboard.collapsed { display: none; }
}

@ -0,0 +1,322 @@
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
background: var(--bg-primary);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Inter', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.app-container {
display: flex;
flex-direction: column;
height: 100%;
height: 100dvh;
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
}
/* ── Top bar ─────────────────────────────────────── */
.top-bar {
display: flex;
align-items: center;
height: 48px;
padding: 0 10px;
background: var(--bg-secondary);
backdrop-filter: blur(var(--blur-amount));
-webkit-backdrop-filter: blur(var(--blur-amount));
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
gap: 8px;
z-index: 10;
position: relative;
}
/* Accent line at top */
.top-bar::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: var(--accent-gradient);
opacity: 0.5;
}
.menu-btn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.15rem;
width: 34px;
height: 34px;
padding: 0;
cursor: pointer;
border-radius: var(--radius-sm);
transition: background var(--transition-fast), color var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.menu-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.app-title {
font-weight: 700;
font-size: 0.85rem;
letter-spacing: -0.02em;
color: var(--text-primary);
white-space: nowrap;
}
.top-bar-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
}
/* Connection status pill */
.connection-status {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px 3px 6px;
border-radius: var(--radius-pill);
background: rgba(248, 113, 113, 0.08);
border: 1px solid rgba(248, 113, 113, 0.15);
font-size: 0.7rem;
font-weight: 600;
color: var(--red);
transition: all var(--transition-normal);
white-space: nowrap;
}
.connection-status::before {
content: '';
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--red);
flex-shrink: 0;
box-shadow: 0 0 5px var(--red);
}
.connection-status.connected {
background: rgba(52, 211, 153, 0.08);
border-color: rgba(52, 211, 153, 0.15);
color: var(--green);
}
.connection-status.connected::before {
background: var(--green);
box-shadow: 0 0 6px var(--green);
}
@keyframes status-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
.connection-status:not(.connected) {
animation: status-pulse 2.5s ease-in-out infinite;
}
.theme-toggle {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1rem;
padding: 0;
cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
transition: background var(--transition-fast), color var(--transition-fast);
flex-shrink: 0;
}
.theme-toggle:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
/* ── Terminal container ──────────────────────────── */
.terminal-container {
flex: 1;
overflow-y: auto;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
background: var(--terminal-bg);
}
.terminal-container::-webkit-scrollbar {
width: 4px;
}
.terminal-container::-webkit-scrollbar-track {
background: transparent;
}
.terminal-container::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 2px;
}
/* ── QR button ───────────────────────────────────── */
.qr-btn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.1rem;
padding: 0;
cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
transition: background var(--transition-fast), color var(--transition-fast);
flex-shrink: 0;
}
.qr-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
/* ── QR modal ────────────────────────────────────── */
.qr-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.qr-modal-overlay[hidden] {
display: none;
}
.qr-modal {
position: relative;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg, 12px);
padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
max-width: min(360px, 90vw);
width: 100%;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
}
.qr-modal-close {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
transition: background var(--transition-fast), color var(--transition-fast);
}
.qr-modal-close:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.qr-modal-image {
width: min(260px, 80vw);
height: min(260px, 80vw);
display: block;
border-radius: 8px;
background: #fff;
padding: 8px;
}
.qr-modal-url {
font-size: 0.72rem;
color: var(--text-secondary);
word-break: break-all;
text-align: center;
max-width: 100%;
font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
}
/* ── Desktop layout ──────────────────────────────── */
@media (min-width: 1024px) {
body {
display: flex;
flex-direction: row;
}
.sidebar-overlay {
display: none;
}
.app-container {
flex: 1;
min-width: 0;
}
.top-bar {
height: 50px;
padding: 0 18px;
}
.menu-btn {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.connection-status:not(.connected) { animation: none; }
}
.connection-status.reconnecting {
background: rgba(251, 191, 36, 0.08);
border-color: rgba(251, 191, 36, 0.15);
color: var(--yellow);
}
.connection-status.reconnecting::before {
background: var(--yellow);
box-shadow: 0 0 5px var(--yellow);
}
.terminal-container.stale { position: relative; }
.terminal-container.stale::after {
content: '';
position: absolute;
inset: 0;
background: var(--bg-primary);
opacity: 0.4;
pointer-events: none;
z-index: 1;
}

@ -0,0 +1,227 @@
.sidebar {
position: fixed;
top: 0;
left: 0;
width: 272px;
height: 100%;
background: var(--sidebar-bg);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-right: 1px solid var(--sidebar-border);
z-index: 100;
transform: translateX(-100%);
transition: transform var(--transition-smooth);
display: flex;
flex-direction: column;
padding-top: env(safe-area-inset-top);
box-shadow: var(--shadow-lg);
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
z-index: 99;
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-smooth);
}
.sidebar-overlay.visible {
opacity: 1;
pointer-events: auto;
}
/* ── Header ──────────────────────────────────────── */
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 14px 12px;
border-bottom: 1px solid var(--sidebar-border);
}
.sidebar-header h2 {
margin: 0;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-dimmed);
}
.sidebar-close {
background: none;
border: none;
color: var(--text-dimmed);
font-size: 1.25rem;
cursor: pointer;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
transition: background var(--transition-fast), color var(--transition-fast);
line-height: 1;
padding: 0;
}
.sidebar-close:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
/* ── Content ─────────────────────────────────────── */
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 10px 0 16px;
}
.sidebar-content::-webkit-scrollbar {
width: 3px;
}
.sidebar-content::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 2px;
}
/* ── Group label ─────────────────────────────────── */
.ws-group-label {
padding: 14px 16px 5px;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-dimmed);
font-weight: 700;
}
/* ── Workspace item ──────────────────────────────── */
.ws-item {
padding: 9px 14px;
cursor: pointer;
display: flex;
align-items: center;
gap: 9px;
font-size: 0.86rem;
font-weight: 500;
color: var(--text-secondary);
transition: background var(--transition-fast), color var(--transition-fast);
margin: 1px 8px;
border-radius: var(--radius-sm);
}
.ws-item:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.ws-item.active {
background: var(--accent-soft);
color: var(--accent-hover);
}
.ws-item .ws-icon {
color: var(--text-dimmed);
font-size: 0.8rem;
width: 18px;
text-align: center;
flex-shrink: 0;
}
.ws-item .ws-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Surface item ────────────────────────────────── */
.surface-item {
padding: 8px 12px;
cursor: pointer;
font-size: 0.83rem;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
transition: background var(--transition-fast), color var(--transition-fast);
margin: 1px 8px;
border-radius: var(--radius-sm);
position: relative;
}
.surface-item:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.surface-item.active {
background: linear-gradient(90deg, var(--accent-soft) 0%, transparent 100%);
color: var(--accent-hover);
font-weight: 600;
}
/* Left accent bar for active item */
.surface-item.active::before {
content: '';
position: absolute;
left: -8px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 60%;
background: var(--accent-gradient);
border-radius: 0 var(--radius-pill) var(--radius-pill) 0;
}
.surface-item .surface-icon {
font-size: 0.75rem;
width: 16px;
text-align: center;
flex-shrink: 0;
opacity: 0.6;
}
.surface-item.active .surface-icon {
opacity: 1;
}
/* ── Desktop ─────────────────────────────────────── */
@media (min-width: 1024px) {
.sidebar {
position: relative;
transform: none;
width: 252px;
flex-shrink: 0;
box-shadow: none;
height: 100vh;
height: 100dvh;
}
.sidebar.open {
transform: none;
}
.sidebar-overlay {
display: none;
}
.sidebar-close {
display: none;
}
}
.sidebar-empty {
padding: 24px 16px;
text-align: center;
color: var(--text-dimmed);
font-size: 0.8rem;
}

@ -0,0 +1,101 @@
@font-face {
font-family: 'SymbolsNerdFont';
src: url('/fonts/SymbolsNerdFontMono-Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: block;
unicode-range: U+23FB-23FE, U+2665, U+26A1, U+2B58, U+E000-E00A, U+E0A0-E0A2, U+E0A3, U+E0B0-E0C8, U+E0CA, U+E0CC-E0D7, U+E200-E2A9, U+E300-E3E3, U+E5FA-E6B7, U+E700-E7C5, U+EA60-EBEB, U+F000-F2FF, U+F400-F533, U+F0001-F1AF0;
}
.terminal-output {
margin: 0;
padding: 12px 16px;
font-family: 'SF Mono', 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'Menlo', 'Monaco', 'Consolas', 'SymbolsNerdFont', 'Symbols Nerd Font Mono', monospace;
font-size: 13px;
line-height: 1.0;
white-space: pre;
word-wrap: normal;
color: var(--text-primary);
contain: content;
min-height: 100%;
-webkit-user-select: text;
user-select: text;
font-feature-settings: "liga" 0, "calt" 0;
}
.terminal-output::selection,
.terminal-output *::selection {
background: var(--accent-glow);
color: inherit;
}
/* Claude Code pattern highlighting */
.hl-tool {
color: var(--blue);
}
.hl-thinking {
color: var(--purple);
}
.hl-output {
color: var(--gray);
}
.hl-prompt {
color: var(--green);
font-weight: 600;
}
.hl-success {
color: var(--green);
}
.hl-error {
color: var(--red);
}
.hl-path {
color: var(--cyan);
}
.hl-box {
color: var(--text-dimmed);
}
.hl-dim {
color: var(--text-dimmed);
}
.hl-header {
color: var(--yellow);
font-weight: 600;
}
.hl-linenum {
color: var(--text-dimmed);
}
.hl-added {
color: var(--green);
}
.hl-removed {
color: var(--red);
}
/* QR code lines — font-size set dynamically in terminal-view.js to fit width */
.qr-line {
display: block;
line-height: 1.0;
letter-spacing: -0.03em;
}
@media (min-width: 1024px) {
.terminal-output {
padding: 16px 24px;
font-size: 13.5px;
}
}

@ -0,0 +1,153 @@
:root,
[data-theme="dark"] {
--bg-primary: #0b0b13;
--bg-secondary: rgba(14, 14, 24, 0.9);
--bg-tertiary: rgba(255, 255, 255, 0.04);
--bg-elevated: rgba(20, 20, 36, 0.95);
--bg-glass: rgba(14, 14, 24, 0.75);
--terminal-bg: #080810;
--text-primary: #e2e2ec;
--text-secondary: #7878a0;
--text-dimmed: #3e3e58;
--border-color: rgba(255, 255, 255, 0.055);
--border-subtle: rgba(255, 255, 255, 0.025);
--border-strong: rgba(255, 255, 255, 0.1);
--accent: #6366f1;
--accent-hover: #818cf8;
--accent-dim: #4f52cc;
--accent-glow: rgba(99, 102, 241, 0.18);
--accent-soft: rgba(99, 102, 241, 0.08);
--accent-gradient: linear-gradient(135deg, #6366f1, #a78bfa);
--green: #34d399;
--green-glow: rgba(52, 211, 153, 0.2);
--red: #f87171;
--red-glow: rgba(248, 113, 113, 0.15);
--blue: #60a5fa;
--purple: #a78bfa;
--cyan: #22d3ee;
--yellow: #fbbf24;
--orange: #fb923c;
--gray: #6b7280;
--key-bg: rgba(26, 26, 46, 0.9);
--key-bg-highlight: rgba(255, 255, 255, 0.035);
--key-border: rgba(255, 255, 255, 0.07);
--key-depth: rgba(0, 0, 0, 0.5);
--key-active-bg: rgba(99, 102, 241, 0.15);
--sidebar-bg: #09090f;
--sidebar-border: rgba(255, 255, 255, 0.04);
--scrollbar-thumb: rgba(255, 255, 255, 0.07);
--scrollbar-track: transparent;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 8px 40px rgba(0, 0, 0, 0.6);
--blur-amount: 20px;
--transition-fast: 120ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 220ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-smooth: 320ms cubic-bezier(0.16, 1, 0.3, 1);
--radius-xs: 5px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 18px;
--radius-pill: 100px;
}
[data-theme="light"] {
--bg-primary: #f4f4f8;
--bg-secondary: rgba(255, 255, 255, 0.9);
--bg-tertiary: rgba(0, 0, 0, 0.035);
--bg-elevated: rgba(255, 255, 255, 0.95);
--bg-glass: rgba(255, 255, 255, 0.75);
--terminal-bg: #fafafa;
--text-primary: #0f0f1a;
--text-secondary: #60607a;
--text-dimmed: #aaaabb;
--border-color: rgba(0, 0, 0, 0.07);
--border-subtle: rgba(0, 0, 0, 0.04);
--border-strong: rgba(0, 0, 0, 0.12);
--accent: #5558eb;
--accent-hover: #4347d4;
--accent-dim: #7b7ef5;
--accent-glow: rgba(85, 88, 235, 0.12);
--accent-soft: rgba(85, 88, 235, 0.07);
--accent-gradient: linear-gradient(135deg, #5558eb, #9b59f5);
--green: #059669;
--green-glow: rgba(5, 150, 105, 0.12);
--red: #dc2626;
--red-glow: rgba(220, 38, 38, 0.1);
--blue: #2563eb;
--purple: #7c3aed;
--cyan: #0891b2;
--yellow: #d97706;
--orange: #ea580c;
--gray: #9ca3af;
--key-bg: rgba(255, 255, 255, 0.95);
--key-bg-highlight: rgba(255, 255, 255, 0.6);
--key-border: rgba(0, 0, 0, 0.09);
--key-depth: rgba(0, 0, 0, 0.12);
--key-active-bg: rgba(85, 88, 235, 0.08);
--sidebar-bg: #eeeef4;
--sidebar-border: rgba(0, 0, 0, 0.05);
--scrollbar-thumb: rgba(0, 0, 0, 0.1);
--scrollbar-track: transparent;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.07);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 40px rgba(0, 0, 0, 0.1);
--blur-amount: 20px;
--transition-fast: 120ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 220ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-smooth: 320ms cubic-bezier(0.16, 1, 0.3, 1);
--radius-xs: 5px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 18px;
--radius-pill: 100px;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--bg-primary: #f4f4f8;
--bg-secondary: rgba(255, 255, 255, 0.9);
--bg-tertiary: rgba(0, 0, 0, 0.035);
--bg-elevated: rgba(255, 255, 255, 0.95);
--bg-glass: rgba(255, 255, 255, 0.75);
--terminal-bg: #fafafa;
--text-primary: #0f0f1a;
--text-secondary: #60607a;
--text-dimmed: #aaaabb;
--border-color: rgba(0, 0, 0, 0.07);
--border-subtle: rgba(0, 0, 0, 0.04);
--border-strong: rgba(0, 0, 0, 0.12);
--accent: #5558eb;
--accent-hover: #4347d4;
--accent-dim: #7b7ef5;
--accent-glow: rgba(85, 88, 235, 0.12);
--accent-soft: rgba(85, 88, 235, 0.07);
--accent-gradient: linear-gradient(135deg, #5558eb, #9b59f5);
--green: #059669;
--green-glow: rgba(5, 150, 105, 0.12);
--red: #dc2626;
--red-glow: rgba(220, 38, 38, 0.1);
--blue: #2563eb;
--purple: #7c3aed;
--cyan: #0891b2;
--yellow: #d97706;
--orange: #ea580c;
--gray: #9ca3af;
--key-bg: rgba(255, 255, 255, 0.95);
--key-bg-highlight: rgba(255, 255, 255, 0.6);
--key-border: rgba(0, 0, 0, 0.09);
--key-depth: rgba(0, 0, 0, 0.12);
--key-active-bg: rgba(85, 88, 235, 0.08);
--sidebar-bg: #eeeef4;
--sidebar-border: rgba(0, 0, 0, 0.05);
--scrollbar-thumb: rgba(0, 0, 0, 0.1);
--scrollbar-track: transparent;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.07);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 40px rgba(0, 0, 0, 0.1);
--blur-amount: 20px;
}
}

@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>cmux-remote</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0c0c14">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/css/themes.css">
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/terminal.css">
<link rel="stylesheet" href="/css/keyboard.css">
<link rel="stylesheet" href="/css/sidebar.css">
</head>
<body>
<!-- Sidebar -->
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<h2>Workspaces</h2>
<button class="sidebar-close" id="sidebar-close" aria-label="Close sidebar">&times;</button>
</div>
<div class="sidebar-content" id="sidebar-tree"></div>
</aside>
<!-- Main layout -->
<div class="app-container">
<!-- Top bar -->
<header class="top-bar">
<button class="menu-btn" id="menu-btn" aria-label="Open sidebar">&#9776;</button>
<span class="app-title">cmux-remote</span>
<div class="top-bar-right">
<span class="connection-status" id="connection-status">Offline</span>
<button class="qr-btn" id="qr-btn" aria-label="Show QR code">&#x2337;</button>
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"></button>
</div>
</header>
<!-- Terminal area -->
<main class="terminal-container" id="terminal-container">
<pre class="terminal-output" id="terminal-output"></pre>
</main>
<!-- Virtual keyboard -->
<button class="keyboard-toggle" id="keyboard-toggle" aria-label="Toggle keyboard" hidden>&#x2328; Keyboard</button>
<footer class="virtual-keyboard" id="virtual-keyboard">
<div class="key-row special-keys">
<button class="key" data-key="Escape">Esc</button>
<button class="key" data-key="Tab">Tab</button>
<button class="key modifier" data-modifier="ctrl" id="ctrl-key">Ctrl</button>
<button class="key modifier" data-modifier="alt" id="alt-key">Alt</button>
<span class="key-spacer"></span>
<button class="key" data-key="ArrowUp">&uarr;</button>
<button class="key" data-key="PageUp">PgUp</button>
<button class="key" data-key="PageDown">PgDn</button>
</div>
<div class="key-row shortcut-keys">
<button class="key shortcut" data-key="Ctrl-c">^C</button>
<button class="key shortcut" data-key="Ctrl-d">^D</button>
<button class="key shortcut" data-key="Ctrl-z">^Z</button>
<button class="key shortcut" data-key="Ctrl-l">^L</button>
<button class="key shortcut" data-key="Ctrl-a">^A</button>
<button class="key shortcut" data-key="Ctrl-e">^E</button>
<button class="key shortcut" data-key="Ctrl-r">^R</button>
<button class="key shortcut" data-key="Ctrl-w">^W</button>
<button class="key shortcut" data-key="Ctrl-u">^U</button>
<span class="key-spacer"></span>
<button class="key" data-key="ArrowLeft">&larr;</button>
<button class="key" data-key="ArrowDown">&darr;</button>
<button class="key" data-key="ArrowRight">&rarr;</button>
</div>
<div class="key-row input-row">
<input type="text" class="text-input" id="text-input" placeholder="Type here..."
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<button class="key send-key" id="send-btn">&crarr;</button>
</div>
</footer>
</div>
<!-- QR modal -->
<div class="qr-modal-overlay" id="qr-modal-overlay" hidden>
<div class="qr-modal" role="dialog" aria-modal="true" aria-label="QR Code">
<button class="qr-modal-close" id="qr-modal-close" aria-label="Close">&times;</button>
<img class="qr-modal-image" src="/api/qr.svg" alt="QR code for access URL" id="qr-modal-image">
<div class="qr-modal-url" id="qr-modal-url"></div>
</div>
</div>
<script src="/js/websocket-client.js"></script>
<script src="/js/terminal-view.js"></script>
<script src="/js/virtual-keyboard.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/gestures.js"></script>
<script src="/js/theme.js"></script>
<script src="/js/app.js"></script>
<script>
// Unregister any cached service workers so CSS/JS changes are always fresh
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(regs => {
regs.forEach(r => r.unregister());
});
}
</script>
</body>
</html>

@ -0,0 +1,197 @@
// Main application orchestrator
(async function () {
// Check if auth is required
const authStatus = await fetch('/api/auth/status').then((r) => r.json()).catch(() => ({ authRequired: true }));
if (authStatus.authRequired) {
const token = localStorage.getItem('cmux-remote-token');
if (!token) {
window.location.href = '/login.html';
return;
}
}
// State
let currentWorkspace = null;
let currentSurface = null;
// Components
const ws = new WebSocketClient();
const terminal = new TerminalView(document.getElementById('terminal-output'));
const statusDot = document.getElementById('connection-status');
const terminalContainer = document.getElementById('terminal-container');
const sidebar = new Sidebar((wsRef, surfaceRef) => {
switchSurface(wsRef, surfaceRef);
});
const gestures = new GestureHandler(sidebar);
const theme = new ThemeSwitcher();
// QR modal
const qrOverlay = document.getElementById('qr-modal-overlay');
const qrBtn = document.getElementById('qr-btn');
const qrCloseBtn = document.getElementById('qr-modal-close');
const qrUrlEl = document.getElementById('qr-modal-url');
function _qrKeyHandler(e) {
if (e.key === 'Escape') {
closeQrModal();
} else if (e.key === 'Tab') {
e.preventDefault();
qrCloseBtn.focus();
}
}
async function openQrModal() {
try {
const { url } = await fetch('/api/access-url').then(r => r.json());
qrUrlEl.textContent = url;
} catch {
qrUrlEl.textContent = window.location.origin;
}
// Reload QR image so it's always fresh
document.getElementById('qr-modal-image').src = '/api/qr.svg?' + Date.now();
qrOverlay.hidden = false;
qrCloseBtn.focus();
document.addEventListener('keydown', _qrKeyHandler);
}
function closeQrModal() {
qrOverlay.hidden = true;
document.removeEventListener('keydown', _qrKeyHandler);
qrBtn.focus();
}
qrBtn.addEventListener('click', openQrModal);
qrCloseBtn.addEventListener('click', closeQrModal);
qrOverlay.addEventListener('click', (e) => {
if (e.target === qrOverlay) closeQrModal();
});
const keyboard = new VirtualKeyboard(
(text) => {
if (currentWorkspace && currentSurface) {
ws.sendText(currentWorkspace, currentSurface, text);
}
},
(key) => {
if (currentWorkspace && currentSurface) {
ws.sendKey(currentWorkspace, currentSurface, key);
}
}
);
// Keyboard toggle (desktop)
const keyboardToggle = document.getElementById('keyboard-toggle');
const virtualKeyboardEl = document.getElementById('virtual-keyboard');
if (window.innerWidth >= 1024) {
virtualKeyboardEl.classList.add('collapsed');
keyboardToggle.hidden = false;
}
keyboardToggle.addEventListener('click', () => {
virtualKeyboardEl.classList.toggle('collapsed');
});
window.addEventListener('resize', () => {
if (window.innerWidth < 1024) {
keyboardToggle.hidden = true;
virtualKeyboardEl.classList.remove('collapsed');
} else {
keyboardToggle.hidden = false;
}
});
// Connection events
ws.on('connected', () => {
statusDot.classList.add('connected');
statusDot.classList.remove('reconnecting');
statusDot.textContent = 'Connected';
terminalContainer.classList.remove('stale');
});
ws.on('disconnected', () => {
statusDot.classList.remove('connected');
statusDot.classList.add('reconnecting');
statusDot.textContent = 'Reconnecting...';
terminalContainer.classList.add('stale');
});
ws.on('reconnecting', ({ attempt }) => {
statusDot.textContent = `Reconnecting (${attempt})...`;
});
ws.on('auth-ok', () => {
// Request workspace list after auth
ws.listWorkspaces();
});
ws.on('workspaces', (msg) => {
sidebar.setWorkspaces(msg.workspaces);
// Auto-subscribe to first terminal surface if none selected
if (!currentSurface && msg.workspaces.length > 0) {
const firstWs = msg.workspaces[0];
for (const pane of firstWs.panes) {
for (const surface of pane.surfaces) {
if (surface.type === 'terminal') {
switchSurface(firstWs.ref, surface.ref);
return;
}
}
}
// Fallback: first surface of any type
if (firstWs.panes.length > 0 && firstWs.panes[0].surfaces.length > 0) {
const s = firstWs.panes[0].surfaces[0];
switchSurface(firstWs.ref, s.ref);
}
}
});
ws.on('screen', (msg) => {
if (msg.surface === currentSurface) {
terminal.setContent(msg.lines);
}
});
ws.on('screen-diff', (msg) => {
if (msg.surface === currentSurface) {
terminal.applyDiff(msg.patches);
}
});
function switchSurface(wsRef, surfaceRef) {
// Unsubscribe from current
if (currentSurface) {
ws.unsubscribe(currentSurface);
}
currentWorkspace = wsRef;
currentSurface = surfaceRef;
terminal.clear();
ws.subscribe(wsRef, surfaceRef);
sidebar.setActiveSurface(surfaceRef);
}
// Visibility API: pause polling when hidden
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (currentSurface) ws.unsubscribe(currentSurface);
} else {
if (currentWorkspace && currentSurface) {
ws.subscribe(currentWorkspace, currentSurface);
}
}
});
// Periodic workspace refresh
setInterval(() => {
if (ws.isConnected()) {
ws.listWorkspaces();
}
}, 10000);
// Start
ws.connect(authStatus.authRequired);
})();

@ -0,0 +1,58 @@
// Login page handler
(function () {
const form = document.getElementById('login-form');
const passwordInput = document.getElementById('password');
const errorEl = document.getElementById('error');
// If already authenticated, redirect
const token = localStorage.getItem('cmux-remote-token');
if (token) {
fetch('/api/auth/verify', {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((data) => {
if (data.valid) window.location.href = '/';
})
.catch(() => {});
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
errorEl.classList.remove('visible');
const password = passwordInput.value.trim();
if (!password) return;
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Connecting...';
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (res.ok && data.token) {
localStorage.setItem('cmux-remote-token', data.token);
window.location.href = '/';
} else {
errorEl.textContent = data.error || 'Login failed';
errorEl.classList.add('visible');
passwordInput.value = '';
passwordInput.focus();
submitBtn.disabled = false;
submitBtn.textContent = 'Connect';
}
} catch (err) {
errorEl.textContent = 'Connection failed';
errorEl.classList.add('visible');
submitBtn.disabled = false;
submitBtn.textContent = 'Connect';
}
});
})();

@ -0,0 +1,63 @@
class GestureHandler {
constructor(sidebar) {
this.sidebar = sidebar;
this.startX = 0;
this.startY = 0;
this.tracking = false;
document.addEventListener('touchstart', (e) => this.onTouchStart(e), { passive: true });
document.addEventListener('touchmove', (e) => this.onTouchMove(e), { passive: false });
document.addEventListener('touchend', (e) => this.onTouchEnd(e), { passive: true });
}
onTouchStart(e) {
const touch = e.touches[0];
this.startX = touch.clientX;
this.startY = touch.clientY;
this.tracking = true;
}
onTouchMove(e) {
if (!this.tracking) return;
const touch = e.touches[0];
const dx = touch.clientX - this.startX;
const dy = touch.clientY - this.startY;
// Only track horizontal swipes from edge
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 10) {
// Swipe right from left edge to open sidebar
if (dx > 0 && this.startX < 50 && !this.sidebar.isOpen()) {
e.preventDefault();
}
// Swipe left to close sidebar
if (dx < 0 && this.sidebar.isOpen()) {
e.preventDefault();
}
}
}
onTouchEnd(e) {
if (!this.tracking) return;
this.tracking = false;
const touch = e.changedTouches[0];
const dx = touch.clientX - this.startX;
const dy = touch.clientY - this.startY;
// Minimum swipe distance
if (Math.abs(dx) < 50 || Math.abs(dy) > Math.abs(dx)) return;
// Swipe right from left edge = open sidebar
if (dx > 0 && this.startX < 50) {
this.sidebar.open();
}
// Swipe left = close sidebar
if (dx < 0 && this.sidebar.isOpen()) {
this.sidebar.close();
}
}
}
window.GestureHandler = GestureHandler;

@ -0,0 +1,114 @@
class Sidebar {
constructor(onSelectSurface) {
this.onSelectSurface = onSelectSurface;
this.sidebar = document.getElementById('sidebar');
this.overlay = document.getElementById('sidebar-overlay');
this.tree = document.getElementById('sidebar-tree');
this.menuBtn = document.getElementById('menu-btn');
this.closeBtn = document.getElementById('sidebar-close');
this.workspaces = [];
this.activeSurface = null;
this._loaded = false;
this.menuBtn.addEventListener('click', () => this.open());
this.closeBtn.addEventListener('click', () => this.close());
this.overlay.addEventListener('click', () => this.close());
// On desktop, sidebar is always visible via CSS (position: relative)
// Check on resize to close overlay if switching to desktop
this._onResize = () => {
if (window.innerWidth >= 1024) {
this.overlay.classList.remove('visible');
}
};
window.addEventListener('resize', this._onResize);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.isOpen() && !this.isDesktop()) this.close();
});
this.render();
}
open() {
this.sidebar.classList.add('open');
this.overlay.classList.add('visible');
}
close() {
this.sidebar.classList.remove('open');
this.overlay.classList.remove('visible');
}
isDesktop() {
return window.innerWidth >= 1024;
}
isOpen() {
return this.sidebar.classList.contains('open');
}
setWorkspaces(workspaces) {
this.workspaces = workspaces;
this._loaded = true;
this.render();
}
setActiveSurface(surfaceRef) {
this.activeSurface = surfaceRef;
this.render();
}
render() {
this.tree.innerHTML = '';
if (this.workspaces.length === 0) {
const empty = document.createElement('div');
empty.className = 'sidebar-empty';
empty.textContent = this._loaded ? 'No workspaces' : 'Loading...';
this.tree.appendChild(empty);
return;
}
for (const ws of this.workspaces) {
// Workspace group label
const groupLabel = document.createElement('div');
groupLabel.className = 'ws-group-label';
groupLabel.textContent = ws.name || ws.ref;
this.tree.appendChild(groupLabel);
// Surfaces within workspace
for (const pane of ws.panes) {
for (const surface of pane.surfaces) {
const item = document.createElement('div');
item.className = 'surface-item';
if (surface.ref === this.activeSurface) {
item.classList.add('active');
}
const icon = document.createElement('span');
icon.className = 'surface-icon';
icon.textContent = surface.type === 'browser' ? '\u{1F310}' : '\u{1F4BB}';
const label = document.createElement('span');
label.className = 'ws-label';
label.textContent = surface.title || surface.ref;
item.appendChild(icon);
item.appendChild(label);
item.addEventListener('click', () => {
this.onSelectSurface(ws.ref, surface.ref);
this.setActiveSurface(surface.ref);
if (!this.isDesktop()) this.close();
});
this.tree.appendChild(item);
}
}
}
}
}
window.Sidebar = Sidebar;

@ -0,0 +1,170 @@
class TerminalView {
constructor(element) {
this.el = element;
this.lines = [];
this.renderPending = false;
this.autoScroll = true;
// Track scroll position to determine auto-scroll
const container = this.el.parentElement;
container.addEventListener('scroll', () => {
const atBottom =
container.scrollHeight - container.scrollTop - container.clientHeight < 30;
this.autoScroll = atBottom;
});
}
setContent(lines) {
this.lines = lines;
this.scheduleRender();
}
applyDiff(patches) {
for (const patch of patches) {
this.lines.splice(patch.startLine, patch.deleteCount, ...patch.insertLines);
}
this.scheduleRender();
}
scheduleRender() {
if (this.renderPending) return;
this.renderPending = true;
requestAnimationFrame(() => {
this.render();
this.renderPending = false;
});
}
isQrLine(line) {
// Detect QR code lines: mostly Unicode block characters (▀▄█)
const blockChars = (line.match(/[\u2580-\u2588]/g) || []).length;
const nonSpace = line.replace(/\s/g, '').length;
return nonSpace > 4 && blockChars / nonSpace > 0.3;
}
render() {
const output = [];
let i = 0;
while (i < this.lines.length) {
if (this.isQrLine(this.lines[i])) {
// Collect all consecutive QR lines
const qrBlock = [];
while (i < this.lines.length && this.isQrLine(this.lines[i])) {
qrBlock.push(this.lines[i]);
i++;
}
// Calculate font-size so QR block fits container width without clipping
const maxLen = Math.max(...qrBlock.map(l => l.length));
const containerWidth = (this.el.parentElement?.clientWidth || 375) - 32;
// monospace char width ≈ 0.62 × font-size
const fontSize = Math.max(3, Math.floor(containerWidth / (maxLen * 0.62)));
for (const qline of qrBlock) {
const esc = qline
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
output.push(`<span class="qr-line" style="font-size:${fontSize}px">${esc}</span>`);
}
} else {
output.push(this.highlightLine(this.lines[i]));
i++;
}
}
const html = output.join('\n');
this.el.innerHTML = html;
if (this.autoScroll) {
const container = this.el.parentElement;
container.scrollTop = container.scrollHeight;
}
}
highlightLine(line) {
// Escape HTML entities first
let escaped = line
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Apply highlighting patterns (order matters — more specific first)
// Diff: added lines (starts with +)
if (/^\+[^+]/.test(line)) {
return `<span class="hl-added">${escaped}</span>`;
}
// Diff: removed lines (starts with -)
if (/^-[^-]/.test(line)) {
return `<span class="hl-removed">${escaped}</span>`;
}
// Tool use markers (blue circle)
escaped = escaped.replace(
/(\u23FA|\u25CF|\u2B24)/g,
'<span class="hl-tool">$1</span>'
);
// Thinking markers
escaped = escaped.replace(
/(\u273B)/g,
'<span class="hl-thinking">$1</span>'
);
// Output indent marker
escaped = escaped.replace(
/(\u23BF)/g,
'<span class="hl-output">$1</span>'
);
// Prompt marker
escaped = escaped.replace(
/(\u276F)/g,
'<span class="hl-prompt">$1</span>'
);
// Success markers
escaped = escaped.replace(
/(\u2713|\u2714|passed|PASS)/g,
'<span class="hl-success">$1</span>'
);
// Error markers
escaped = escaped.replace(
/(\u2717|\u2718|error|ERROR|FAIL|failed)/g,
'<span class="hl-error">$1</span>'
);
// Box drawing characters
escaped = escaped.replace(
/([\u2500-\u257F\u250C\u2510\u2514\u2518\u251C\u2524\u252C\u2534\u253C]+)/g,
'<span class="hl-box">$1</span>'
);
// File paths (simplified: word containing / with file extension)
escaped = escaped.replace(
/(?<!\w)((?:\.?\.?\/)?(?:[\w.-]+\/)+[\w.-]+\.[\w]+)/g,
'<span class="hl-path">$1</span>'
);
// Line numbers in context (e.g., " 123 |" or ":42:")
escaped = escaped.replace(
/^(\s*\d+\s*[|:])/,
'<span class="hl-linenum">$1</span>'
);
return escaped;
}
scrollToBottom() {
const container = this.el.parentElement;
container.scrollTop = container.scrollHeight;
this.autoScroll = true;
}
clear() {
this.lines = [];
this.el.innerHTML = '';
}
}
window.TerminalView = TerminalView;

@ -0,0 +1,38 @@
class ThemeSwitcher {
constructor() {
this.btn = document.getElementById('theme-toggle');
this.current = localStorage.getItem('cmux-remote-theme') || 'auto';
this.apply();
this.btn.addEventListener('click', () => this.toggle());
}
toggle() {
const resolved = this.getResolved();
// Toggle to opposite of current effective theme
this.current = resolved === 'dark' ? 'light' : 'dark';
localStorage.setItem('cmux-remote-theme', this.current);
this.apply();
}
getResolved() {
if (this.current === 'auto') {
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
return this.current;
}
apply() {
const theme = this.getResolved();
document.documentElement.setAttribute('data-theme', theme);
this.btn.textContent = theme === 'dark' ? '\u{2600}' : '\u{1F319}';
// Update theme-color meta
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
meta.content = theme === 'dark' ? '#0c0c14' : '#f8f9fc';
}
}
}
window.ThemeSwitcher = ThemeSwitcher;

@ -0,0 +1,165 @@
class VirtualKeyboard {
constructor(onSendText, onSendKey) {
this.onSendText = onSendText;
this.onSendKey = onSendKey;
this.ctrlActive = false;
this.ctrlLocked = false;
this.altActive = false;
this.altLocked = false;
this._repeatTimer = null;
this._repeatInterval = null;
this.textInput = document.getElementById('text-input');
this.sendBtn = document.getElementById('send-btn');
this.ctrlKey = document.getElementById('ctrl-key');
this.altKey = document.getElementById('alt-key');
this.init();
}
static get REPEAT_KEYS() {
return new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'PageUp', 'PageDown']);
}
static get REPEAT_INITIAL_DELAY() { return 400; }
static get REPEAT_INTERVAL() { return 80; }
init() {
// Text input + send
this.sendBtn.addEventListener('click', () => this.submitText());
this.textInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.submitText();
}
});
// Ctrl modifier
this.ctrlKey.addEventListener('click', () => this.toggleModifier('ctrl'));
this.ctrlKey.addEventListener('dblclick', () => this.lockModifier('ctrl'));
// Alt modifier
this.altKey.addEventListener('click', () => this.toggleModifier('alt'));
this.altKey.addEventListener('dblclick', () => this.lockModifier('alt'));
// Special keys
document.querySelectorAll('.key[data-key]').forEach((btn) => {
if (btn.classList.contains('modifier')) return;
const key = btn.dataset.key;
if (VirtualKeyboard.REPEAT_KEYS.has(key)) {
const startRepeat = () => {
this._clearRepeat();
this.handleKeyPress(key);
this._repeatTimer = setTimeout(() => {
this._repeatInterval = setInterval(() => {
this.handleKeyPress(key);
}, VirtualKeyboard.REPEAT_INTERVAL);
}, VirtualKeyboard.REPEAT_INITIAL_DELAY);
};
btn.addEventListener('mousedown', startRepeat);
btn.addEventListener('touchstart', startRepeat, { passive: true });
btn.addEventListener('mouseup', () => this._clearRepeat());
btn.addEventListener('mouseleave', () => this._clearRepeat());
btn.addEventListener('touchend', () => this._clearRepeat());
btn.addEventListener('touchcancel', () => this._clearRepeat());
} else {
btn.addEventListener('click', () => this.handleKeyPress(key));
}
});
}
_haptic() {
if (navigator.vibrate) navigator.vibrate(8);
}
_clearRepeat() {
clearTimeout(this._repeatTimer);
clearInterval(this._repeatInterval);
this._repeatTimer = null;
this._repeatInterval = null;
}
submitText() {
this._haptic();
const text = this.textInput.value;
if (text) {
this.onSendText(text + '\n');
this.textInput.value = '';
} else {
// Empty submit = Enter key
this.onSendKey('Enter');
}
this.textInput.focus();
}
handleKeyPress(key) {
this._haptic();
// Check for shortcut keys like Ctrl-c
if (key.startsWith('Ctrl-')) {
this.onSendKey(key);
return;
}
// Apply active modifiers
let finalKey = key;
if (this.ctrlActive) {
finalKey = `Ctrl-${key}`;
if (!this.ctrlLocked) this.deactivateModifier('ctrl');
}
if (this.altActive) {
finalKey = `Alt-${key}`;
if (!this.altLocked) this.deactivateModifier('alt');
}
this.onSendKey(finalKey);
}
toggleModifier(mod) {
this._haptic();
if (mod === 'ctrl') {
if (this.ctrlLocked) {
this.deactivateModifier('ctrl');
return;
}
this.ctrlActive = !this.ctrlActive;
this.ctrlKey.classList.toggle('active', this.ctrlActive);
} else if (mod === 'alt') {
if (this.altLocked) {
this.deactivateModifier('alt');
return;
}
this.altActive = !this.altActive;
this.altKey.classList.toggle('active', this.altActive);
}
}
lockModifier(mod) {
if (mod === 'ctrl') {
this.ctrlActive = true;
this.ctrlLocked = true;
this.ctrlKey.classList.add('active', 'locked');
} else if (mod === 'alt') {
this.altActive = true;
this.altLocked = true;
this.altKey.classList.add('active', 'locked');
}
}
deactivateModifier(mod) {
if (mod === 'ctrl') {
this.ctrlActive = false;
this.ctrlLocked = false;
this.ctrlKey.classList.remove('active', 'locked');
} else if (mod === 'alt') {
this.altActive = false;
this.altLocked = false;
this.altKey.classList.remove('active', 'locked');
}
}
focus() {
this.textInput.focus();
}
}
window.VirtualKeyboard = VirtualKeyboard;

@ -0,0 +1,133 @@
class WebSocketClient {
constructor() {
this.ws = null;
this.handlers = new Map();
this.reconnectDelay = 1000;
this.maxReconnectDelay = 30000;
this.currentDelay = this.reconnectDelay;
this.shouldReconnect = true;
this.connected = false;
this.reconnectAttempt = 0;
}
connect(authRequired) {
this.authRequired = authRequired !== false;
const token = localStorage.getItem('cmux-remote-token');
if (this.authRequired && !token) {
window.location.href = '/login.html';
return;
}
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}`;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.connected = true;
this.reconnectAttempt = 0;
this.currentDelay = this.reconnectDelay;
this.emit('connected');
// Authenticate (send token or empty for no-auth mode)
this.send({ type: 'auth', token: token || '' });
};
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
this.emit(msg.type, msg);
// Redirect to login on auth failure (only if auth is required)
if (msg.type === 'auth-fail' && this.authRequired) {
localStorage.removeItem('cmux-remote-token');
window.location.href = '/login.html';
}
} catch (err) {
console.error('[ws] Parse error:', err);
}
};
this.ws.onclose = () => {
this.connected = false;
this.emit('disconnected');
if (this.shouldReconnect) {
const attempt = ++this.reconnectAttempt;
const delay = this.currentDelay;
this.emit('reconnecting', { attempt, delay });
setTimeout(() => this.connect(this.authRequired), delay);
this.currentDelay = Math.min(this.currentDelay * 1.5, this.maxReconnectDelay);
}
};
this.ws.onerror = (err) => {
console.error('[ws] Error:', err);
};
}
send(msg) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
on(type, handler) {
if (!this.handlers.has(type)) {
this.handlers.set(type, []);
}
this.handlers.get(type).push(handler);
}
off(type, handler) {
const list = this.handlers.get(type);
if (list) {
const idx = list.indexOf(handler);
if (idx >= 0) list.splice(idx, 1);
}
}
emit(type, data) {
const list = this.handlers.get(type);
if (list) {
for (const handler of list) {
handler(data);
}
}
}
subscribe(workspace, surface) {
this.send({ type: 'subscribe', workspace, surface });
}
unsubscribe(surface) {
this.send({ type: 'unsubscribe', surface });
}
sendText(workspace, surface, text) {
this.send({ type: 'send-text', workspace, surface, text });
}
sendKey(workspace, surface, key) {
this.send({ type: 'send-key', workspace, surface, key });
}
listWorkspaces() {
this.send({ type: 'list-workspaces' });
}
requestScroll(workspace, surface, lines) {
this.send({ type: 'scroll-request', workspace, surface, lines });
}
disconnect() {
this.shouldReconnect = false;
if (this.ws) this.ws.close();
}
isConnected() {
return this.connected;
}
}
window.WebSocketClient = WebSocketClient;

@ -0,0 +1,276 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>cmux-remote</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0b0b13">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/css/themes.css">
<style>
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Inter', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
/* Animated background */
.bg {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
}
.bg-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.35;
}
.bg-orb-1 {
width: 500px; height: 500px;
background: radial-gradient(circle, var(--accent) 0%, transparent 70%);
top: -20%; left: -10%;
animation: orb-drift 18s ease-in-out infinite alternate;
}
.bg-orb-2 {
width: 400px; height: 400px;
background: radial-gradient(circle, #a78bfa 0%, transparent 70%);
bottom: -15%; right: -5%;
animation: orb-drift 22s ease-in-out infinite alternate-reverse;
}
.bg-orb-3 {
width: 300px; height: 300px;
background: radial-gradient(circle, #22d3ee 0%, transparent 70%);
top: 40%; left: 50%;
transform: translate(-50%, -50%);
animation: orb-drift 15s ease-in-out infinite alternate;
opacity: 0.15;
}
@keyframes orb-drift {
from { transform: translate(0, 0) scale(1); }
to { transform: translate(40px, 30px) scale(1.08); }
}
/* Grid overlay */
.bg::after {
content: '';
position: absolute;
inset: 0;
background-image:
linear-gradient(var(--border-subtle) 1px, transparent 1px),
linear-gradient(90deg, var(--border-subtle) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(ellipse 80% 80% at 50% 50%, black 30%, transparent 100%);
-webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% 50%, black 30%, transparent 100%);
}
/* Card */
.login-card {
position: relative;
z-index: 1;
width: 92%;
max-width: 360px;
padding: 2.25rem 2rem;
background: var(--bg-glass);
backdrop-filter: blur(32px);
-webkit-backdrop-filter: blur(32px);
border-radius: var(--radius-lg);
border: 1px solid var(--border-strong);
box-shadow: var(--shadow-lg), 0 0 0 1px var(--border-subtle) inset;
animation: card-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* Shine edge on card */
.login-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(135deg, rgba(255,255,255,0.06) 0%, transparent 50%);
pointer-events: none;
}
@keyframes card-in {
from { opacity: 0; transform: translateY(20px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* Logo */
.logo {
width: 44px;
height: 44px;
border-radius: var(--radius-md);
background: var(--accent-gradient);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.4rem;
box-shadow: 0 4px 20px var(--accent-glow);
position: relative;
}
.logo::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(135deg, rgba(255,255,255,0.2) 0%, transparent 60%);
}
.logo svg {
width: 22px;
height: 22px;
color: #fff;
position: relative;
z-index: 1;
}
h1 {
margin: 0 0 0.3rem;
font-size: 1.4rem;
font-weight: 700;
letter-spacing: -0.025em;
background: linear-gradient(135deg, var(--text-primary) 0%, var(--text-secondary) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
color: var(--text-secondary);
margin: 0 0 1.8rem;
font-size: 0.85rem;
line-height: 1.55;
}
label {
display: block;
font-size: 0.72rem;
font-weight: 700;
color: var(--text-dimmed);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
input[type="password"] {
width: 100%;
padding: 0.8rem 1rem;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: rgba(0,0,0,0.2);
color: var(--text-primary);
font-size: 0.95rem;
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
font-family: inherit;
}
[data-theme="light"] input[type="password"] {
background: rgba(255,255,255,0.7);
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) input[type="password"] {
background: rgba(255,255,255,0.7);
}
}
input[type="password"]:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
input[type="password"]::placeholder {
color: var(--text-dimmed);
}
button[type="submit"] {
width: 100%;
padding: 0.82rem;
margin-top: 1.1rem;
border: none;
border-radius: var(--radius-sm);
background: var(--accent-gradient);
color: #fff;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: opacity 120ms ease, transform 120ms ease, box-shadow 120ms ease;
box-shadow: 0 2px 10px var(--accent-glow);
letter-spacing: 0.01em;
position: relative;
overflow: hidden;
}
button[type="submit"]::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255,255,255,0.1) 0%, transparent 60%);
}
button[type="submit"]:hover {
box-shadow: 0 4px 20px var(--accent-glow);
transform: translateY(-1px);
}
button[type="submit"]:active {
transform: scale(0.98);
box-shadow: 0 1px 6px var(--accent-glow);
}
.error {
color: var(--red);
font-size: 0.82rem;
margin-top: 0.9rem;
display: none;
padding: 0.55rem 0.85rem;
background: var(--red-glow);
border-radius: var(--radius-sm);
border: 1px solid rgba(248, 113, 113, 0.12);
}
.error.visible { display: block; }
button[type="submit"]:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
@media (prefers-reduced-motion: reduce) {
.bg-orb, .login-card { animation: none !important; }
}
</style>
</head>
<body>
<div class="bg">
<div class="bg-orb bg-orb-1"></div>
<div class="bg-orb bg-orb-2"></div>
<div class="bg-orb bg-orb-3"></div>
</div>
<div class="login-card">
<div class="logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
</div>
<h1>cmux-remote</h1>
<p class="subtitle">Connect to your terminal session</p>
<form id="login-form">
<label for="password">Password</label>
<input type="password" id="password" placeholder="••••••••" autocomplete="current-password" autofocus>
<button type="submit">Connect</button>
<p class="error" id="error"></p>
</form>
</div>
<script src="/js/auth.js"></script>
</body>
</html>

@ -0,0 +1,17 @@
{
"name": "cmux-remote",
"short_name": "cmux",
"description": "Remote terminal control for cmux",
"start_url": "/",
"display": "standalone",
"orientation": "any",
"theme_color": "#0c0c14",
"background_color": "#0c0c14",
"icons": [
{
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%236366f1'/><path d='M30 65 L45 35 L30 20' stroke='white' stroke-width='8' stroke-linecap='round' stroke-linejoin='round' fill='none'/><line x1='50' y1='70' x2='75' y2='70' stroke='white' stroke-width='8' stroke-linecap='round'/></svg>",
"sizes": "any",
"type": "image/svg+xml"
}
]
}

@ -0,0 +1,53 @@
const CACHE_NAME = 'cmux-remote-v3';
const STATIC_ASSETS = [
'/',
'/index.html',
'/login.html',
'/css/main.css',
'/css/terminal.css',
'/css/keyboard.css',
'/css/sidebar.css',
'/css/themes.css',
'/js/app.js',
'/js/auth.js',
'/js/terminal-view.js',
'/js/websocket-client.js',
'/js/virtual-keyboard.js',
'/js/sidebar.js',
'/js/gestures.js',
'/js/theme.js',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
// Skip API and WebSocket requests
const url = new URL(event.request.url);
if (url.pathname.startsWith('/api/') || event.request.headers.get('upgrade') === 'websocket') {
return;
}
event.respondWith(
fetch(event.request)
.then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
})
.catch(() => caches.match(event.request))
);
});

@ -0,0 +1,207 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import type { WorkspaceInfo, PaneInfo, SurfaceInfo } from '../protocol/messages.js';
const execFileAsync = promisify(execFile);
const MAX_CONCURRENT = 5;
const CACHE_TTL_MS = 2000;
interface CacheEntry<T> {
data: T;
timestamp: number;
}
export class CmuxClient {
private cmuxPath: string;
private running = 0;
private queue: Array<() => void> = [];
private cache = new Map<string, CacheEntry<unknown>>();
private shuttingDown = false;
constructor(cmuxPath = 'cmux') {
this.cmuxPath = cmuxPath;
}
shutdown(): void {
this.shuttingDown = true;
}
private async throttle(): Promise<void> {
if (this.running < MAX_CONCURRENT) {
this.running++;
return;
}
return new Promise((resolve) => {
this.queue.push(() => {
this.running++;
resolve();
});
});
}
private release(): void {
this.running--;
const next = this.queue.shift();
if (next) next();
}
private getCached<T>(key: string): T | null {
const entry = this.cache.get(key);
if (entry && Date.now() - entry.timestamp < CACHE_TTL_MS) {
return entry.data as T;
}
this.cache.delete(key);
return null;
}
private setCache<T>(key: string, data: T): void {
this.cache.set(key, { data, timestamp: Date.now() });
}
async exec(args: string[]): Promise<string> {
if (this.shuttingDown) return '';
await this.throttle();
try {
const { stdout } = await execFileAsync(this.cmuxPath, args, {
timeout: 10000,
maxBuffer: 1024 * 1024,
});
return stdout;
} catch (err) {
if (this.shuttingDown) return '';
throw err;
} finally {
this.release();
}
}
async listWorkspaces(): Promise<WorkspaceInfo[]> {
const cached = this.getCached<WorkspaceInfo[]>('workspaces');
if (cached) return cached;
const output = await this.exec(['tree', '--all']);
const workspaces = this.parseTree(output);
this.setCache('workspaces', workspaces);
return workspaces;
}
parseTree(output: string): WorkspaceInfo[] {
const workspaces: WorkspaceInfo[] = [];
let currentWs: WorkspaceInfo | null = null;
let currentPane: PaneInfo | null = null;
for (const line of output.split('\n')) {
// Strip tree-drawing characters and leading whitespace
const stripped = line.replace(/^[\s│├└─┬┤┼┌┐┘┴]+/g, '').trim();
if (!stripped) continue;
// Match workspace lines: "workspace workspace:1 "name" [flags]"
const wsMatch = stripped.match(/^workspace\s+(workspace:\d+)\s*(.*)/);
if (wsMatch) {
// Extract quoted name if present
const nameMatch = wsMatch[2].match(/"([^"]+)"/);
currentWs = {
id: wsMatch[1],
ref: wsMatch[1],
name: nameMatch?.[1] || undefined,
panes: [],
};
workspaces.push(currentWs);
currentPane = null;
continue;
}
// Match pane lines: "pane pane:2 [flags]"
const paneMatch = stripped.match(/^pane\s+(pane:\d+)/);
if (paneMatch && currentWs) {
currentPane = {
id: paneMatch[1],
ref: paneMatch[1],
surfaces: [],
};
currentWs.panes.push(currentPane);
continue;
}
// Match surface lines: "surface surface:2 [terminal] "title" [flags]"
const surfaceMatch = stripped.match(/^surface\s+(surface:\d+)\s*(?:\[(\w+)\])?\s*(.*)/);
if (surfaceMatch && currentPane) {
const titleMatch = surfaceMatch[3].match(/"([^"]+)"/);
currentPane.surfaces.push({
id: surfaceMatch[1],
ref: surfaceMatch[1],
type: surfaceMatch[2] || 'terminal',
title: titleMatch?.[1] || undefined,
});
}
}
return workspaces;
}
async listPaneSurfaces(workspace: string, pane?: string): Promise<SurfaceInfo[]> {
const cacheKey = `surfaces:${workspace}:${pane || 'all'}`;
const cached = this.getCached<SurfaceInfo[]>(cacheKey);
if (cached) return cached;
const args = ['list-pane-surfaces', '--workspace', workspace];
if (pane) args.push('--pane', pane);
const output = await this.exec(args);
const surfaces = this.parseSurfaces(output);
this.setCache(cacheKey, surfaces);
return surfaces;
}
parseSurfaces(output: string): SurfaceInfo[] {
const surfaces: SurfaceInfo[] = [];
for (const line of output.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^(surface:\d+)\s*(?:\[(\w+)\])?\s*(.*)/);
if (match) {
surfaces.push({
id: match[1],
ref: match[1],
type: match[2] || 'terminal',
title: match[3]?.trim() || undefined,
});
}
}
return surfaces;
}
async readScreen(
workspace: string,
surface: string,
opts?: { scrollback?: boolean; lines?: number }
): Promise<string> {
const args = ['read-screen', '--workspace', workspace, '--surface', surface];
if (opts?.scrollback) args.push('--scrollback');
if (opts?.lines) args.push('--lines', String(opts.lines));
return this.exec(args);
}
async sendText(workspace: string, surface: string, text: string): Promise<void> {
await this.exec(['send', '--workspace', workspace, '--surface', surface, text]);
}
async sendKey(workspace: string, surface: string, key: string): Promise<void> {
await this.exec(['send-key', '--workspace', workspace, '--surface', surface, key]);
}
async capturePaneScrollback(
workspace: string,
surface: string,
lines?: number
): Promise<string> {
const args = ['capture-pane', '--workspace', workspace, '--surface', surface, '--scrollback'];
if (lines) args.push('--lines', String(lines));
return this.exec(args);
}
clearCache(): void {
this.cache.clear();
}
}

@ -0,0 +1,53 @@
import type { CmuxClient } from './cmux-client.js';
// Map browser key names to cmux send-key names
const KEY_MAP: Record<string, string> = {
Enter: 'enter',
Escape: 'escape',
Tab: 'tab',
Backspace: 'backspace',
Delete: 'delete',
ArrowUp: 'arrow_up',
ArrowDown: 'arrow_down',
ArrowLeft: 'arrow_left',
ArrowRight: 'arrow_right',
Home: 'home',
End: 'end',
PageUp: 'page_up',
PageDown: 'page_down',
Space: 'space',
};
export class InputHandler {
private client: CmuxClient;
constructor(client: CmuxClient) {
this.client = client;
}
async handleText(workspace: string, surface: string, text: string): Promise<void> {
await this.client.sendText(workspace, surface, text);
}
async handleKey(workspace: string, surface: string, key: string): Promise<void> {
// Handle Ctrl+key combos (from frontend: "Ctrl-c", "Ctrl-d", etc.)
const ctrlMatch = key.match(/^(?:Ctrl|Control)[+-](.+)$/i);
if (ctrlMatch) {
const letter = ctrlMatch[1].toLowerCase();
await this.client.sendKey(workspace, surface, `ctrl+${letter}`);
return;
}
// Handle Alt+key combos
const altMatch = key.match(/^Alt[+-](.+)$/i);
if (altMatch) {
const letter = altMatch[1].toLowerCase();
await this.client.sendKey(workspace, surface, `alt+${letter}`);
return;
}
// Map known keys
const mapped = KEY_MAP[key] || key;
await this.client.sendKey(workspace, surface, mapped);
}
}

@ -0,0 +1,166 @@
import { createHash } from 'node:crypto';
import { EventEmitter } from 'node:events';
import type { CmuxClient } from './cmux-client.js';
import { computeDiff } from '../utils/text-differ.js';
import type { DiffPatch } from '../protocol/messages.js';
interface PollTarget {
workspace: string;
surface: string;
subscribers: Set<string>;
lastHash: string;
lastLines: string[];
idleCount: number;
timer: ReturnType<typeof setTimeout> | null;
}
interface ScreenUpdate {
surface: string;
workspace: string;
full: boolean;
lines: string[];
patches?: DiffPatch[];
}
export class ScreenPoller extends EventEmitter {
private client: CmuxClient;
private targets = new Map<string, PollTarget>();
private basePollRate: number;
private fullSyncInterval = 30000;
private lastFullSync = new Map<string, number>();
static readonly IDLE_THRESHOLD = 3;
static readonly DEEP_IDLE_MS = 10000;
constructor(client: CmuxClient, pollRate = 200) {
super();
this.client = client;
this.basePollRate = pollRate;
}
subscribe(clientId: string, workspace: string, surface: string): void {
const key = `${workspace}:${surface}`;
let target = this.targets.get(key);
if (!target) {
target = {
workspace,
surface,
subscribers: new Set(),
lastHash: '',
lastLines: [],
idleCount: 0,
timer: null,
};
this.targets.set(key, target);
}
target.subscribers.add(clientId);
if (!target.timer) {
this.schedulePoll(key, target, 0);
}
}
unsubscribe(clientId: string, surface?: string): void {
for (const [key, target] of this.targets) {
if (surface && !key.endsWith(`:${surface}`)) continue;
target.subscribers.delete(clientId);
if (target.subscribers.size === 0) {
if (target.timer) clearTimeout(target.timer);
target.timer = null;
this.targets.delete(key);
this.lastFullSync.delete(key);
}
}
}
resetToFast(surface: string): void {
for (const [key, target] of this.targets) {
if (key.endsWith(`:${surface}`)) {
target.idleCount = 0;
// Reschedule with fast rate
if (target.timer) clearTimeout(target.timer);
this.schedulePoll(key, target, this.basePollRate);
}
}
}
private getInterval(target: PollTarget): number {
if (target.idleCount >= ScreenPoller.IDLE_THRESHOLD) {
const idleTimeMs = target.idleCount * this.basePollRate;
if (idleTimeMs > ScreenPoller.DEEP_IDLE_MS) return 2000;
return 1000;
}
return this.basePollRate;
}
private schedulePoll(key: string, target: PollTarget, delay: number): void {
target.timer = setTimeout(() => this.poll(key, target), delay);
}
private async poll(key: string, target: PollTarget): Promise<void> {
if (target.subscribers.size === 0) return;
try {
const content = await this.client.readScreen(target.workspace, target.surface);
const hash = createHash('sha256').update(content).digest('hex');
const now = Date.now();
const needsFullSync =
!this.lastFullSync.has(key) ||
now - this.lastFullSync.get(key)! > this.fullSyncInterval;
if (hash !== target.lastHash) {
target.idleCount = 0;
const newLines = content.split('\n');
const update: ScreenUpdate = {
surface: target.surface,
workspace: target.workspace,
full: target.lastLines.length === 0 || needsFullSync,
lines: newLines,
};
if (!update.full && target.lastLines.length > 0) {
update.patches = computeDiff(target.lastLines, newLines);
}
if (needsFullSync) {
this.lastFullSync.set(key, now);
}
target.lastHash = hash;
target.lastLines = newLines;
this.emit('update', update);
} else {
target.idleCount++;
}
} catch (err) {
this.emit('error', { surface: target.surface, error: err });
}
// Schedule next poll
if (target.subscribers.size > 0) {
this.schedulePoll(key, target, this.getInterval(target));
}
}
getSubscriberCount(surface: string): number {
for (const [key, target] of this.targets) {
if (key.endsWith(`:${surface}`)) return target.subscribers.size;
}
return 0;
}
destroy(): void {
for (const target of this.targets.values()) {
if (target.timer) clearTimeout(target.timer);
}
this.targets.clear();
this.removeAllListeners();
}
}

@ -0,0 +1,88 @@
import type { ScreenPoller } from './screen-poller.js';
interface ClientSession {
id: string;
authenticated: boolean;
subscriptions: Set<string>; // "workspace:surface" keys
}
export class SessionManager {
private sessions = new Map<string, ClientSession>();
private poller: ScreenPoller;
constructor(poller: ScreenPoller) {
this.poller = poller;
}
addClient(clientId: string): void {
this.sessions.set(clientId, {
id: clientId,
authenticated: false,
subscriptions: new Set(),
});
}
authenticateClient(clientId: string): void {
const session = this.sessions.get(clientId);
if (session) session.authenticated = true;
}
isAuthenticated(clientId: string): boolean {
return this.sessions.get(clientId)?.authenticated ?? false;
}
subscribe(clientId: string, workspace: string, surface: string): void {
const session = this.sessions.get(clientId);
if (!session) return;
const key = `${workspace}:${surface}`;
session.subscriptions.add(key);
this.poller.subscribe(clientId, workspace, surface);
}
unsubscribe(clientId: string, surface: string): void {
const session = this.sessions.get(clientId);
if (!session) return;
for (const key of session.subscriptions) {
if (key.endsWith(`:${surface}`)) {
session.subscriptions.delete(key);
}
}
this.poller.unsubscribe(clientId, surface);
}
removeClient(clientId: string): void {
const session = this.sessions.get(clientId);
if (!session) return;
// Unsubscribe from all surfaces
this.poller.unsubscribe(clientId);
this.sessions.delete(clientId);
}
getSubscribedClients(surface: string): string[] {
const clients: string[] = [];
for (const [id, session] of this.sessions) {
for (const key of session.subscriptions) {
if (key.endsWith(`:${surface}`)) {
clients.push(id);
break;
}
}
}
return clients;
}
getClientCount(): number {
return this.sessions.size;
}
getAuthenticatedCount(): number {
let count = 0;
for (const session of this.sessions.values()) {
if (session.authenticated) count++;
}
return count;
}
}

@ -0,0 +1,123 @@
import { createServer } from 'node:http';
import { Command } from 'commander';
import qrcode from 'qrcode-terminal';
import { createApp } from './server/app.js';
import { AuthManager, generatePassphrase } from './server/auth.js';
import { WsServer } from './server/websocket.js';
import { CloudflareTunnel } from './server/tunnel.js';
import { CmuxClient } from './bridge/cmux-client.js';
import { ScreenPoller } from './bridge/screen-poller.js';
import { SessionManager } from './bridge/session-manager.js';
import { InputHandler } from './bridge/input-handler.js';
import type { Config } from './utils/config.js';
import { DEFAULT_CONFIG } from './utils/config.js';
const program = new Command();
program
.name('cmux-remote')
.description('Browser-based remote terminal control for cmux')
.version('0.1.0')
.option('-p, --port <port>', 'Local port', String(DEFAULT_CONFIG.port))
.option('-P, --password <pass>', 'Access password (or CMUX_REMOTE_PASSWORD env)')
.option('--no-tunnel', 'Disable Cloudflare Tunnel')
.option('--poll-rate <ms>', 'Poll interval in ms', String(DEFAULT_CONFIG.pollRate))
.option('--theme <theme>', 'Default theme (dark|light)', DEFAULT_CONFIG.theme)
.option('-v, --verbose', 'Verbose logging', false)
.action(async (opts) => {
const config: Config = {
port: parseInt(opts.port, 10),
password: opts.password || process.env.CMUX_REMOTE_PASSWORD || null,
tunnel: opts.tunnel !== false,
pollRate: parseInt(opts.pollRate, 10),
theme: opts.theme as 'dark' | 'light',
verbose: opts.verbose,
cmuxPath: process.env.CMUX_PATH || 'cmux',
};
// Initialize components
const auth = new AuthManager();
if (config.password) {
await auth.setPassword(config.password);
} else {
auth.disableAuth();
}
const cmux = new CmuxClient(config.cmuxPath);
const poller = new ScreenPoller(cmux, config.pollRate);
const sessions = new SessionManager(poller);
const input = new InputHandler(cmux);
// Create HTTP server; qrUrlRef is updated after tunnel starts
const qrUrlRef = { url: `http://localhost:${config.port}` };
const app = createApp(auth, qrUrlRef);
const server = createServer(app);
// WebSocket server
const wsServer = new WsServer({
server,
auth,
sessions,
poller,
input,
cmux,
verbose: config.verbose,
});
// Start listening
server.listen(config.port, () => {
console.log('');
console.log(' cmux-remote v0.1.0');
console.log(' ==================');
console.log('');
console.log(` Local: http://localhost:${config.port}`);
if (config.password) {
console.log(` Password: ${config.password}`);
} else {
console.log(` Auth: disabled (use -P to set password)`);
}
console.log('');
});
// Start tunnel
let tunnel: CloudflareTunnel | null = null;
if (config.tunnel) {
tunnel = new CloudflareTunnel();
try {
const tunnelUrl = await tunnel.start(config.port);
qrUrlRef.url = tunnelUrl;
console.log(` Tunnel: ${tunnelUrl}`);
} catch (err) {
console.warn(` Tunnel: Failed - ${(err as Error).message}`);
}
}
// Print QR code to terminal
console.log('');
qrcode.generate(qrUrlRef.url, { small: true }, (code: string) => {
for (const line of code.split('\n')) {
console.log(` ${line}`);
}
console.log('');
});
// Graceful shutdown
let shuttingDown = false;
const shutdown = () => {
if (shuttingDown) return;
shuttingDown = true;
console.log('\nShutting down...');
cmux.shutdown();
poller.destroy();
wsServer.close();
tunnel?.stop();
server.close(() => process.exit(0));
// Force exit after 3s if connections don't drain
setTimeout(() => process.exit(0), 3000).unref();
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
});
program.parse();

@ -0,0 +1,119 @@
// Server -> Client messages
export interface ScreenMessage {
type: 'screen';
surface: string;
content: string;
lines: string[];
cursor?: { row: number; col: number };
}
export interface ScreenDiffMessage {
type: 'screen-diff';
surface: string;
patches: DiffPatch[];
}
export interface DiffPatch {
startLine: number;
deleteCount: number;
insertLines: string[];
}
export interface WorkspacesMessage {
type: 'workspaces';
workspaces: WorkspaceInfo[];
}
export interface WorkspaceInfo {
id: string;
ref: string;
name?: string;
panes: PaneInfo[];
}
export interface PaneInfo {
id: string;
ref: string;
surfaces: SurfaceInfo[];
}
export interface SurfaceInfo {
id: string;
ref: string;
type: string;
title?: string;
}
export interface AuthResultMessage {
type: 'auth-ok' | 'auth-fail';
message?: string;
}
export interface ErrorMessage {
type: 'error';
message: string;
}
export type ServerMessage =
| ScreenMessage
| ScreenDiffMessage
| WorkspacesMessage
| AuthResultMessage
| ErrorMessage;
// Client -> Server messages
export interface AuthMessage {
type: 'auth';
token: string;
}
export interface SubscribeMessage {
type: 'subscribe';
workspace: string;
surface: string;
}
export interface UnsubscribeMessage {
type: 'unsubscribe';
surface: string;
}
export interface SendTextMessage {
type: 'send-text';
workspace: string;
surface: string;
text: string;
}
export interface SendKeyMessage {
type: 'send-key';
workspace: string;
surface: string;
key: string;
}
export interface ListWorkspacesMessage {
type: 'list-workspaces';
}
export interface ListSurfacesMessage {
type: 'list-surfaces';
workspace: string;
}
export interface ScrollRequestMessage {
type: 'scroll-request';
workspace: string;
surface: string;
lines: number;
}
export type ClientMessage =
| AuthMessage
| SubscribeMessage
| UnsubscribeMessage
| SendTextMessage
| SendKeyMessage
| ListWorkspacesMessage
| ListSurfacesMessage
| ScrollRequestMessage;

@ -0,0 +1,73 @@
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import QRCode from 'qrcode';
import type { AuthManager } from './auth.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export function createApp(auth: AuthManager, qrUrlRef: { url: string }): express.Express {
const app = express();
// Security headers
app.use(auth.cspMiddleware());
// Parse JSON bodies
app.use(express.json());
// QR code endpoint — generates SVG for the current access URL
app.get('/api/qr.svg', async (_req, res) => {
try {
const svg = await QRCode.toString(qrUrlRef.url, { type: 'svg' });
res.setHeader('Content-Type', 'image/svg+xml');
res.send(svg);
} catch {
res.status(500).send('QR generation failed');
}
});
// Access URL endpoint — returns the current access URL (tunnel or local)
app.get('/api/access-url', (_req, res) => {
res.json({ url: qrUrlRef.url });
});
// Auth status endpoint
app.get('/api/auth/status', (_req, res) => {
res.json({ authRequired: !auth.isAuthDisabled() });
});
// Auth endpoint
app.post('/api/auth/login', auth.loginHandler());
// Token verification endpoint
app.get('/api/auth/verify', (req, res) => {
if (auth.isAuthDisabled()) {
res.json({ valid: true });
return;
}
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ valid: false });
return;
}
const token = authHeader.slice(7);
const valid = auth.verifyToken(token);
res.json({ valid });
});
// Static files — no-cache so changes are always picked up
const publicDir = join(__dirname, '..', '..', 'public');
app.use(express.static(publicDir, {
setHeaders: (res) => {
res.setHeader('Cache-Control', 'no-store');
},
}));
// SPA fallback — serve index.html for non-API, non-file routes
app.get('/{*splat}', (_req, res) => {
res.sendFile(join(publicDir, 'index.html'));
});
return app;
}

@ -0,0 +1,140 @@
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { randomBytes } from 'node:crypto';
import type { Request, Response, NextFunction } from 'express';
const SALT_ROUNDS = 10;
const TOKEN_EXPIRY = '24h';
const MAX_ATTEMPTS = 5;
const RATE_WINDOW_MS = 60000;
interface AttemptRecord {
count: number;
firstAttempt: number;
}
export class AuthManager {
private passwordHash: string | null = null;
private jwtSecret: string;
private attempts = new Map<string, AttemptRecord>();
private authDisabled = false;
constructor() {
this.jwtSecret = randomBytes(32).toString('hex');
}
disableAuth(): void {
this.authDisabled = true;
}
isAuthDisabled(): boolean {
return this.authDisabled;
}
async setPassword(password: string): Promise<void> {
this.passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
}
async verifyPassword(password: string): Promise<boolean> {
if (!this.passwordHash) return false;
return bcrypt.compare(password, this.passwordHash);
}
generateToken(): string {
return jwt.sign({ ts: Date.now() }, this.jwtSecret, { expiresIn: TOKEN_EXPIRY });
}
verifyToken(token: string): boolean {
if (this.authDisabled) return true;
try {
jwt.verify(token, this.jwtSecret);
return true;
} catch {
return false;
}
}
isRateLimited(ip: string): boolean {
const record = this.attempts.get(ip);
if (!record) return false;
// Reset if window expired
if (Date.now() - record.firstAttempt > RATE_WINDOW_MS) {
this.attempts.delete(ip);
return false;
}
return record.count >= MAX_ATTEMPTS;
}
recordAttempt(ip: string): void {
const record = this.attempts.get(ip);
if (!record || Date.now() - record.firstAttempt > RATE_WINDOW_MS) {
this.attempts.set(ip, { count: 1, firstAttempt: Date.now() });
} else {
record.count++;
}
}
resetAttempts(ip: string): void {
this.attempts.delete(ip);
}
loginHandler() {
return async (req: Request, res: Response): Promise<void> => {
const ip = req.ip || req.socket.remoteAddress || 'unknown';
if (this.isRateLimited(ip)) {
res.status(429).json({ error: 'Too many attempts. Try again later.' });
return;
}
const { password } = req.body as { password?: string };
if (!password) {
res.status(400).json({ error: 'Password required' });
return;
}
this.recordAttempt(ip);
const valid = await this.verifyPassword(password);
if (!valid) {
console.log(`[auth] Failed login from ${ip}`);
res.status(401).json({ error: 'Invalid password' });
return;
}
this.resetAttempts(ip);
const token = this.generateToken();
console.log(`[auth] Successful login from ${ip}`);
res.json({ token });
};
}
cspMiddleware() {
return (_req: Request, res: Response, next: NextFunction): void => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; img-src 'self' data:;"
);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Referrer-Policy', 'no-referrer');
next();
};
}
}
export function generatePassphrase(): string {
const words = [
'alpha', 'bravo', 'cedar', 'delta', 'ember', 'frost', 'grape', 'hatch',
'ivory', 'junco', 'knack', 'lunar', 'maple', 'north', 'orbit', 'pearl',
'quilt', 'ridge', 'solar', 'thorn', 'ultra', 'vivid', 'watch', 'xenon',
];
const picked: string[] = [];
const bytes = randomBytes(4);
for (let i = 0; i < 4; i++) {
picked.push(words[bytes[i] % words.length]);
}
return picked.join('-');
}

@ -0,0 +1,65 @@
import { spawn, type ChildProcess } from 'node:child_process';
export class CloudflareTunnel {
private process: ChildProcess | null = null;
private url: string | null = null;
async start(port: number): Promise<string> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Tunnel startup timed out after 30s'));
}, 30000);
this.process = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
stdio: ['ignore', 'pipe', 'pipe'],
});
const handleOutput = (data: Buffer) => {
const text = data.toString();
// cloudflared prints the URL to stderr
const urlMatch = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
if (urlMatch && !this.url) {
this.url = urlMatch[0];
clearTimeout(timeout);
resolve(this.url);
}
};
this.process.stdout?.on('data', handleOutput);
this.process.stderr?.on('data', handleOutput);
this.process.on('error', (err) => {
clearTimeout(timeout);
if (err.message.includes('ENOENT')) {
reject(
new Error(
'cloudflared not found. Install with: brew install cloudflared\n' +
'Or disable tunnel with: --no-tunnel'
)
);
} else {
reject(err);
}
});
this.process.on('exit', (code) => {
if (!this.url) {
clearTimeout(timeout);
reject(new Error(`cloudflared exited with code ${code}`));
}
});
});
}
getUrl(): string | null {
return this.url;
}
stop(): void {
if (this.process) {
this.process.kill('SIGTERM');
this.process = null;
this.url = null;
}
}
}

@ -0,0 +1,197 @@
import { WebSocketServer, WebSocket } from 'ws';
import type { Server } from 'node:http';
import { nanoid } from 'nanoid';
import type { AuthManager } from './auth.js';
import type { SessionManager } from '../bridge/session-manager.js';
import type { ScreenPoller } from '../bridge/screen-poller.js';
import type { InputHandler } from '../bridge/input-handler.js';
import type { CmuxClient } from '../bridge/cmux-client.js';
import type { ClientMessage, ServerMessage } from '../protocol/messages.js';
interface ClientInfo {
id: string;
ws: WebSocket;
}
export class WsServer {
private wss: WebSocketServer;
private clients = new Map<string, ClientInfo>();
private auth: AuthManager;
private sessions: SessionManager;
private poller: ScreenPoller;
private input: InputHandler;
private cmux: CmuxClient;
private verbose: boolean;
constructor(opts: {
server: Server;
auth: AuthManager;
sessions: SessionManager;
poller: ScreenPoller;
input: InputHandler;
cmux: CmuxClient;
verbose?: boolean;
}) {
this.auth = opts.auth;
this.sessions = opts.sessions;
this.poller = opts.poller;
this.input = opts.input;
this.cmux = opts.cmux;
this.verbose = opts.verbose ?? false;
this.wss = new WebSocketServer({
server: opts.server,
perMessageDeflate: true,
});
this.wss.on('connection', (ws) => this.handleConnection(ws));
// Listen for screen updates from poller
this.poller.on('update', (update) => this.broadcastUpdate(update));
}
private handleConnection(ws: WebSocket): void {
const clientId = nanoid(12);
this.clients.set(clientId, { id: clientId, ws });
this.sessions.addClient(clientId);
if (this.verbose) console.log(`[ws] Client connected: ${clientId}`);
ws.on('message', (data) => {
try {
const msg = JSON.parse(data.toString()) as ClientMessage;
this.handleMessage(clientId, msg);
} catch (err) {
this.sendTo(clientId, { type: 'error', message: 'Invalid message format' });
}
});
ws.on('close', () => {
if (this.verbose) console.log(`[ws] Client disconnected: ${clientId}`);
this.sessions.removeClient(clientId);
this.clients.delete(clientId);
});
ws.on('error', (err) => {
console.error(`[ws] Client error ${clientId}:`, err.message);
});
}
private async handleMessage(clientId: string, msg: ClientMessage): Promise<void> {
// Auth messages don't require prior authentication
if (msg.type === 'auth') {
const valid = this.auth.verifyToken(msg.token);
if (valid) {
this.sessions.authenticateClient(clientId);
this.sendTo(clientId, { type: 'auth-ok' });
} else {
this.sendTo(clientId, { type: 'auth-fail', message: 'Invalid token' });
}
return;
}
// All other messages require authentication
if (!this.sessions.isAuthenticated(clientId)) {
this.sendTo(clientId, { type: 'auth-fail', message: 'Not authenticated' });
return;
}
switch (msg.type) {
case 'subscribe':
this.sessions.subscribe(clientId, msg.workspace, msg.surface);
if (this.verbose) console.log(`[ws] ${clientId} subscribed to ${msg.surface}`);
break;
case 'unsubscribe':
this.sessions.unsubscribe(clientId, msg.surface);
break;
case 'send-text':
if (this.verbose) console.log(`[ws] ${clientId} send-text to ${msg.surface}`);
this.poller.resetToFast(msg.surface);
await this.input.handleText(msg.workspace, msg.surface, msg.text);
break;
case 'send-key':
if (this.verbose) console.log(`[ws] ${clientId} send-key ${msg.key} to ${msg.surface}`);
this.poller.resetToFast(msg.surface);
await this.input.handleKey(msg.workspace, msg.surface, msg.key);
break;
case 'list-workspaces': {
const workspaces = await this.cmux.listWorkspaces();
this.sendTo(clientId, { type: 'workspaces', workspaces });
break;
}
case 'list-surfaces': {
const surfaces = await this.cmux.listPaneSurfaces(msg.workspace);
// Wrap in workspace info format
const workspaces = await this.cmux.listWorkspaces();
this.sendTo(clientId, { type: 'workspaces', workspaces });
break;
}
case 'scroll-request': {
const content = await this.cmux.capturePaneScrollback(
msg.workspace,
msg.surface,
msg.lines
);
const lines = content.split('\n');
this.sendTo(clientId, {
type: 'screen',
surface: msg.surface,
content,
lines,
});
break;
}
default:
this.sendTo(clientId, { type: 'error', message: `Unknown message type` });
}
}
private broadcastUpdate(update: {
surface: string;
workspace: string;
full: boolean;
lines: string[];
patches?: Array<{ startLine: number; deleteCount: number; insertLines: string[] }>;
}): void {
const subscribedClients = this.sessions.getSubscribedClients(update.surface);
for (const clientId of subscribedClients) {
if (update.full || !update.patches) {
this.sendTo(clientId, {
type: 'screen',
surface: update.surface,
content: update.lines.join('\n'),
lines: update.lines,
});
} else {
this.sendTo(clientId, {
type: 'screen-diff',
surface: update.surface,
patches: update.patches,
});
}
}
}
private sendTo(clientId: string, msg: ServerMessage): void {
const client = this.clients.get(clientId);
if (client && client.ws.readyState === WebSocket.OPEN) {
client.ws.send(JSON.stringify(msg));
}
}
getClientCount(): number {
return this.clients.size;
}
close(): void {
this.wss.close();
}
}

@ -0,0 +1,8 @@
declare module 'qrcode-terminal' {
interface Options {
small?: boolean;
}
function generate(text: string, opts?: Options, callback?: (code: string) => void): void;
export { generate };
export default { generate };
}

@ -0,0 +1,19 @@
export interface Config {
port: number;
password: string | null;
tunnel: boolean;
pollRate: number;
theme: 'dark' | 'light';
verbose: boolean;
cmuxPath: string;
}
export const DEFAULT_CONFIG: Config = {
port: 9870,
password: null,
tunnel: true,
pollRate: 200,
theme: 'dark',
verbose: false,
cmuxPath: 'cmux',
};

@ -0,0 +1,47 @@
import type { DiffPatch } from '../protocol/messages.js';
export function computeDiff(oldLines: string[], newLines: string[]): DiffPatch[] {
const patches: DiffPatch[] = [];
const maxLen = Math.max(oldLines.length, newLines.length);
let i = 0;
while (i < maxLen) {
// Skip identical lines
if (i < oldLines.length && i < newLines.length && oldLines[i] === newLines[i]) {
i++;
continue;
}
// Found a difference — collect consecutive changed lines
const start = i;
// Advance through differing lines
while (
i < maxLen &&
!(i < oldLines.length && i < newLines.length && oldLines[i] === newLines[i])
) {
i++;
}
patches.push({
startLine: start,
deleteCount: Math.min(i, oldLines.length) - start,
insertLines: newLines.slice(start, Math.min(i, newLines.length)),
});
}
return patches;
}
export function applyDiff(lines: string[], patches: DiffPatch[]): string[] {
const result = [...lines];
let offset = 0;
for (const patch of patches) {
const adjustedStart = patch.startLine + offset;
result.splice(adjustedStart, patch.deleteCount, ...patch.insertLines);
offset += patch.insertLines.length - patch.deleteCount;
}
return result;
}

@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import { CmuxClient } from '../../src/bridge/cmux-client.js';
describe('CmuxClient', () => {
describe('parseTree', () => {
it('parses real cmux tree output with tree-drawing chars', () => {
const client = new CmuxClient();
const output = `window window:1 [current]
workspace workspace:1 "My Project" [selected]
pane pane:2
surface surface:2 [terminal] "zsh"
surface surface:13 [terminal] "node"
pane pane:3 [focused]
surface surface:4 [browser] "localhost"
workspace workspace:2 "Notes"
pane pane:6 [focused]
surface surface:6 [terminal] "vim" [selected]`;
const result = client.parseTree(output);
expect(result).toHaveLength(2);
expect(result[0].ref).toBe('workspace:1');
expect(result[0].name).toBe('My Project');
expect(result[0].panes).toHaveLength(2);
expect(result[0].panes[0].surfaces).toHaveLength(2);
expect(result[0].panes[0].surfaces[0].type).toBe('terminal');
expect(result[0].panes[0].surfaces[0].title).toBe('zsh');
expect(result[0].panes[1].surfaces[0].type).toBe('browser');
expect(result[0].panes[1].surfaces[0].title).toBe('localhost');
expect(result[1].panes[0].surfaces[0].title).toBe('vim');
});
it('handles empty output', () => {
const client = new CmuxClient();
expect(client.parseTree('')).toEqual([]);
});
it('handles workspace without quoted name', () => {
const client = new CmuxClient();
const output = `window window:1
workspace workspace:1 [selected]
pane pane:1
surface surface:1 [terminal] [selected]`;
const result = client.parseTree(output);
expect(result[0].name).toBeUndefined();
});
it('extracts titles with special characters', () => {
const client = new CmuxClient();
const output = `window window:1
workspace workspace:5 "✳ cmux-remote-plugin"
pane pane:9 [focused]
surface surface:14 [terminal] "⠂ cmux-remote-plugin" [selected]`;
const result = client.parseTree(output);
expect(result[0].name).toBe('✳ cmux-remote-plugin');
expect(result[0].panes[0].surfaces[0].title).toBe('⠂ cmux-remote-plugin');
});
});
describe('parseSurfaces', () => {
it('parses surface list', () => {
const client = new CmuxClient();
const output = `surface:1 [terminal] zsh
surface:2 [browser] localhost`;
const result = client.parseSurfaces(output);
expect(result).toHaveLength(2);
expect(result[0].ref).toBe('surface:1');
expect(result[0].type).toBe('terminal');
expect(result[1].type).toBe('browser');
});
});
});

@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ScreenPoller } from '../../src/bridge/screen-poller.js';
// Mock CmuxClient
function createMockClient(screenContent = 'line1\nline2\nline3') {
return {
readScreen: vi.fn().mockResolvedValue(screenContent),
sendText: vi.fn(),
sendKey: vi.fn(),
} as any;
}
describe('ScreenPoller', () => {
beforeEach(() => {
vi.useFakeTimers();
});
it('emits update on first poll', async () => {
const client = createMockClient();
const poller = new ScreenPoller(client, 200);
const updates: any[] = [];
poller.on('update', (u: any) => updates.push(u));
poller.subscribe('client1', 'workspace:1', 'surface:1');
// Fast-forward past first poll
await vi.advanceTimersByTimeAsync(50);
expect(updates.length).toBe(1);
expect(updates[0].full).toBe(true);
expect(updates[0].lines).toEqual(['line1', 'line2', 'line3']);
poller.destroy();
});
it('does not emit when content is unchanged', async () => {
const client = createMockClient();
const poller = new ScreenPoller(client, 200);
const updates: any[] = [];
poller.on('update', (u: any) => updates.push(u));
poller.subscribe('client1', 'workspace:1', 'surface:1');
// First poll
await vi.advanceTimersByTimeAsync(50);
expect(updates.length).toBe(1);
// Second poll — same content
await vi.advanceTimersByTimeAsync(250);
expect(updates.length).toBe(1);
poller.destroy();
});
it('sends diff patches when content changes', async () => {
const client = createMockClient('line1\nline2\nline3');
const poller = new ScreenPoller(client, 200);
const updates: any[] = [];
poller.on('update', (u: any) => updates.push(u));
poller.subscribe('client1', 'workspace:1', 'surface:1');
// First poll
await vi.advanceTimersByTimeAsync(50);
expect(updates.length).toBe(1);
// Change content
client.readScreen.mockResolvedValue('line1\nline2-changed\nline3');
// Second poll
await vi.advanceTimersByTimeAsync(250);
expect(updates.length).toBe(2);
expect(updates[1].full).toBe(false);
expect(updates[1].patches).toBeDefined();
poller.destroy();
});
it('stops polling when all subscribers unsubscribe', async () => {
const client = createMockClient();
const poller = new ScreenPoller(client, 200);
poller.subscribe('client1', 'workspace:1', 'surface:1');
await vi.advanceTimersByTimeAsync(50);
poller.unsubscribe('client1', 'surface:1');
const callCount = client.readScreen.mock.calls.length;
await vi.advanceTimersByTimeAsync(500);
// Should not have polled again
expect(client.readScreen.mock.calls.length).toBe(callCount);
poller.destroy();
});
it('resets to fast polling on resetToFast', async () => {
const client = createMockClient();
const poller = new ScreenPoller(client, 200);
poller.subscribe('client1', 'workspace:1', 'surface:1');
await vi.advanceTimersByTimeAsync(50);
// Simulate idle
for (let i = 0; i < 5; i++) {
await vi.advanceTimersByTimeAsync(250);
}
poller.resetToFast('surface:1');
// Should have reset idle count internally
poller.destroy();
});
});

@ -0,0 +1,85 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { AuthManager, generatePassphrase } from '../../src/server/auth.js';
describe('AuthManager', () => {
let auth: AuthManager;
beforeEach(async () => {
auth = new AuthManager();
await auth.setPassword('test-password');
});
describe('password verification', () => {
it('accepts correct password', async () => {
expect(await auth.verifyPassword('test-password')).toBe(true);
});
it('rejects wrong password', async () => {
expect(await auth.verifyPassword('wrong')).toBe(false);
});
});
describe('JWT tokens', () => {
it('generates and verifies a valid token', () => {
const token = auth.generateToken();
expect(auth.verifyToken(token)).toBe(true);
});
it('rejects invalid tokens', () => {
expect(auth.verifyToken('not-a-real-token')).toBe(false);
});
it('rejects tokens from different instance', async () => {
const token = auth.generateToken();
const other = new AuthManager();
await other.setPassword('x');
expect(other.verifyToken(token)).toBe(false);
});
});
describe('rate limiting', () => {
it('allows initial attempts', () => {
expect(auth.isRateLimited('1.2.3.4')).toBe(false);
});
it('blocks after max attempts', () => {
for (let i = 0; i < 5; i++) {
auth.recordAttempt('1.2.3.4');
}
expect(auth.isRateLimited('1.2.3.4')).toBe(true);
});
it('does not affect other IPs', () => {
for (let i = 0; i < 5; i++) {
auth.recordAttempt('1.2.3.4');
}
expect(auth.isRateLimited('5.6.7.8')).toBe(false);
});
it('resets after successful auth', () => {
for (let i = 0; i < 3; i++) {
auth.recordAttempt('1.2.3.4');
}
auth.resetAttempts('1.2.3.4');
expect(auth.isRateLimited('1.2.3.4')).toBe(false);
});
});
});
describe('generatePassphrase', () => {
it('generates a 4-word passphrase', () => {
const phrase = generatePassphrase();
const words = phrase.split('-');
expect(words.length).toBe(4);
for (const word of words) {
expect(word.length).toBeGreaterThan(0);
}
});
it('generates different passphrases', () => {
const a = generatePassphrase();
const b = generatePassphrase();
// Extremely unlikely to be equal
expect(a).not.toBe(b);
});
});

@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { computeDiff, applyDiff } from '../../src/utils/text-differ.js';
describe('text-differ', () => {
describe('computeDiff', () => {
it('returns empty patches for identical content', () => {
const lines = ['a', 'b', 'c'];
expect(computeDiff(lines, lines)).toEqual([]);
});
it('detects a single line change', () => {
const oldLines = ['a', 'b', 'c'];
const newLines = ['a', 'B', 'c'];
const patches = computeDiff(oldLines, newLines);
expect(patches.length).toBe(1);
expect(patches[0].startLine).toBe(1);
expect(patches[0].deleteCount).toBe(1);
expect(patches[0].insertLines).toEqual(['B']);
});
it('detects added lines', () => {
const oldLines = ['a', 'b'];
const newLines = ['a', 'b', 'c', 'd'];
const patches = computeDiff(oldLines, newLines);
expect(patches.length).toBeGreaterThan(0);
const result = applyDiff(oldLines, patches);
expect(result).toEqual(newLines);
});
it('detects removed lines', () => {
const oldLines = ['a', 'b', 'c', 'd'];
const newLines = ['a', 'd'];
const patches = computeDiff(oldLines, newLines);
const result = applyDiff(oldLines, patches);
expect(result).toEqual(newLines);
});
});
describe('applyDiff', () => {
it('applies patches to reconstruct new content', () => {
const old = ['line1', 'line2', 'line3', 'line4', 'line5'];
const now = ['line1', 'CHANGED', 'line3', 'ADDED', 'line4', 'line5'];
const patches = computeDiff(old, now);
const result = applyDiff(old, patches);
expect(result).toEqual(now);
});
it('handles empty old lines', () => {
const patches = computeDiff([], ['a', 'b']);
const result = applyDiff([], patches);
expect(result).toEqual(['a', 'b']);
});
it('handles empty new lines', () => {
const patches = computeDiff(['a', 'b'], []);
const result = applyDiff(['a', 'b'], patches);
expect(result).toEqual([]);
});
});
});

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}
Loading…
Cancel
Save