// 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; 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; if (scrollbackLoading) scrollbackLoading.hidden = true; terminal.autoScroll = true; // Re-subscribe to get fresh live content if (currentWorkspace && currentSurface) { ws.subscribe(currentWorkspace, currentSurface); } } // Auto-load scrollback when user scrolls to the top terminalContainer.addEventListener('scroll', () => { if (scrollbackPending || !terminal.lines.length) return; if (terminalContainer.scrollTop < 50 && currentWorkspace && currentSurface) { inScrollMode = true; scrollbackLines += PAGE_LINES; requestScrollback(scrollbackLines); } }); const keyboard = new VirtualKeyboard( (text) => { if (currentWorkspace && currentSurface) { if (inScrollMode) exitScrollMode(); ws.sendText(currentWorkspace, currentSurface, text); } }, (key) => { if (key === 'PageUp') { 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); } } ); 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 ws.on('connected', () => { statusDot.classList.add('connected'); statusDot.classList.remove('reconnecting'); statusDot.title = 'Connected'; terminalContainer.classList.remove('stale'); }); ws.on('disconnected', () => { statusDot.classList.remove('connected'); statusDot.classList.add('reconnecting'); statusDot.title = 'Reconnecting...'; terminalContainer.classList.add('stale'); }); 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; if (inScrollMode && !msg.scrollback) return; if (msg.scrollback) { // Preserve scroll position relative to bottom so content doesn't jump const prevScrollHeight = terminalContainer.scrollHeight; const prevScrollTop = terminalContainer.scrollTop; terminal.setContent(msg.lines); terminal.autoScroll = false; scrollbackPending = false; if (scrollbackLoading) scrollbackLoading.hidden = true; requestAnimationFrame(() => { const newScrollHeight = terminalContainer.scrollHeight; const added = newScrollHeight - prevScrollHeight; // Keep the same content visible — shift scroll by the amount of new content added above terminalContainer.scrollTop = prevScrollTop + Math.max(added, 0); }); } else { terminal.setContent(msg.lines); } if (terminal.claudeMode && terminal.lastClaudeDoc) { claudeKeyboard.updateContext(terminal.lastClaudeDoc); } }); ws.on('screen-diff', (msg) => { if (msg.surface === currentSurface && !inScrollMode) { terminal.applyDiff(msg.patches); if (terminal.claudeMode && terminal.lastClaudeDoc) { claudeKeyboard.updateContext(terminal.lastClaudeDoc); } } }); 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 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; 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 mode; updateClaudeMode will re-enable if needed document.body.classList.remove('claude-mode'); terminal.setClaudeMode(false, null); 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); // 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); })();