// 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; let lastWorkspaces = []; // URL hash helpers — format: #wsRef/surfaceRef function getHashSurface() { const hash = location.hash.slice(1); if (!hash) return null; const slash = hash.indexOf('/'); if (slash === -1) return null; return { ws: decodeURIComponent(hash.slice(0, slash)), surface: decodeURIComponent(hash.slice(slash + 1)), }; } function setHashSurface(wsRef, surfaceRef) { history.replaceState(null, '', '#' + encodeURIComponent(wsRef) + '/' + encodeURIComponent(surfaceRef)); } function findAndSwitch(workspaces, wsRef, surfaceRef) { for (const workspace of workspaces) { if (workspace.ref === wsRef) { for (const pane of workspace.panes) { for (const surface of pane.surfaces) { if (surface.ref === surfaceRef) { switchSurface(wsRef, surfaceRef); return true; } } } } } return false; } // 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 browserView = document.getElementById('browser-view'); const terminalOutput = document.getElementById('terminal-output'); // ── Image zoom viewer ────────────────────────────────────────── class ImageZoomViewer { constructor(img, container) { this._img = img; this._con = container; this._scale = 1; this._x = 0; this._y = 0; this._lastDist = 0; this._dragging = false; this._lastX = 0; this._lastY = 0; container.addEventListener('touchstart', this._ts.bind(this), { passive: false }); container.addEventListener('touchmove', this._tm.bind(this), { passive: false }); container.addEventListener('touchend', () => { this._dragging = false; }, { passive: true }); container.addEventListener('wheel', this._wheel.bind(this), { passive: false }); container.addEventListener('mousedown', this._md.bind(this)); container.addEventListener('mousemove', this._mm.bind(this)); container.addEventListener('mouseup', this._mu.bind(this)); container.addEventListener('mouseleave', this._mu.bind(this)); container.addEventListener('dblclick', () => this.reset()); } reset() { this._scale = 1; this._x = 0; this._y = 0; this._apply(); } _apply() { this._img.style.transform = `translate(${this._x}px, ${this._y}px) scale(${this._scale})`; } _clampScale(s) { return Math.min(10, Math.max(0.5, s)); } _ts(e) { if (e.touches.length === 2) { e.preventDefault(); this._lastDist = this._dist(e.touches[0], e.touches[1]); } else if (e.touches.length === 1) { this._dragging = true; this._lastX = e.touches[0].clientX; this._lastY = e.touches[0].clientY; } } _tm(e) { e.preventDefault(); if (e.touches.length === 2) { const d = this._dist(e.touches[0], e.touches[1]); this._scale = this._clampScale(this._scale * (d / this._lastDist)); this._lastDist = d; this._apply(); } else if (e.touches.length === 1 && this._dragging) { this._x += e.touches[0].clientX - this._lastX; this._y += e.touches[0].clientY - this._lastY; this._lastX = e.touches[0].clientX; this._lastY = e.touches[0].clientY; this._apply(); } } _wheel(e) { e.preventDefault(); this._scale = this._clampScale(this._scale * (e.deltaY < 0 ? 1.12 : 0.9)); this._apply(); } _md(e) { this._dragging = true; this._lastX = e.clientX; this._lastY = e.clientY; this._con.classList.add('dragging'); } _mm(e) { if (!this._dragging) return; this._x += e.clientX - this._lastX; this._y += e.clientY - this._lastY; this._lastX = e.clientX; this._lastY = e.clientY; this._apply(); } _mu() { this._dragging = false; this._con.classList.remove('dragging'); } _dist(t1, t2) { return Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY); } } const browserLoading = document.getElementById('browser-loading'); const zoomBtn = document.getElementById('browser-zoom-btn'); const zoomOverlay = document.getElementById('img-zoom-overlay'); const zoomContainer = document.getElementById('img-zoom-container'); const zoomImg = document.getElementById('img-zoom-img'); const zoomClose = document.getElementById('img-zoom-close'); let zoomViewer = null; zoomBtn.addEventListener('click', () => { zoomImg.src = browserView.src; zoomOverlay.hidden = false; if (!zoomViewer) { zoomViewer = new ImageZoomViewer(zoomImg, zoomContainer); } else { zoomViewer.reset(); } }); zoomClose.addEventListener('click', () => { zoomOverlay.hidden = true; }); const sidebar = new Sidebar((wsRef, surfaceRef) => { switchSurface(wsRef, surfaceRef); }); const gestures = new GestureHandler(sidebar); const theme = new ThemeSwitcher(); // Scrollback state const PAGE_LINES = 500; let scrollbackLines = 0; // 0 = live view let inScrollMode = false; let scrollbackPending = false; // Set once capture-pane stops returning new lines (history exhausted). Without // this, a surface with little/no scrollback flashes the loader forever: each // wheel-up at the top re-requests, the response adds nothing, the view stays put // at scrollTop 0, so the next wheel-up fires again. Reset on surface switch / // when live updates resume (more history may exist later). let noMoreHistory = false; const scrollbackLoading = document.getElementById('scrollback-loading'); function requestScrollback(lines) { if (!currentWorkspace || !currentSurface) return; scrollbackPending = true; if (scrollbackLoading) scrollbackLoading.hidden = false; ws.requestScroll(currentWorkspace, currentSurface, lines); } function exitScrollMode() { inScrollMode = false; scrollbackLines = 0; scrollbackPending = false; noMoreHistory = false; if (scrollbackLoading) scrollbackLoading.hidden = true; terminal.autoScroll = true; // Re-subscribe to get fresh live content if (currentWorkspace && currentSurface) { ws.subscribe(currentWorkspace, currentSurface); } } // Scroll-driven history: reaching the top (with scroll room) loads older // history; reaching the bottom resumes live updates. Entry is explicit (only // on an actual scroll-to-top), never automatic — that avoids the freeze the // old auto-load-when-fits caused. terminalContainer.addEventListener('scroll', () => { const c = terminalContainer; const atBottom = c.scrollHeight - c.scrollTop - c.clientHeight < 30; if (atBottom) { if (inScrollMode) exitScrollMode(); return; } if (c.scrollTop < 40 && !scrollbackPending && terminal.lines.length && currentWorkspace && currentSurface) { loadOlderHistory(); } }); function loadOlderHistory() { if (scrollbackPending || noMoreHistory || !terminal.lines.length || !currentWorkspace || !currentSurface) return; inScrollMode = true; terminal.autoScroll = false; // pin position; render-fix won't snap to bottom scrollbackLines += PAGE_LINES; requestScrollback(scrollbackLines); } // Desktop has no touch scroll. When the live screen fits the viewport exactly // there is no scroll room, so the 'scroll' event never fires and history can't // be reached. Catch the wheel-up intent directly to load older history. terminalContainer.addEventListener('wheel', (e) => { if (e.deltaY < 0 && terminalContainer.scrollTop < 40) { loadOlderHistory(); } }, { passive: true }); const keyboard = new VirtualKeyboard( (text) => { if (currentWorkspace && currentSurface) { if (inScrollMode) exitScrollMode(); ws.sendText(currentWorkspace, currentSurface, text); } }, (key) => { if (key === 'PageUp') { if (noMoreHistory) return; inScrollMode = true; scrollbackLines += PAGE_LINES; requestScrollback(scrollbackLines); return; } if (key === 'PageDown') { if (inScrollMode) { scrollbackLines = Math.max(0, scrollbackLines - PAGE_LINES); if (scrollbackLines === 0) { exitScrollMode(); } else { requestScrollback(scrollbackLines); } } return; } if (inScrollMode) exitScrollMode(); if (currentWorkspace && currentSurface) { ws.sendKey(currentWorkspace, currentSurface, key); } }, (text) => { // onSubmitText: send text + server-side Enter (awaited ordering) if (currentWorkspace && currentSurface) { if (inScrollMode) exitScrollMode(); ws.sendTextSubmit(currentWorkspace, currentSurface, text); } } ); const claudeKeyboard = new ClaudeKeyboard( (text) => { if (currentWorkspace && currentSurface) { ws.sendText(currentWorkspace, currentSurface, text); } }, (key) => { if (currentWorkspace && currentSurface) { ws.sendKey(currentWorkspace, currentSurface, key); } } ); function handleClaudeAction(action, payload) { if (!currentWorkspace || !currentSurface) return; if (action === 'select') { ws.sendText(currentWorkspace, currentSurface, payload + '\n'); } else if (action === 'mode-toggle') { ws.sendKey(currentWorkspace, currentSurface, 'Shift-Tab'); } } // Reload button document.getElementById('reload-btn').addEventListener('click', () => location.reload()); // Horizontal scroll toggle const scrollXCb = document.getElementById('scroll-x-cb'); scrollXCb.addEventListener('change', () => { terminalContainer.classList.toggle('scroll-x', scrollXCb.checked); }); // Keyboard toggle — restore saved state const keyboardToggle = document.getElementById('keyboard-toggle'); const virtualKeyboardEl = document.getElementById('virtual-keyboard'); const savedKbCollapsed = localStorage.getItem('cmux-kb-collapsed'); if (savedKbCollapsed !== null) { virtualKeyboardEl.classList.toggle('collapsed', savedKbCollapsed === 'true'); } else if (window.innerWidth >= 1024) { virtualKeyboardEl.classList.add('collapsed'); } keyboardToggle.addEventListener('click', () => { virtualKeyboardEl.classList.toggle('collapsed'); localStorage.setItem('cmux-kb-collapsed', virtualKeyboardEl.classList.contains('collapsed')); }); window.addEventListener('resize', () => { if (window.innerWidth < 1024) { virtualKeyboardEl.classList.remove('collapsed'); localStorage.removeItem('cmux-kb-collapsed'); } }); // Settings panel const settingsBtn = document.getElementById('settings-btn'); const settingsPanel = document.getElementById('settings-panel'); const settingsOverlay = document.getElementById('settings-overlay'); const settingsClose = document.getElementById('settings-close'); settingsBtn.addEventListener('click', () => { settingsPanel.classList.add('open'); settingsOverlay.classList.add('visible'); }); function closeSettings() { settingsPanel.classList.remove('open'); settingsOverlay.classList.remove('visible'); } settingsClose.addEventListener('click', closeSettings); settingsOverlay.addEventListener('click', closeSettings); function updateTitle(wsRef, surfaceRef) { const el = document.getElementById('app-title'); if (!el) return; let rawTitle = surfaceRef || ''; for (const workspace of lastWorkspaces) { if (workspace.ref === wsRef) { for (const pane of workspace.panes) { for (const surface of pane.surfaces) { if (surface.ref === surfaceRef) { rawTitle = surface.title || surface.ref; break; } } } } } const status = Sidebar.parseStatus(rawTitle); el.innerHTML = ''; if (status.state) { const dot = document.createElement('span'); dot.className = 'title-status ' + status.state; el.appendChild(dot); } el.appendChild(document.createTextNode(status.title)); } // Connection events // The `.stale` veil dims the WHOLE terminal (a 40%-opacity overlay). Showing // it on every brief disconnect makes the screen visibly "fade out then back" // whenever the socket flaps/reconnects (common on remote/tunnel connections). // Debounce it: only dim once a disconnect has lasted long enough that the view // is genuinely stale; a fast reconnect clears the pending dim with no flash. let staleTimer = null; ws.on('connected', () => { statusDot.classList.add('connected'); statusDot.classList.remove('reconnecting'); statusDot.title = 'Connected'; if (staleTimer) { clearTimeout(staleTimer); staleTimer = null; } terminalContainer.classList.remove('stale'); }); ws.on('disconnected', () => { statusDot.classList.remove('connected'); statusDot.classList.add('reconnecting'); statusDot.title = 'Reconnecting...'; if (!staleTimer && !terminalContainer.classList.contains('stale')) { staleTimer = setTimeout(() => { terminalContainer.classList.add('stale'); staleTimer = null; }, 2500); } }); ws.on('reconnecting', ({ attempt }) => { statusDot.title = `Reconnecting (${attempt})...`; }); ws.on('auth-ok', () => { ws.listWorkspaces(); // Re-subscribe to current surface after reconnect if (currentWorkspace && currentSurface) { ws.subscribe(currentWorkspace, currentSurface); } }); ws.on('workspaces', (msg) => { sidebar.setWorkspaces(msg.workspaces); lastWorkspaces = msg.workspaces; // Auto-subscribe only on first load (no surface selected yet) if (!currentSurface && msg.workspaces.length > 0) { // 1. Try to restore from URL hash const saved = getHashSurface(); if (saved && findAndSwitch(msg.workspaces, saved.ws, saved.surface)) return; // 2. Try to restore from localStorage try { const ls = JSON.parse(localStorage.getItem('cmux-last-surface') || 'null'); if (ls && findAndSwitch(msg.workspaces, ls.ws, ls.surface)) return; } catch {} // 3. Fall back to first terminal surface 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; } } } // 3. Fall back to 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) return; // While viewing loaded history, pause live screen replacement. if (inScrollMode && !msg.scrollback) return; if (msg.scrollback) { // User-triggered history load (scroll-to-top or PageUp): show it, pause // live updates, and preserve the user's reading position. Older lines are // prepended, so shift scrollTop by the height added above. const prevScrollHeight = terminalContainer.scrollHeight; const prevScrollTop = terminalContainer.scrollTop; const prevLineCount = terminal.lines.length; terminal.setContent(msg.lines); terminal.autoScroll = false; scrollbackPending = false; if (scrollbackLoading) scrollbackLoading.hidden = true; // capture-pane returned no additional lines → history is exhausted. Stop // further requests so the loader doesn't flash on every wheel-up at the top. if (msg.lines.length <= prevLineCount) { noMoreHistory = true; } requestAnimationFrame(() => { const added = terminalContainer.scrollHeight - prevScrollHeight; // Preserve the reading position: keep whatever is on screen exactly where it // is and let the freshly-loaded older lines sit ABOVE it, so the view doesn't // jump — scrolling up further then reveals the history smoothly. (Older lines // are prepended, so shift scrollTop down by the height added above.) terminalContainer.scrollTop = prevScrollTop + Math.max(added, 0); }); } else { terminal.setContent(msg.lines); } }); ws.on('screen-diff', (msg) => { if (msg.surface === currentSurface && !inScrollMode) { terminal.applyDiff(msg.patches); } }); ws.on('browser-screenshot', (msg) => { if (msg.surface !== currentSurface) return; const mime = msg.mime || 'image/jpeg'; const src = `data:${mime};base64,${msg.imageData}`; terminalOutput.hidden = true; terminalContainer.classList.add('browser-mode'); // Hide loading bar, show image browserLoading.hidden = true; browserLoading.classList.remove('flash'); browserView.hidden = false; zoomBtn.hidden = false; browserView.src = src; if (!zoomOverlay.hidden) zoomImg.src = src; }); async function updateCodexMode(surfaceRef) { // Skip if claude mode is already active (mutually exclusive) if (document.body.classList.contains('claude-mode')) return; try { const data = await fetch('/api/codex-surfaces').then(r => r.json()); if (surfaceRef !== currentSurface) return; const isCodex = data.surfaces.includes(surfaceRef); document.body.classList.toggle('codex-mode', isCodex); if (terminal.setCodexMode) terminal.setCodexMode(isCodex); } catch { if (surfaceRef !== currentSurface) return; document.body.classList.remove('codex-mode'); if (terminal.setCodexMode) terminal.setCodexMode(false); } } async function updateClaudeMode(surfaceRef) { try { const data = await fetch('/api/claude-surfaces').then(r => r.json()); if (surfaceRef !== currentSurface) return; const isClaudeCode = data.surfaces.includes(surfaceRef); document.body.classList.toggle('claude-mode', isClaudeCode); terminal.setClaudeMode(isClaudeCode, handleClaudeAction); if (isClaudeCode) { claudeKeyboard.activate(); } else { claudeKeyboard.deactivate(); } } catch { if (surfaceRef !== currentSurface) return; document.body.classList.remove('claude-mode'); terminal.setClaudeMode(false, null); claudeKeyboard.deactivate(); } } function switchSurface(wsRef, surfaceRef) { if (currentSurface) ws.unsubscribe(currentSurface); inScrollMode = false; scrollbackLines = 0; scrollbackPending = false; noMoreHistory = false; if (scrollbackLoading) scrollbackLoading.hidden = true; currentWorkspace = wsRef; currentSurface = surfaceRef; updateTitle(wsRef, surfaceRef); localStorage.setItem('cmux-last-surface', JSON.stringify({ ws: wsRef, surface: surfaceRef })); // Immediately reset claude/codex mode; update functions will re-enable if needed document.body.classList.remove('claude-mode'); document.body.classList.remove('codex-mode'); terminal.setClaudeMode(false, null); if (terminal.setCodexMode) terminal.setCodexMode(false); claudeKeyboard.deactivate(); terminal.clear(); terminal.autoScroll = true; browserView.hidden = true; browserLoading.hidden = true; browserLoading.classList.remove('flash'); zoomBtn.hidden = true; zoomOverlay.hidden = true; terminalOutput.hidden = false; terminalContainer.classList.remove('browser-mode'); ws.subscribe(wsRef, surfaceRef); sidebar.setActiveSurface(surfaceRef); setHashSurface(wsRef, surfaceRef); updateClaudeMode(surfaceRef); updateCodexMode(surfaceRef); // If this is a browser surface, show loading indicator immediately const surfaceInfo = lastWorkspaces.flatMap(w => w.panes.flatMap(p => p.surfaces)) .find(s => s.ref === surfaceRef); if (surfaceInfo?.type === 'browser') { terminalOutput.hidden = true; terminalContainer.classList.add('browser-mode'); browserLoading.hidden = false; browserLoading.classList.add('flash'); } } // Browser back/forward navigation window.addEventListener('hashchange', () => { const saved = getHashSurface(); if (saved && ws.isConnected()) { findAndSwitch(lastWorkspaces, saved.ws, saved.surface); } }); // Visibility API: pause polling when hidden, reconnect when visible let hiddenSince = 0; document.addEventListener('visibilitychange', () => { if (document.hidden) { hiddenSince = Date.now(); if (currentSurface) ws.unsubscribe(currentSurface); } else { const elapsed = hiddenSince ? Date.now() - hiddenSince : 0; hiddenSince = 0; if (!currentWorkspace || !currentSurface) return; // iOS kills WebSocket connections in background. // If hidden >5s or connection is dead, force fresh reconnect. if (!ws.isConnected() || elapsed > 5000) { ws.reconnectNow(); } else { ws.subscribe(currentWorkspace, currentSurface); } } }); // Periodic workspace refresh setInterval(() => { if (ws.isConnected()) { ws.listWorkspaces(); } }, 10000); // Start ws.connect(authStatus.authRequired); })();