@ -13,12 +13,145 @@
// 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 ) ;
} ) ;
@ -26,99 +159,181 @@
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' ) ;
// Scrollback state
const PAGE _LINES = 500 ;
let scrollbackLines = 0 ; // 0 = live view
let inScrollMode = false ;
function _qrKeyHandler ( e ) {
if ( e . key === 'Escape' ) {
closeQrModal ( ) ;
} else if ( e . key === 'Tab' ) {
e . preventDefault ( ) ;
qrCloseBtn . focus ( ) ;
}
function requestScrollback ( lines ) {
if ( ! currentWorkspace || ! currentSurface ) return ;
ws . requestScroll ( currentWorkspace , currentSurface , lines ) ;
}
async function openQrModal ( ) {
try {
const { url } = await fetch ( '/api/access-url' ) . then ( r => r . json ( ) ) ;
qrUrlEl . textContent = url ;
} catch {
qrUrlEl . textContent = window . location . origin ;
function exitScrollMode ( ) {
inScrollMode = false ;
scrollbackLines = 0 ;
terminal . autoScroll = true ;
// Re-subscribe to get fresh live content
if ( currentWorkspace && currentSurface ) {
ws . subscribe ( currentWorkspace , currentSurface ) ;
}
// 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 ) {
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 ) ;
}
}
) ;
// Keyboard toggle (desktop)
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' ) ;
if ( window . innerWidth >= 1024 ) {
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 . hidden = false ;
}
keyboardToggle . addEventListener ( 'click' , ( ) => {
virtualKeyboardEl . classList . toggle ( 'collapsed' ) ;
localStorage . setItem ( 'cmux-kb-collapsed' , virtualKeyboardEl . classList . contains ( 'collapsed' ) ) ;
} ) ;
window . addEventListener ( 'resize' , ( ) => {
if ( window . innerWidth < 1024 ) {
keyboardToggle . hidden = true ;
virtualKeyboardEl . classList . remove ( 'collapsed' ) ;
} else {
keyboardToggle . hidden = false ;
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 ) ;
// Title helper — reuses sidebar's status-parsing logic
function _parseStatus ( title ) {
const workingRe = /^[\u2800-\u28FF\u23FA\u25CF\u2B24]\s*/ ;
const idleRe = /^[\u2700-\u27BF]\s*/ ;
if ( workingRe . test ( title ) ) return { state : 'working' , title : title . replace ( workingRe , '' ) } ;
if ( idleRe . test ( title ) ) return { state : 'idle' , title : title . replace ( idleRe , '' ) } ;
return { state : null , title } ;
}
function updateTitle ( wsRef , surfaceRef ) {
const el = document . getElementById ( 'app-title' ) ;
if ( ! el ) return ;
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 = _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 . textContent = 'Connected' ;
statusDot . t itle = 'Connected' ;
terminalContainer . classList . remove ( 'stale' ) ;
} ) ;
ws . on ( 'disconnected' , ( ) => {
statusDot . classList . remove ( 'connected' ) ;
statusDot . classList . add ( 'reconnecting' ) ;
statusDot . textContent = 'Reconnecting...' ;
statusDot . t itle = 'Reconnecting...' ;
terminalContainer . classList . add ( 'stale' ) ;
} ) ;
ws . on ( 'reconnecting' , ( { attempt } ) => {
statusDot . textContent = ` Reconnecting ( ${ attempt } )... ` ;
statusDot . t itle = ` Reconnecting ( ${ attempt } )... ` ;
} ) ;
ws . on ( 'auth-ok' , ( ) => {
@ -128,9 +343,21 @@
ws . on ( 'workspaces' , ( msg ) => {
sidebar . setWorkspaces ( msg . workspaces ) ;
lastWorkspaces = msg . workspaces ;
// Auto-subscribe to first terminal surface if none selected
// 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 ) {
@ -140,7 +367,7 @@
}
}
}
// Fallback: first surface of any type
// 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 ) ;
@ -149,31 +376,111 @@
} ) ;
ws . on ( 'screen' , ( msg ) => {
if ( msg . surface === currentSurface ) {
terminal . setContent ( msg . lines ) ;
if ( msg . surface !== currentSurface ) return ;
if ( inScrollMode && ! msg . scrollback ) return ;
terminal . setContent ( msg . lines ) ;
if ( msg . scrollback ) {
terminal . autoScroll = false ;
// After render, scroll to show content just before current view (most recently scrolled-off)
requestAnimationFrame ( ( ) => {
requestAnimationFrame ( ( ) => {
terminalContainer . scrollTop = terminalContainer . scrollHeight - terminalContainer . clientHeight ;
} ) ;
} ) ;
}
} ) ;
ws . on ( 'screen-diff' , ( msg ) => {
if ( msg . surface === currentSurface ) {
if ( msg . surface === currentSurface && ! inScrollMode ) {
terminal . applyDiff ( msg . patches ) ;
}
} ) ;
function switchSurface ( wsRef , surfaceRef ) {
// Unsubscribe from current
if ( currentSurface ) {
ws . unsubscribe ( currentSurface ) ;
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 ;
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
document . addEventListener ( 'visibilitychange' , ( ) => {
if ( document . hidden ) {