2026-01-19 00:38:11 +01:00
// Claudeman App - Tab-based Terminal UI
2026-01-19 06:51:25 +01:00
// Default terminal scrollback (can be changed via settings)
const DEFAULT_SCROLLBACK = 5000 ;
2026-01-18 05:14:34 +01:00
class ClaudemanApp {
constructor () {
this . sessions = new Map ();
2026-01-18 07:31:19 +01:00
this . cases = [];
2026-01-18 05:14:34 +01:00
this . currentRun = null ;
2026-01-19 01:26:38 +01:00
this . totalTokens = 0 ;
2026-01-18 05:14:34 +01:00
this . eventSource = null ;
2026-01-18 07:31:19 +01:00
this . terminal = null ;
this . fitAddon = null ;
2026-01-19 00:38:11 +01:00
this . activeSessionId = null ;
this . respawnStatus = {};
this . respawnTimers = {}; // Track timed respawn timers
this . terminalBuffers = new Map (); // Store terminal content per session
2026-01-19 01:26:38 +01:00
this . editingSessionId = null ; // Session being edited in options modal
2026-01-19 02:10:44 +01:00
this . pendingCloseSessionId = null ; // Session pending close confirmation
2026-01-19 02:18:51 +01:00
this . screenSessions = []; // Screen sessions for process monitor
2026-01-18 05:14:34 +01:00
2026-01-19 13:21:01 +01:00
// Inner loop/todo state per session
this . innerStates = new Map (); // Map<sessionId, { loop, todos }>
this . innerStatePanelCollapsed = true ; // Default to collapsed
2026-01-19 00:38:11 +01:00
// Terminal write batching
2026-01-18 21:36:37 +01:00
this . pendingWrites = '' ;
this . writeFrameScheduled = false ;
2026-01-19 06:51:25 +01:00
// Render debouncing
this . renderSessionTabsTimeout = null ;
2026-01-21 06:00:34 +01:00
this . renderInnerStatePanelTimeout = null ;
this . renderTaskPanelTimeout = null ;
this . renderScreenSessionsTimeout = null ;
2026-01-19 06:51:25 +01:00
// System stats polling
this . systemStatsInterval = null ;
2026-01-19 11:48:02 +01:00
// DOM element cache for performance (avoid repeated getElementById calls)
this . _elemCache = {};
2026-01-18 05:14:34 +01:00
this . init ();
}
2026-01-19 11:48:02 +01:00
// Cached element getter - avoids repeated DOM queries
$ ( id ) {
if ( ! this . _elemCache [ id ]) {
this . _elemCache [ id ] = document . getElementById ( id );
}
return this . _elemCache [ id ];
}
2026-01-18 05:14:34 +01:00
init () {
2026-01-18 07:31:19 +01:00
this . initTerminal ();
2026-01-18 22:11:43 +01:00
this . loadFontSize ();
2026-01-19 19:22:28 +01:00
this . applyHeaderVisibilitySettings ();
2026-01-20 17:54:59 +01:00
this . applyMonitorVisibility ();
2026-01-18 05:14:34 +01:00
this . connectSSE ();
this . loadState ();
2026-01-18 20:11:46 +01:00
this . loadQuickStartCases ();
2026-01-18 07:31:19 +01:00
this . setupEventListeners ();
2026-01-19 06:51:25 +01:00
// Start system stats polling
this . startSystemStatsPolling ();
2026-01-18 05:14:34 +01:00
}
2026-01-18 07:31:19 +01:00
initTerminal () {
2026-01-19 06:51:25 +01:00
// Load scrollback setting from localStorage (default 5000)
const scrollback = parseInt ( localStorage . getItem ( 'claudeman-scrollback' )) || DEFAULT_SCROLLBACK ;
2026-01-18 07:31:19 +01:00
this . terminal = new Terminal ({
theme : {
2026-01-19 00:38:11 +01:00
background : '#0d0d0d' ,
2026-01-18 07:31:19 +01:00
foreground : '#e0e0e0' ,
cursor : '#e0e0e0' ,
2026-01-19 00:38:11 +01:00
cursorAccent : '#0d0d0d' ,
2026-01-18 07:31:19 +01:00
selection : 'rgba(255, 255, 255, 0.3)' ,
2026-01-19 00:38:11 +01:00
black : '#0d0d0d' ,
2026-01-18 07:31:19 +01:00
red : '#ff6b6b' ,
green : '#51cf66' ,
yellow : '#ffd43b' ,
blue : '#339af0' ,
magenta : '#cc5de8' ,
cyan : '#22b8cf' ,
white : '#e0e0e0' ,
brightBlack : '#495057' ,
brightRed : '#ff8787' ,
brightGreen : '#69db7c' ,
brightYellow : '#ffe066' ,
brightBlue : '#5c7cfa' ,
brightMagenta : '#da77f2' ,
brightCyan : '#66d9e8' ,
brightWhite : '#ffffff' ,
},
2026-01-19 00:38:11 +01:00
fontFamily : '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace' ,
2026-01-18 07:31:19 +01:00
fontSize : 14 ,
2026-01-19 00:38:11 +01:00
lineHeight : 1.2 ,
2026-01-18 07:31:19 +01:00
cursorBlink : true ,
cursorStyle : 'block' ,
2026-01-19 06:51:25 +01:00
scrollback : scrollback ,
2026-01-18 07:31:19 +01:00
allowTransparency : true ,
});
this . fitAddon = new FitAddon . FitAddon ();
this . terminal . loadAddon ( this . fitAddon );
const container = document . getElementById ( 'terminalContainer' );
this . terminal . open ( container );
this . fitAddon . fit ();
// Welcome message
2026-01-19 00:38:11 +01:00
this . showWelcome ();
2026-01-18 07:31:19 +01:00
2026-01-19 11:48:02 +01:00
// Handle resize with throttling for performance
this . _resizeTimeout = null ;
this . _lastResizeDims = null ;
2026-01-18 07:31:19 +01:00
2026-01-19 11:48:02 +01:00
const throttledResize = () => {
if ( this . _resizeTimeout ) return ;
this . _resizeTimeout = setTimeout (() => {
this . _resizeTimeout = null ;
if ( this . fitAddon ) {
this . fitAddon . fit ();
if ( this . activeSessionId ) {
const dims = this . fitAddon . proposeDimensions ();
// Only send resize if dimensions actually changed
if ( dims && ( ! this . _lastResizeDims ||
dims . cols !== this . _lastResizeDims . cols ||
dims . rows !== this . _lastResizeDims . rows )) {
this . _lastResizeDims = { cols : dims . cols , rows : dims . rows };
fetch ( `/api/sessions/ ${ this . activeSessionId } /resize` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ cols : dims . cols , rows : dims . rows })
});
}
2026-01-18 07:31:19 +01:00
}
}
2026-01-19 11:48:02 +01:00
}, 100 ); // Throttle to 100ms
};
window . addEventListener ( 'resize' , throttledResize );
const resizeObserver = new ResizeObserver ( throttledResize );
2026-01-18 07:31:19 +01:00
resizeObserver . observe ( container );
2026-01-21 06:02:55 +01:00
// Handle keyboard input with batching for rapid keystrokes
this . _pendingInput = '' ;
this . _inputFlushTimeout = null ;
this . _inputFlushDelay = 16 ; // Flush at 60fps max
const flushInput = () => {
if ( this . _pendingInput && this . activeSessionId ) {
const input = this . _pendingInput ;
this . _pendingInput = '' ;
2026-01-18 07:31:19 +01:00
fetch ( `/api/sessions/ ${ this . activeSessionId } /input` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
2026-01-21 06:02:55 +01:00
body : JSON . stringify ({ input })
2026-01-18 07:31:19 +01:00
});
}
2026-01-21 06:02:55 +01:00
this . _inputFlushTimeout = null ;
};
this . terminal . onData (( data ) => {
if ( this . activeSessionId ) {
this . _pendingInput += data ;
// Flush immediately for control characters (Enter, Ctrl+C, etc.)
if ( data . charCodeAt ( 0 ) < 32 || data . length > 1 ) {
if ( this . _inputFlushTimeout ) {
clearTimeout ( this . _inputFlushTimeout );
this . _inputFlushTimeout = null ;
}
flushInput ();
return ;
}
// Batch regular input at 60fps
if ( ! this . _inputFlushTimeout ) {
this . _inputFlushTimeout = setTimeout ( flushInput , this . _inputFlushDelay );
}
}
2026-01-18 07:31:19 +01:00
});
}
2026-01-19 00:38:11 +01:00
showWelcome () {
this . terminal . writeln ( '\x1b[1;36m Claudeman Terminal\x1b[0m' );
2026-01-21 13:16:13 +01:00
this . terminal . writeln ( '' );
this . terminal . writeln ( '\x1b[90m Each instance opens a persistent GNU Screen session running Claude or Shell.\x1b[0m' );
this . terminal . writeln ( '\x1b[90m Sessions stay alive for autonomous work, even if you close this browser.\x1b[0m' );
this . terminal . writeln ( '' );
this . terminal . writeln ( '\x1b[90m Press \x1b[1;37mCtrl+Enter\x1b[0m\x1b[90m to start Claude • Click \x1b[1;37mRun Shell\x1b[0m\x1b[90m for a terminal\x1b[0m' );
2026-01-19 00:38:11 +01:00
this . terminal . writeln ( '' );
}
batchTerminalWrite ( data ) {
this . pendingWrites += data ;
if ( ! this . writeFrameScheduled ) {
this . writeFrameScheduled = true ;
requestAnimationFrame (() => {
if ( this . pendingWrites && this . terminal ) {
this . terminal . write ( this . pendingWrites );
this . pendingWrites = '' ;
}
this . writeFrameScheduled = false ;
});
}
}
2026-01-18 07:31:19 +01:00
setupEventListeners () {
2026-01-19 01:42:47 +01:00
// Use capture to handle before terminal
2026-01-18 21:42:57 +01:00
document . addEventListener ( 'keydown' , ( e ) => {
2026-01-19 00:38:11 +01:00
// Escape - close panels and modals
2026-01-18 21:42:57 +01:00
if ( e . key === 'Escape' ) {
2026-01-19 00:38:11 +01:00
this . closeAllPanels ();
2026-01-18 22:18:17 +01:00
this . closeHelp ();
}
2026-01-19 00:38:11 +01:00
// Ctrl/Cmd + ? - help
2026-01-18 22:18:17 +01:00
if (( e . ctrlKey || e . metaKey ) && ( e . key === '?' || e . key === '/' )) {
e . preventDefault ();
this . showHelp ();
2026-01-18 21:42:57 +01:00
}
2026-01-19 00:38:11 +01:00
// Ctrl/Cmd + Enter - quick start
if (( e . ctrlKey || e . metaKey ) && e . key === 'Enter' ) {
2026-01-18 21:42:57 +01:00
e . preventDefault ();
this . quickStart ();
}
2026-01-19 00:38:11 +01:00
// Ctrl/Cmd + W - close active session
if (( e . ctrlKey || e . metaKey ) && e . key === 'w' ) {
e . preventDefault ();
this . killActiveSession ();
}
// Ctrl/Cmd + Tab - next session
if (( e . ctrlKey || e . metaKey ) && e . key === 'Tab' ) {
e . preventDefault ();
this . nextSession ();
}
// Ctrl/Cmd + K - kill all
2026-01-18 21:42:57 +01:00
if (( e . ctrlKey || e . metaKey ) && e . key === 'k' ) {
e . preventDefault ();
this . killAllSessions ();
}
2026-01-19 00:38:11 +01:00
// Ctrl/Cmd + L - clear terminal
if (( e . ctrlKey || e . metaKey ) && e . key === 'l' ) {
2026-01-18 21:42:57 +01:00
e . preventDefault ();
this . clearTerminal ();
}
2026-01-19 00:38:11 +01:00
// Ctrl/Cmd + +/- - font size
2026-01-18 22:11:43 +01:00
if (( e . ctrlKey || e . metaKey ) && ( e . key === '=' || e . key === '+' )) {
e . preventDefault ();
this . increaseFontSize ();
}
if (( e . ctrlKey || e . metaKey ) && e . key === '-' ) {
e . preventDefault ();
this . decreaseFontSize ();
}
2026-01-19 01:42:47 +01:00
}, true ); // Use capture phase to handle before terminal
2026-01-18 07:31:19 +01:00
}
2026-01-19 00:38:11 +01:00
// ========== SSE Connection ==========
2026-01-18 07:31:19 +01:00
2026-01-18 05:14:34 +01:00
connectSSE () {
this . eventSource = new EventSource ( '/api/events' );
2026-01-19 00:38:11 +01:00
this . eventSource . onopen = () => this . setConnectionStatus ( 'connected' );
2026-01-18 05:14:34 +01:00
this . eventSource . onerror = () => {
this . setConnectionStatus ( 'disconnected' );
setTimeout (() => this . connectSSE (), 3000 );
};
this . eventSource . addEventListener ( 'init' , ( e ) => {
2026-01-19 00:38:11 +01:00
this . handleInit ( JSON . parse ( e . data ));
2026-01-18 05:14:34 +01:00
});
this . eventSource . addEventListener ( 'session:created' , ( e ) => {
const data = JSON . parse ( e . data );
this . sessions . set ( data . id , data );
2026-01-19 00:38:11 +01:00
this . renderSessionTabs ();
this . updateCost ();
2026-01-18 05:14:34 +01:00
});
this . eventSource . addEventListener ( 'session:updated' , ( e ) => {
const data = JSON . parse ( e . data );
2026-01-19 03:40:57 +01:00
const session = data . session || data ;
this . sessions . set ( session . id , session );
2026-01-19 00:38:11 +01:00
this . renderSessionTabs ();
this . updateCost ();
// Update tokens display if this is the active session
2026-01-19 03:40:57 +01:00
if ( session . id === this . activeSessionId && session . tokens ) {
this . updateRespawnTokens ( session . tokens . total );
2026-01-19 00:38:11 +01:00
}
});
this . eventSource . addEventListener ( 'session:deleted' , ( e ) => {
const data = JSON . parse ( e . data );
this . sessions . delete ( data . id );
this . terminalBuffers . delete ( data . id );
2026-01-20 17:21:47 +01:00
this . innerStates . delete ( data . id ); // Clean up inner state for this session
2026-01-19 00:38:11 +01:00
if ( this . activeSessionId === data . id ) {
this . activeSessionId = null ;
this . terminal . clear ();
this . showWelcome ();
}
this . renderSessionTabs ();
2026-01-20 17:21:47 +01:00
this . renderInnerStatePanel (); // Update inner panel after session deleted
2026-01-18 05:14:34 +01:00
});
2026-01-18 07:31:19 +01:00
this . eventSource . addEventListener ( 'session:terminal' , ( e ) => {
const data = JSON . parse ( e . data );
2026-01-19 00:38:11 +01:00
if ( data . id === this . activeSessionId ) {
this . batchTerminalWrite ( data . data );
}
2026-01-18 05:14:34 +01:00
});
2026-01-21 04:19:34 +01:00
this . eventSource . addEventListener ( 'session:clearTerminal' , async ( e ) => {
2026-01-19 07:33:33 +01:00
const data = JSON . parse ( e . data );
if ( data . id === this . activeSessionId ) {
2026-01-21 04:19:34 +01:00
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
try {
const res = await fetch ( `/api/sessions/ ${ data . id } /terminal` );
const termData = await res . json ();
this . terminal . clear ();
this . terminal . reset ();
if ( termData . terminalBuffer ) {
this . terminal . write ( termData . terminalBuffer );
}
// Send resize to ensure proper dimensions
const dims = this . fitAddon . proposeDimensions ();
if ( dims ) {
await fetch ( `/api/sessions/ ${ data . id } /resize` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ cols : dims . cols , rows : dims . rows })
});
}
} catch ( err ) {
console . error ( 'clearTerminal refresh failed:' , err );
}
2026-01-19 07:33:33 +01:00
}
});
2026-01-18 05:14:34 +01:00
this . eventSource . addEventListener ( 'session:completion' , ( e ) => {
const data = JSON . parse ( e . data );
this . totalCost += data . cost || 0 ;
2026-01-19 00:38:11 +01:00
this . updateCost ();
if ( data . id === this . activeSessionId ) {
this . terminal . writeln ( '' );
this . terminal . writeln ( `\x1b[1;32m Done (Cost: $ ${ ( data . cost || 0 ). toFixed ( 4 ) } )\x1b[0m` );
2026-01-18 07:31:19 +01:00
}
2026-01-18 05:14:34 +01:00
});
this . eventSource . addEventListener ( 'session:error' , ( e ) => {
const data = JSON . parse ( e . data );
2026-01-19 00:38:11 +01:00
if ( data . id === this . activeSessionId ) {
this . terminal . writeln ( `\x1b[1;31m Error: ${ data . error } \x1b[0m` );
}
2026-01-18 07:31:19 +01:00
});
this . eventSource . addEventListener ( 'session:exit' , ( e ) => {
const data = JSON . parse ( e . data );
2026-01-19 00:38:11 +01:00
const session = this . sessions . get ( data . id );
if ( session ) {
session . status = 'stopped' ;
this . renderSessionTabs ();
}
2026-01-18 05:14:34 +01:00
});
2026-01-19 00:38:11 +01:00
this . eventSource . addEventListener ( 'session:idle' , ( e ) => {
2026-01-18 05:14:34 +01:00
const data = JSON . parse ( e . data );
2026-01-19 11:48:02 +01:00
console . log ( '[DEBUG] session:idle event for:' , data . id );
2026-01-19 00:38:11 +01:00
const session = this . sessions . get ( data . id );
if ( session ) {
session . status = 'idle' ;
this . renderSessionTabs ();
2026-01-19 11:48:02 +01:00
this . sendPendingCtrlL ( data . id );
2026-01-19 00:38:11 +01:00
}
});
this . eventSource . addEventListener ( 'session:working' , ( e ) => {
const data = JSON . parse ( e . data );
2026-01-19 11:48:02 +01:00
console . log ( '[DEBUG] session:working event for:' , data . id );
2026-01-19 00:38:11 +01:00
const session = this . sessions . get ( data . id );
if ( session ) {
session . status = 'busy' ;
this . renderSessionTabs ();
2026-01-19 11:48:02 +01:00
this . sendPendingCtrlL ( data . id );
2026-01-19 00:38:11 +01:00
}
});
// Scheduled run events
this . eventSource . addEventListener ( 'scheduled:created' , ( e ) => {
this . currentRun = JSON . parse ( e . data );
2026-01-18 05:14:34 +01:00
this . showTimer ();
});
this . eventSource . addEventListener ( 'scheduled:updated' , ( e ) => {
2026-01-19 00:38:11 +01:00
this . currentRun = JSON . parse ( e . data );
2026-01-18 05:14:34 +01:00
this . updateTimer ();
});
this . eventSource . addEventListener ( 'scheduled:completed' , ( e ) => {
2026-01-19 00:38:11 +01:00
this . currentRun = JSON . parse ( e . data );
2026-01-18 05:14:34 +01:00
this . hideTimer ();
2026-01-19 00:38:11 +01:00
this . showToast ( 'Scheduled run completed!' , 'success' );
2026-01-18 05:14:34 +01:00
});
this . eventSource . addEventListener ( 'scheduled:stopped' , ( e ) => {
2026-01-19 00:38:11 +01:00
this . currentRun = null ;
2026-01-18 05:14:34 +01:00
this . hideTimer ();
2026-01-18 07:31:19 +01:00
});
// Respawn events
this . eventSource . addEventListener ( 'respawn:started' , ( e ) => {
const data = JSON . parse ( e . data );
this . respawnStatus [ data . sessionId ] = data . status ;
2026-01-19 00:38:11 +01:00
if ( data . sessionId === this . activeSessionId ) {
this . showRespawnBanner ();
}
2026-01-18 07:31:19 +01:00
});
this . eventSource . addEventListener ( 'respawn:stopped' , ( e ) => {
const data = JSON . parse ( e . data );
delete this . respawnStatus [ data . sessionId ];
2026-01-19 00:38:11 +01:00
if ( data . sessionId === this . activeSessionId ) {
this . hideRespawnBanner ();
}
2026-01-18 07:31:19 +01:00
});
this . eventSource . addEventListener ( 'respawn:stateChanged' , ( e ) => {
const data = JSON . parse ( e . data );
if ( this . respawnStatus [ data . sessionId ]) {
this . respawnStatus [ data . sessionId ]. state = data . state ;
}
2026-01-19 00:38:11 +01:00
if ( data . sessionId === this . activeSessionId ) {
this . updateRespawnBanner ( data . state );
}
2026-01-18 07:31:19 +01:00
});
this . eventSource . addEventListener ( 'respawn:cycleStarted' , ( e ) => {
const data = JSON . parse ( e . data );
if ( this . respawnStatus [ data . sessionId ]) {
this . respawnStatus [ data . sessionId ]. cycleCount = data . cycleNumber ;
}
2026-01-19 00:38:11 +01:00
if ( data . sessionId === this . activeSessionId ) {
document . getElementById ( 'respawnCycleCount' ). textContent = data . cycleNumber ;
}
2026-01-18 07:31:19 +01:00
});
this . eventSource . addEventListener ( 'respawn:stepSent' , ( e ) => {
const data = JSON . parse ( e . data );
2026-01-19 00:38:11 +01:00
if ( data . sessionId === this . activeSessionId ) {
document . getElementById ( 'respawnStep' ). textContent = data . input ;
}
2026-01-18 07:31:19 +01:00
});
2026-01-19 00:38:11 +01:00
// Respawn timer events
this . eventSource . addEventListener ( 'respawn:timerStarted' , ( e ) => {
2026-01-18 07:31:19 +01:00
const data = JSON . parse ( e . data );
2026-01-19 00:38:11 +01:00
this . respawnTimers [ data . sessionId ] = {
endAt : data . endAt ,
startedAt : data . startedAt ,
durationMinutes : data . durationMinutes
};
if ( data . sessionId === this . activeSessionId ) {
this . showRespawnTimer ();
}
2026-01-18 07:31:19 +01:00
});
2026-01-19 00:38:11 +01:00
// Auto-clear event
this . eventSource . addEventListener ( 'session:autoClear' , ( e ) => {
2026-01-18 07:31:19 +01:00
const data = JSON . parse ( e . data );
2026-01-19 00:38:11 +01:00
if ( data . sessionId === this . activeSessionId ) {
this . showToast ( `Auto-cleared at ${ data . tokens . toLocaleString () } tokens` , 'info' );
this . updateRespawnTokens ( 0 );
}
2026-01-18 07:31:19 +01:00
});
2026-01-19 00:38:11 +01:00
// Background task events
this . eventSource . addEventListener ( 'task:created' , ( e ) => {
2026-01-18 07:31:19 +01:00
const data = JSON . parse ( e . data );
2026-01-19 00:38:11 +01:00
this . renderSessionTabs ();
if ( data . sessionId === this . activeSessionId ) {
this . renderTaskPanel ();
}
2026-01-18 07:31:19 +01:00
});
2026-01-19 00:38:11 +01:00
this . eventSource . addEventListener ( 'task:completed' , ( e ) => {
const data = JSON . parse ( e . data );
this . renderSessionTabs ();
if ( data . sessionId === this . activeSessionId ) {
this . renderTaskPanel ();
}
});
this . eventSource . addEventListener ( 'task:failed' , ( e ) => {
const data = JSON . parse ( e . data );
this . renderSessionTabs ();
if ( data . sessionId === this . activeSessionId ) {
this . renderTaskPanel ();
}
});
this . eventSource . addEventListener ( 'task:updated' , ( e ) => {
const data = JSON . parse ( e . data );
if ( data . sessionId === this . activeSessionId ) {
this . renderTaskPanel ();
}
});
2026-01-19 02:18:51 +01:00
// Screen events
this . eventSource . addEventListener ( 'screen:created' , ( e ) => {
const screen = JSON . parse ( e . data );
this . screenSessions . push ( screen );
2026-01-19 02:48:28 +01:00
this . renderScreenSessions ();
2026-01-19 02:18:51 +01:00
});
this . eventSource . addEventListener ( 'screen:killed' , ( e ) => {
const data = JSON . parse ( e . data );
this . screenSessions = this . screenSessions . filter ( s => s . sessionId !== data . sessionId );
2026-01-19 02:48:28 +01:00
this . renderScreenSessions ();
2026-01-19 02:18:51 +01:00
});
this . eventSource . addEventListener ( 'screen:died' , ( e ) => {
const data = JSON . parse ( e . data );
this . screenSessions = this . screenSessions . filter ( s => s . sessionId !== data . sessionId );
2026-01-19 02:48:28 +01:00
this . renderScreenSessions ();
2026-01-19 02:18:51 +01:00
this . showToast ( 'Screen session died: ' + data . sessionId . slice ( 0 , 8 ), 'warning' );
});
this . eventSource . addEventListener ( 'screen:statsUpdated' , ( e ) => {
this . screenSessions = JSON . parse ( e . data );
2026-01-19 03:40:57 +01:00
if ( document . getElementById ( 'monitorPanel' ). classList . contains ( 'open' )) {
2026-01-19 02:48:28 +01:00
this . renderScreenSessions ();
2026-01-19 02:18:51 +01:00
}
});
2026-01-19 13:21:01 +01:00
// Inner loop/todo events
this . eventSource . addEventListener ( 'session:innerLoopUpdate' , ( e ) => {
const data = JSON . parse ( e . data );
this . updateInnerState ( data . sessionId , { loop : data . state });
});
this . eventSource . addEventListener ( 'session:innerTodoUpdate' , ( e ) => {
const data = JSON . parse ( e . data );
this . updateInnerState ( data . sessionId , { todos : data . todos });
});
this . eventSource . addEventListener ( 'session:innerCompletionDetected' , ( e ) => {
const data = JSON . parse ( e . data );
2026-01-19 17:22:55 +01:00
// Prevent duplicate notifications for the same completion
const completionKey = ` ${ data . sessionId } : ${ data . phrase } ` ;
if ( this . _shownCompletions ? . has ( completionKey )) {
return ;
}
if ( ! this . _shownCompletions ) {
this . _shownCompletions = new Set ();
}
this . _shownCompletions . add ( completionKey );
// Clear after 30 seconds to allow re-notification if loop restarts
setTimeout (() => this . _shownCompletions ? . delete ( completionKey ), 30000 );
2026-01-19 13:21:01 +01:00
// Update inner state to mark loop as inactive
const existing = this . innerStates . get ( data . sessionId ) || {};
if ( existing . loop ) {
existing . loop . active = false ;
this . updateInnerState ( data . sessionId , existing );
}
});
2026-01-18 05:14:34 +01:00
}
setConnectionStatus ( status ) {
2026-01-19 00:38:11 +01:00
const dot = document . querySelector ( '.status-dot' );
const text = document . querySelector ( '.status-text' );
2026-01-18 05:14:34 +01:00
dot . className = 'status-dot ' + status ;
2026-01-19 00:38:11 +01:00
text . textContent = status === 'connected' ? 'Connected' : 'Disconnected' ;
2026-01-18 05:14:34 +01:00
}
handleInit ( data ) {
this . sessions . clear ();
2026-01-19 13:21:01 +01:00
this . innerStates . clear ();
data . sessions . forEach ( s => {
this . sessions . set ( s . id , s );
// Load inner state from session data
if ( s . innerLoop || s . innerTodos ) {
this . innerStates . set ( s . id , {
loop : s . innerLoop || null ,
todos : s . innerTodos || []
});
}
});
2026-01-18 05:14:34 +01:00
2026-01-19 00:38:11 +01:00
if ( data . respawnStatus ) {
this . respawnStatus = data . respawnStatus ;
}
this . totalCost = data . sessions . reduce (( sum , s ) => sum + ( s . totalCost || 0 ), 0 );
this . totalCost += data . scheduledRuns . reduce (( sum , r ) => sum + ( r . totalCost || 0 ), 0 );
2026-01-18 05:14:34 +01:00
const activeRun = data . scheduledRuns . find ( r => r . status === 'running' );
if ( activeRun ) {
this . currentRun = activeRun ;
this . showTimer ();
}
2026-01-19 00:38:11 +01:00
this . updateCost ();
this . renderSessionTabs ();
// Auto-select first session if any
if ( this . sessions . size > 0 && ! this . activeSessionId ) {
const firstSession = this . sessions . values (). next (). value ;
this . selectSession ( firstSession . id );
2026-01-18 07:31:19 +01:00
}
2026-01-18 05:14:34 +01:00
}
async loadState () {
try {
const res = await fetch ( '/api/status' );
const data = await res . json ();
this . handleInit ( data );
} catch ( err ) {
console . error ( 'Failed to load state:' , err );
}
}
2026-01-19 00:38:11 +01:00
// ========== Session Tabs ==========
renderSessionTabs () {
2026-01-19 06:51:25 +01:00
// Debounce renders at 100ms to prevent excessive DOM updates
if ( this . renderSessionTabsTimeout ) {
clearTimeout ( this . renderSessionTabsTimeout );
}
this . renderSessionTabsTimeout = setTimeout (() => {
this . _renderSessionTabsImmediate ();
}, 100 );
}
_renderSessionTabsImmediate () {
2026-01-19 11:48:02 +01:00
const container = this . $ ( 'sessionTabs' );
2026-01-21 06:06:57 +01:00
const existingTabs = container . querySelectorAll ( '.session-tab[data-id]' );
const existingIds = new Set ([... existingTabs ]. map ( t => t . dataset . id ));
const currentIds = new Set ( this . sessions . keys ());
// Check if we can do incremental update (same session IDs)
const canIncremental = existingIds . size === currentIds . size &&
[... existingIds ]. every ( id => currentIds . has ( id ));
if ( canIncremental ) {
// Incremental update - only modify changed properties
for ( const [ id , session ] of this . sessions ) {
const tab = container . querySelector ( `.session-tab[data-id=" ${ id } "]` );
if ( ! tab ) continue ;
const isActive = id === this . activeSessionId ;
const status = session . status || 'idle' ;
const name = this . getSessionName ( session );
const taskStats = session . taskStats || { running : 0 , total : 0 };
const hasRunningTasks = taskStats . running > 0 ;
// Update active class
if ( isActive && ! tab . classList . contains ( 'active' )) {
tab . classList . add ( 'active' );
} else if ( ! isActive && tab . classList . contains ( 'active' )) {
tab . classList . remove ( 'active' );
}
// Update status indicator
const statusEl = tab . querySelector ( '.tab-status' );
if ( statusEl && ! statusEl . classList . contains ( status )) {
statusEl . className = `tab-status ${ status } ` ;
}
// Update name if changed
const nameEl = tab . querySelector ( '.tab-name' );
if ( nameEl && nameEl . textContent !== name ) {
nameEl . textContent = name ;
}
// Update task badge
const badgeEl = tab . querySelector ( '.tab-badge' );
if ( hasRunningTasks ) {
if ( badgeEl ) {
if ( badgeEl . textContent !== String ( taskStats . running )) {
badgeEl . textContent = taskStats . running ;
}
} else {
// Need to add badge - do full rebuild
this . _fullRenderSessionTabs ();
return ;
}
} else if ( badgeEl ) {
// Need to remove badge - do full rebuild
this . _fullRenderSessionTabs ();
return ;
}
}
} else {
// Full rebuild needed (sessions added/removed)
this . _fullRenderSessionTabs ();
}
// Auto-focus: if there's exactly one session and none is active, select it
if ( this . sessions . size === 1 && ! this . activeSessionId ) {
const [ sessionId ] = this . sessions . keys ();
this . selectSession ( sessionId );
}
}
_fullRenderSessionTabs () {
const container = this . $ ( 'sessionTabs' );
2026-01-19 00:38:11 +01:00
2026-01-19 11:48:02 +01:00
// Build tabs HTML using array for better string concatenation performance
const parts = [];
2026-01-19 00:38:11 +01:00
for ( const [ id , session ] of this . sessions ) {
const isActive = id === this . activeSessionId ;
const status = session . status || 'idle' ;
const name = this . getSessionName ( session );
2026-01-19 01:26:38 +01:00
const mode = session . mode || 'claude' ;
2026-01-19 00:38:11 +01:00
const taskStats = session . taskStats || { running : 0 , total : 0 };
const hasRunningTasks = taskStats . running > 0 ;
2026-01-19 11:48:02 +01:00
parts . push ( `<div class="session-tab ${ isActive ? 'active' : '' } " data-id=" ${ id } " onclick="app.selectSession(' ${ id } ')" oncontextmenu="event.preventDefault(); app.startInlineRename(' ${ id } ')">
2026-01-19 00:38:11 +01:00
<span class="tab-status ${ status } "></span>
2026-01-19 01:26:38 +01:00
${ mode === 'shell' ? '<span class="tab-mode shell">sh</span>' : '' }
2026-01-19 01:42:47 +01:00
<span class="tab-name" data-session-id=" ${ id } "> ${ this . escapeHtml ( name ) } </span>
2026-01-19 00:38:11 +01:00
${ hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()"> ${ taskStats . running } </span>` : '' }
2026-01-19 01:26:38 +01:00
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(' ${ id } ')" title="Session options">⚙</span>
2026-01-19 02:10:44 +01:00
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(' ${ id } ')">×</span>
2026-01-19 11:48:02 +01:00
</div>` );
2026-01-19 00:38:11 +01:00
}
2026-01-19 11:48:02 +01:00
container . innerHTML = parts . join ( '' );
2026-01-19 00:38:11 +01:00
}
getSessionName ( session ) {
2026-01-19 01:26:38 +01:00
// Use custom name if set
if ( session . name ) {
return session . name ;
}
// Fall back to directory name
2026-01-19 00:38:11 +01:00
if ( session . workingDir ) {
return session . workingDir . split ( '/' ). pop () || session . workingDir ;
}
return session . id . slice ( 0 , 8 );
}
async selectSession ( sessionId ) {
if ( this . activeSessionId === sessionId ) return ;
this . activeSessionId = sessionId ;
this . renderSessionTabs ();
2026-01-19 19:56:28 +01:00
// Check if this is a restored session that needs to be attached
const session = this . sessions . get ( sessionId );
if ( session && session . pid === null && session . status === 'idle' ) {
// This is a restored session - attach to the existing screen
try {
await fetch ( `/api/sessions/ ${ sessionId } /interactive` , { method : 'POST' });
// Update local session state
session . status = 'busy' ;
} catch ( err ) {
console . error ( 'Failed to attach to restored session:' , err );
}
}
2026-01-19 00:38:11 +01:00
// Load terminal buffer for this session
2026-01-18 07:31:19 +01:00
try {
2026-01-19 00:38:11 +01:00
const res = await fetch ( `/api/sessions/ ${ sessionId } /terminal` );
const data = await res . json ();
2026-01-19 07:33:33 +01:00
this . terminal . clear ();
2026-01-19 06:51:25 +01:00
this . terminal . reset ();
2026-01-19 00:38:11 +01:00
if ( data . terminalBuffer ) {
2026-01-19 07:54:30 +01:00
this . terminal . write ( data . terminalBuffer );
2026-01-19 00:38:11 +01:00
}
2026-01-19 07:54:30 +01:00
// Send resize and Ctrl+L to trigger Claude to redraw at correct size
2026-01-19 00:38:11 +01:00
const dims = this . fitAddon . proposeDimensions ();
if ( dims ) {
2026-01-19 07:35:55 +01:00
await fetch ( `/api/sessions/ ${ sessionId } /resize` , {
2026-01-19 00:38:11 +01:00
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ cols : dims . cols , rows : dims . rows })
});
}
// Update respawn banner
if ( this . respawnStatus [ sessionId ]) {
this . showRespawnBanner ();
this . updateRespawnBanner ( this . respawnStatus [ sessionId ]. state );
document . getElementById ( 'respawnCycleCount' ). textContent = this . respawnStatus [ sessionId ]. cycleCount || 0 ;
} else {
this . hideRespawnBanner ();
}
// Update task panel if open
2026-01-19 11:48:02 +01:00
const taskPanel = document . getElementById ( 'taskPanel' );
if ( taskPanel && taskPanel . classList . contains ( 'open' )) {
2026-01-19 00:38:11 +01:00
this . renderTaskPanel ();
}
2026-01-19 13:21:01 +01:00
// Update inner state panel for this session
const session = this . sessions . get ( sessionId );
if ( session && ( session . innerLoop || session . innerTodos )) {
this . updateInnerState ( sessionId , {
loop : session . innerLoop ,
todos : session . innerTodos
});
}
this . renderInnerStatePanel ();
2026-01-19 00:38:11 +01:00
this . terminal . focus ();
2026-01-18 07:31:19 +01:00
} catch ( err ) {
2026-01-19 00:38:11 +01:00
console . error ( 'Failed to load session terminal:' , err );
2026-01-18 07:31:19 +01:00
}
}
2026-01-19 04:47:48 +01:00
async closeSession ( sessionId , killScreen = true ) {
2026-01-19 00:38:11 +01:00
try {
2026-01-19 04:47:48 +01:00
await fetch ( `/api/sessions/ ${ sessionId } ?killScreen= ${ killScreen } ` , { method : 'DELETE' });
2026-01-19 00:38:11 +01:00
this . sessions . delete ( sessionId );
this . terminalBuffers . delete ( sessionId );
2026-01-19 13:21:01 +01:00
this . innerStates . delete ( sessionId );
2026-01-19 00:38:11 +01:00
if ( this . activeSessionId === sessionId ) {
this . activeSessionId = null ;
// Select another session or show welcome
if ( this . sessions . size > 0 ) {
const nextSession = this . sessions . values (). next (). value ;
this . selectSession ( nextSession . id );
} else {
this . terminal . clear ();
this . showWelcome ();
2026-01-20 17:21:47 +01:00
this . renderInnerStatePanel (); // Clear inner panel when no sessions
2026-01-19 00:38:11 +01:00
}
}
this . renderSessionTabs ();
2026-01-19 04:47:48 +01:00
if ( killScreen ) {
this . showToast ( 'Session closed and screen killed' , 'success' );
} else {
this . showToast ( 'Tab hidden, screen still running' , 'info' );
}
2026-01-19 00:38:11 +01:00
} catch ( err ) {
this . showToast ( 'Failed to close session' , 'error' );
}
}
2026-01-19 02:10:44 +01:00
// Request confirmation before closing a session
requestCloseSession ( sessionId ) {
const session = this . sessions . get ( sessionId );
if ( ! session ) return ;
this . pendingCloseSessionId = sessionId ;
// Show session name in confirmation dialog
const name = this . getSessionName ( session );
const sessionNameEl = document . getElementById ( 'closeConfirmSessionName' );
sessionNameEl . textContent = name ;
document . getElementById ( 'closeConfirmModal' ). classList . add ( 'active' );
}
cancelCloseSession () {
this . pendingCloseSessionId = null ;
document . getElementById ( 'closeConfirmModal' ). classList . remove ( 'active' );
}
2026-01-19 04:47:48 +01:00
async confirmCloseSession ( killScreen = true ) {
2026-01-19 02:10:44 +01:00
const sessionId = this . pendingCloseSessionId ;
this . cancelCloseSession ();
if ( sessionId ) {
2026-01-19 04:47:48 +01:00
await this . closeSession ( sessionId , killScreen );
2026-01-19 02:10:44 +01:00
}
}
2026-01-19 00:38:11 +01:00
nextSession () {
const ids = Array . from ( this . sessions . keys ());
if ( ids . length <= 1 ) return ;
const currentIndex = ids . indexOf ( this . activeSessionId );
const nextIndex = ( currentIndex + 1 ) % ids . length ;
this . selectSession ( ids [ nextIndex ]);
}
// ========== Quick Start ==========
2026-01-19 18:25:31 +01:00
async loadQuickStartCases ( selectCaseName = null ) {
2026-01-18 20:11:46 +01:00
try {
2026-01-21 12:52:38 +01:00
// Load settings to get lastUsedCase
let lastUsedCase = null ;
try {
const settingsRes = await fetch ( '/api/settings' );
if ( settingsRes . ok ) {
const settings = await settingsRes . json ();
lastUsedCase = settings . lastUsedCase || null ;
}
} catch {
// Ignore settings load errors
}
2026-01-19 18:25:31 +01:00
// Add cache-busting to ensure fresh data
const res = await fetch ( '/api/cases?_t=' + Date . now ());
2026-01-18 20:11:46 +01:00
const cases = await res . json ();
2026-01-19 00:38:11 +01:00
this . cases = cases ;
2026-01-21 12:52:38 +01:00
console . log ( '[loadQuickStartCases] Loaded cases:' , cases . map ( c => c . name ), 'lastUsedCase:' , lastUsedCase );
2026-01-19 00:38:11 +01:00
2026-01-18 20:11:46 +01:00
const select = document . getElementById ( 'quickStartCase' );
2026-01-19 18:25:31 +01:00
// Build options - existing cases first, then testcase as fallback if not present
let options = '' ;
const hasTestcase = cases . some ( c => c . name === 'testcase' );
2026-01-18 20:11:46 +01:00
cases . forEach ( c => {
2026-01-19 18:25:31 +01:00
options += `<option value=" ${ c . name } "> ${ c . name } </option>` ;
2026-01-18 20:11:46 +01:00
});
2026-01-19 18:25:31 +01:00
// Add testcase option if it doesn't exist (will be created on first run)
if ( ! hasTestcase ) {
options = `<option value="testcase">testcase</option>` + options ;
}
2026-01-19 01:42:47 +01:00
2026-01-19 18:25:31 +01:00
select . innerHTML = options ;
console . log ( '[loadQuickStartCases] Set options:' , select . innerHTML . substring ( 0 , 200 ));
// If a specific case was requested, select it
if ( selectCaseName ) {
select . value = selectCaseName ;
this . updateDirDisplayForCase ( selectCaseName );
2026-01-21 12:52:38 +01:00
} else if ( lastUsedCase && cases . some ( c => c . name === lastUsedCase )) {
// Use lastUsedCase if available and exists
select . value = lastUsedCase ;
this . updateDirDisplayForCase ( lastUsedCase );
2026-01-19 18:25:31 +01:00
} else if ( cases . length > 0 ) {
2026-01-21 12:52:38 +01:00
// Fallback to testcase or first case
2026-01-19 01:42:47 +01:00
const firstCase = cases . find ( c => c . name === 'testcase' ) || cases [ 0 ];
select . value = firstCase . name ;
this . updateDirDisplayForCase ( firstCase . name );
2026-01-19 18:25:31 +01:00
} else {
2026-01-21 13:07:45 +01:00
// No cases exist yet - show the default case name as directory
2026-01-19 18:25:31 +01:00
select . value = 'testcase' ;
2026-01-21 13:07:45 +01:00
document . getElementById ( 'dirDisplay' ). textContent = '~/claudeman-cases/testcase' ;
2026-01-19 01:42:47 +01:00
}
2026-01-19 18:25:31 +01:00
// Only add event listener once (on first load)
if ( ! select . dataset . listenerAdded ) {
select . addEventListener ( 'change' , () => {
this . updateDirDisplayForCase ( select . value );
});
select . dataset . listenerAdded = 'true' ;
}
2026-01-18 20:11:46 +01:00
} catch ( err ) {
2026-01-19 00:38:11 +01:00
console . error ( 'Failed to load cases:' , err );
2026-01-18 20:11:46 +01:00
}
}
2026-01-19 01:42:47 +01:00
async updateDirDisplayForCase ( caseName ) {
try {
const res = await fetch ( `/api/cases/ ${ caseName } ` );
const data = await res . json ();
if ( data . path ) {
document . getElementById ( 'dirDisplay' ). textContent = data . path ;
document . getElementById ( 'dirInput' ). value = data . path ;
}
} catch ( err ) {
document . getElementById ( 'dirDisplay' ). textContent = caseName ;
}
}
2026-01-18 20:11:46 +01:00
async quickStart () {
2026-01-19 01:26:38 +01:00
// Alias for backward compatibility
return this . runClaude ();
}
2026-01-19 03:40:57 +01:00
// Tab count stepper functions
incrementTabCount () {
const input = document . getElementById ( 'tabCount' );
const current = parseInt ( input . value ) || 1 ;
2026-01-21 04:19:34 +01:00
input . value = Math . min ( 20 , current + 1 );
2026-01-19 03:40:57 +01:00
}
decrementTabCount () {
const input = document . getElementById ( 'tabCount' );
const current = parseInt ( input . value ) || 1 ;
input . value = Math . max ( 1 , current - 1 );
}
2026-01-21 13:16:13 +01:00
// Shell count stepper functions
incrementShellCount () {
const input = document . getElementById ( 'shellCount' );
const current = parseInt ( input . value ) || 1 ;
input . value = Math . min ( 20 , current + 1 );
}
decrementShellCount () {
const input = document . getElementById ( 'shellCount' );
const current = parseInt ( input . value ) || 1 ;
input . value = Math . max ( 1 , current - 1 );
}
2026-01-19 01:26:38 +01:00
async runClaude () {
2026-01-19 00:38:11 +01:00
const caseName = document . getElementById ( 'quickStartCase' ). value || 'testcase' ;
2026-01-21 04:19:34 +01:00
const tabCount = Math . min ( 20 , Math . max ( 1 , parseInt ( document . getElementById ( 'tabCount' ). value ) || 1 ));
2026-01-18 20:11:46 +01:00
this . terminal . clear ();
2026-01-19 02:55:10 +01:00
this . terminal . writeln ( `\x1b[1;32m Starting ${ tabCount } Claude session(s) in ${ caseName } ...\x1b[0m` );
2026-01-18 20:11:46 +01:00
this . terminal . writeln ( '' );
try {
2026-01-19 02:55:10 +01:00
// Get case path first
const caseRes = await fetch ( `/api/cases/ ${ caseName } ` );
2026-01-19 18:25:31 +01:00
let caseData = await caseRes . json ();
2026-01-18 20:11:46 +01:00
2026-01-19 02:55:10 +01:00
// Create the case if it doesn't exist
if ( ! caseData . path ) {
const createCaseRes = await fetch ( '/api/cases' , {
2026-01-18 20:11:46 +01:00
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
2026-01-19 02:55:10 +01:00
body : JSON . stringify ({ name : caseName , description : '' })
2026-01-18 20:11:46 +01:00
});
2026-01-19 02:55:10 +01:00
const createCaseData = await createCaseRes . json ();
if ( ! createCaseData . success ) throw new Error ( createCaseData . error || 'Failed to create case' );
2026-01-19 18:25:31 +01:00
// Use the newly created case data (API returns { success, case: { name, path } })
caseData = createCaseData . case ;
2026-01-19 02:55:10 +01:00
}
2026-01-19 15:03:45 +01:00
const workingDir = caseData . path ;
if ( ! workingDir ) throw new Error ( 'Case path not found' );
2026-01-19 02:55:10 +01:00
let firstSessionId = null ;
2026-01-19 04:27:57 +01:00
// Find the highest existing w-number for this case to avoid duplicates
let startNumber = 1 ;
for ( const [, session ] of this . sessions ) {
const match = session . name && session . name . match ( /^w(\d+)-/ );
if ( match ) {
const num = parseInt ( match [ 1 ]);
if ( num >= startNumber ) {
startNumber = num + 1 ;
}
}
}
2026-01-19 18:56:34 +01:00
// Get global Ralph tracker setting
const ralphEnabled = this . isRalphTrackerEnabledByDefault ();
2026-01-19 04:27:57 +01:00
// Create multiple sessions with unique w-numbers
for ( let i = 0 ; i < tabCount ; i ++ ) {
const sessionName = `w ${ startNumber + i } - ${ caseName } ` ;
2026-01-19 02:55:10 +01:00
// Create session
const createRes = await fetch ( '/api/sessions' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ workingDir , name : sessionName })
});
const createData = await createRes . json ();
if ( ! createData . success ) throw new Error ( createData . error );
2026-01-19 18:56:34 +01:00
// Apply global Ralph tracker setting
if ( ralphEnabled ) {
await fetch ( `/api/sessions/ ${ createData . session . id } /inner-config` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ enabled : true })
});
}
2026-01-19 02:55:10 +01:00
// Start interactive mode
await fetch ( `/api/sessions/ ${ createData . session . id } /interactive` , {
method : 'POST'
});
// Track first session
2026-01-19 05:00:21 +01:00
if ( i === 0 ) {
2026-01-19 02:55:10 +01:00
firstSessionId = createData . session . id ;
}
this . terminal . writeln ( `\x1b[90m Created session ${ i } / ${ tabCount } : ${ sessionName } \x1b[0m` );
}
2026-01-21 04:19:34 +01:00
// Auto-switch to the new session using selectSession (does proper refresh)
2026-01-19 02:55:10 +01:00
if ( firstSessionId ) {
2026-01-21 04:19:34 +01:00
await this . selectSession ( firstSessionId );
2026-01-19 02:55:10 +01:00
this . loadQuickStartCases ();
2026-01-18 20:11:46 +01:00
}
this . terminal . focus ();
} catch ( err ) {
2026-01-19 00:38:11 +01:00
this . terminal . writeln ( `\x1b[1;31m Error: ${ err . message } \x1b[0m` );
2026-01-18 20:11:46 +01:00
}
}
2026-01-19 01:26:38 +01:00
async runShell () {
const caseName = document . getElementById ( 'quickStartCase' ). value || 'testcase' ;
2026-01-21 13:16:13 +01:00
const shellCount = Math . min ( 20 , Math . max ( 1 , parseInt ( document . getElementById ( 'shellCount' ). value ) || 1 ));
2026-01-19 01:26:38 +01:00
this . terminal . clear ();
2026-01-21 13:16:13 +01:00
this . terminal . writeln ( `\x1b[1;33m Starting ${ shellCount } Shell session(s) in ${ caseName } ...\x1b[0m` );
2026-01-19 01:26:38 +01:00
this . terminal . writeln ( '' );
try {
// Get the case path
const caseRes = await fetch ( `/api/cases/ ${ caseName } ` );
const caseData = await caseRes . json ();
2026-01-19 15:03:45 +01:00
const workingDir = caseData . path ;
if ( ! workingDir ) throw new Error ( 'Case path not found' );
2026-01-19 01:26:38 +01:00
2026-01-19 04:27:57 +01:00
// Find the highest existing s-number for shells to avoid duplicates
let startNumber = 1 ;
for ( const [, session ] of this . sessions ) {
const match = session . name && session . name . match ( /^s(\d+)-/ );
if ( match ) {
const num = parseInt ( match [ 1 ]);
if ( num >= startNumber ) {
startNumber = num + 1 ;
}
}
}
2026-01-21 13:16:13 +01:00
// Create multiple shell sessions
for ( let i = 0 ; i < shellCount ; i ++ ) {
const sessionName = `s ${ startNumber + i } - ${ caseName } ` ;
2026-01-19 01:26:38 +01:00
2026-01-21 13:16:13 +01:00
// Create session with shell mode
const createRes = await fetch ( '/api/sessions' , {
2026-01-19 01:26:38 +01:00
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
2026-01-21 13:16:13 +01:00
body : JSON . stringify ({ workingDir , mode : 'shell' , name : sessionName })
2026-01-19 01:26:38 +01:00
});
2026-01-21 13:16:13 +01:00
const createData = await createRes . json ();
if ( ! createData . success ) throw new Error ( createData . error );
const sessionId = createData . session . id ;
// Start shell
await fetch ( `/api/sessions/ ${ sessionId } /shell` , {
method : 'POST'
});
// Set active to last created
this . activeSessionId = sessionId ;
// Send resize
const dims = this . fitAddon . proposeDimensions ();
if ( dims ) {
await fetch ( `/api/sessions/ ${ sessionId } /resize` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ cols : dims . cols , rows : dims . rows })
});
}
2026-01-19 01:26:38 +01:00
}
this . terminal . focus ();
} catch ( err ) {
this . terminal . writeln ( `\x1b[1;31m Error: ${ err . message } \x1b[0m` );
}
}
2026-01-19 00:38:11 +01:00
// ========== Directory Input ==========
2026-01-18 07:31:19 +01:00
2026-01-19 00:38:11 +01:00
toggleDirInput () {
const btn = document . querySelector ( '#dirDisplay' ). parentElement ;
const input = document . getElementById ( 'dirInput' );
if ( input . classList . contains ( 'hidden' )) {
input . classList . remove ( 'hidden' );
btn . style . display = 'none' ;
input . focus ();
}
}
hideDirInput () {
const btn = document . querySelector ( '#dirDisplay' ). parentElement ;
const input = document . getElementById ( 'dirInput' );
setTimeout (() => {
input . classList . add ( 'hidden' );
btn . style . display = '' ;
const value = input . value . trim ();
document . getElementById ( 'dirDisplay' ). textContent = value || 'No directory' ;
}, 100 );
}
2026-01-19 03:40:57 +01:00
// ========== Respawn Banner ==========
2026-01-18 07:31:19 +01:00
showRespawnBanner () {
2026-01-19 11:48:02 +01:00
this . $ ( 'respawnBanner' ). style . display = 'flex' ;
2026-01-19 00:38:11 +01:00
// Also show timer if there's a timed respawn
if ( this . activeSessionId && this . respawnTimers [ this . activeSessionId ]) {
this . showRespawnTimer ();
}
// Show tokens if session has token data
const session = this . sessions . get ( this . activeSessionId );
if ( session && session . tokens ) {
this . updateRespawnTokens ( session . tokens . total );
}
2026-01-18 07:31:19 +01:00
}
hideRespawnBanner () {
2026-01-19 11:48:02 +01:00
this . $ ( 'respawnBanner' ). style . display = 'none' ;
2026-01-19 00:38:11 +01:00
this . hideRespawnTimer ();
2026-01-18 07:31:19 +01:00
}
updateRespawnBanner ( state ) {
2026-01-19 11:48:02 +01:00
this . $ ( 'respawnState' ). textContent = state . replace ( /_/g , ' ' );
2026-01-19 00:38:11 +01:00
}
2026-01-18 07:31:19 +01:00
2026-01-19 00:38:11 +01:00
showRespawnTimer () {
2026-01-19 11:48:02 +01:00
const timerEl = this . $ ( 'respawnTimer' );
2026-01-19 00:38:11 +01:00
timerEl . style . display = '' ;
this . updateRespawnTimer ();
// Update every second
if ( this . respawnTimerInterval ) clearInterval ( this . respawnTimerInterval );
this . respawnTimerInterval = setInterval (() => this . updateRespawnTimer (), 1000 );
}
hideRespawnTimer () {
2026-01-19 11:48:02 +01:00
this . $ ( 'respawnTimer' ). style . display = 'none' ;
2026-01-19 00:38:11 +01:00
if ( this . respawnTimerInterval ) {
clearInterval ( this . respawnTimerInterval );
this . respawnTimerInterval = null ;
2026-01-18 07:31:19 +01:00
}
}
2026-01-19 00:38:11 +01:00
updateRespawnTimer () {
if ( ! this . activeSessionId || ! this . respawnTimers [ this . activeSessionId ]) {
this . hideRespawnTimer ();
return ;
}
2026-01-18 07:31:19 +01:00
2026-01-19 00:38:11 +01:00
const timer = this . respawnTimers [ this . activeSessionId ];
const now = Date . now ();
const remaining = Math . max ( 0 , timer . endAt - now );
2026-01-18 07:31:19 +01:00
2026-01-19 00:38:11 +01:00
if ( remaining <= 0 ) {
2026-01-19 11:48:02 +01:00
this . $ ( 'respawnTimer' ). textContent = 'Time up' ;
2026-01-19 00:38:11 +01:00
delete this . respawnTimers [ this . activeSessionId ];
this . hideRespawnTimer ();
return ;
}
2026-01-19 11:48:02 +01:00
this . $ ( 'respawnTimer' ). textContent = this . formatTime ( remaining );
2026-01-19 00:38:11 +01:00
}
updateRespawnTokens ( totalTokens ) {
2026-01-19 11:48:02 +01:00
const tokensEl = this . $ ( 'respawnTokens' );
2026-01-19 00:38:11 +01:00
if ( totalTokens > 0 ) {
tokensEl . style . display = '' ;
tokensEl . textContent = ` ${ ( totalTokens / 1000 ). toFixed ( 1 ) } k tokens` ;
} else {
tokensEl . style . display = 'none' ;
}
2026-01-18 07:31:19 +01:00
}
async stopRespawn () {
if ( ! this . activeSessionId ) return ;
2026-01-19 00:38:11 +01:00
try {
await fetch ( `/api/sessions/ ${ this . activeSessionId } /respawn/stop` , { method : 'POST' });
delete this . respawnTimers [ this . activeSessionId ];
} catch ( err ) {
this . showToast ( 'Failed to stop respawn' , 'error' );
}
}
// ========== Kill Sessions ==========
async killActiveSession () {
if ( ! this . activeSessionId ) {
this . showToast ( 'No active session' , 'warning' );
return ;
}
await this . closeSession ( this . activeSessionId );
}
async killAllSessions () {
if ( this . sessions . size === 0 ) return ;
if ( ! confirm ( `Kill all ${ this . sessions . size } session(s)?` )) return ;
2026-01-18 07:31:19 +01:00
try {
2026-01-19 00:38:11 +01:00
await fetch ( '/api/sessions' , { method : 'DELETE' });
this . sessions . clear ();
this . terminalBuffers . clear ();
this . activeSessionId = null ;
this . respawnStatus = {};
this . hideRespawnBanner ();
this . renderSessionTabs ();
this . terminal . clear ();
this . showWelcome ();
this . showToast ( 'All sessions killed' , 'success' );
2026-01-18 07:31:19 +01:00
} catch ( err ) {
2026-01-19 00:38:11 +01:00
this . showToast ( 'Failed to kill sessions' , 'error' );
2026-01-18 07:31:19 +01:00
}
}
2026-01-19 00:38:11 +01:00
// ========== Terminal Controls ==========
2026-01-18 07:31:19 +01:00
clearTerminal () {
this . terminal . clear ();
2026-01-18 05:14:34 +01:00
}
2026-01-19 11:48:02 +01:00
// Send Ctrl+L to fix display for newly created sessions once Claude is running
sendPendingCtrlL ( sessionId ) {
console . log ( '[DEBUG] sendPendingCtrlL called for:' , sessionId , 'pending:' , this . pendingCtrlL ? [... this . pendingCtrlL ] : 'none' );
if ( ! this . pendingCtrlL || ! this . pendingCtrlL . has ( sessionId )) {
console . log ( '[DEBUG] No pending Ctrl+L for this session' );
return ;
}
this . pendingCtrlL . delete ( sessionId );
// Only send if this is the active session
if ( sessionId !== this . activeSessionId ) {
console . log ( '[DEBUG] Not active session, skipping Ctrl+L' );
return ;
}
console . log ( '[DEBUG] Sending resize + Ctrl+L for session:' , sessionId );
// Send resize + Ctrl+L to fix the display
const dims = this . fitAddon . proposeDimensions ();
if ( dims ) {
fetch ( `/api/sessions/ ${ sessionId } /resize` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ cols : dims . cols , rows : dims . rows })
}). then (() => {
console . log ( '[DEBUG] Resize sent, now sending Ctrl+L' );
fetch ( `/api/sessions/ ${ sessionId } /input` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ input : '\x0c' })
});
});
}
}
2026-01-18 22:18:17 +01:00
async copyTerminal () {
try {
const buffer = this . terminal . buffer . active ;
let text = '' ;
for ( let i = 0 ; i < buffer . length ; i ++ ) {
const line = buffer . getLine ( i );
2026-01-19 00:38:11 +01:00
if ( line ) text += line . translateToString ( true ) + '\n' ;
2026-01-18 22:18:17 +01:00
}
2026-01-19 00:38:11 +01:00
await navigator . clipboard . writeText ( text . replace ( /\n+$/ , '\n' ));
this . showToast ( 'Copied to clipboard' , 'success' );
2026-01-18 22:18:17 +01:00
} catch ( err ) {
2026-01-19 00:38:11 +01:00
this . showToast ( 'Failed to copy' , 'error' );
2026-01-18 22:18:17 +01:00
}
}
2026-01-18 22:11:43 +01:00
increaseFontSize () {
const current = this . terminal . options . fontSize || 14 ;
2026-01-19 00:38:11 +01:00
this . setFontSize ( Math . min ( current + 2 , 24 ));
2026-01-18 22:11:43 +01:00
}
decreaseFontSize () {
const current = this . terminal . options . fontSize || 14 ;
2026-01-19 00:38:11 +01:00
this . setFontSize ( Math . max ( current - 2 , 10 ));
2026-01-18 22:11:43 +01:00
}
setFontSize ( size ) {
this . terminal . options . fontSize = size ;
2026-01-19 00:38:11 +01:00
document . getElementById ( 'fontSizeDisplay' ). textContent = size ;
2026-01-18 22:11:43 +01:00
this . fitAddon . fit ();
localStorage . setItem ( 'claudeman-font-size' , size );
}
loadFontSize () {
const saved = localStorage . getItem ( 'claudeman-font-size' );
if ( saved ) {
const size = parseInt ( saved , 10 );
if ( size >= 10 && size <= 24 ) {
this . terminal . options . fontSize = size ;
2026-01-19 00:38:11 +01:00
document . getElementById ( 'fontSizeDisplay' ). textContent = size ;
2026-01-18 22:11:43 +01:00
}
}
}
2026-01-19 00:38:11 +01:00
// ========== Timer ==========
2026-01-18 05:14:34 +01:00
showTimer () {
2026-01-19 00:38:11 +01:00
document . getElementById ( 'timerBanner' ). style . display = 'flex' ;
2026-01-18 05:14:34 +01:00
this . updateTimer ();
2026-01-19 00:38:11 +01:00
this . timerInterval = setInterval (() => this . updateTimer (), 1000 );
2026-01-18 05:14:34 +01:00
}
hideTimer () {
document . getElementById ( 'timerBanner' ). style . display = 'none' ;
2026-01-19 00:38:11 +01:00
if ( this . timerInterval ) {
clearInterval ( this . timerInterval );
this . timerInterval = null ;
}
2026-01-18 05:14:34 +01:00
}
updateTimer () {
2026-01-19 00:38:11 +01:00
if ( ! this . currentRun || this . currentRun . status !== 'running' ) return ;
2026-01-18 05:14:34 +01:00
const now = Date . now ();
const remaining = Math . max ( 0 , this . currentRun . endAt - now );
const total = this . currentRun . endAt - this . currentRun . startedAt ;
const elapsed = now - this . currentRun . startedAt ;
const percent = Math . min ( 100 , ( elapsed / total ) * 100 );
document . getElementById ( 'timerValue' ). textContent = this . formatTime ( remaining );
document . getElementById ( 'timerProgress' ). style . width = ` ${ percent } %` ;
2026-01-19 00:38:11 +01:00
document . getElementById ( 'timerMeta' ). textContent =
` ${ this . currentRun . completedTasks } tasks | $ ${ this . currentRun . totalCost . toFixed ( 2 ) } ` ;
2026-01-18 22:04:02 +01:00
}
2026-01-19 00:38:11 +01:00
async stopCurrentRun () {
if ( ! this . currentRun ) return ;
try {
await fetch ( `/api/scheduled/ ${ this . currentRun . id } ` , { method : 'DELETE' });
} catch ( err ) {
this . showToast ( 'Failed to stop run' , 'error' );
2026-01-18 22:04:02 +01:00
}
2026-01-18 05:14:34 +01:00
}
formatTime ( ms ) {
2026-01-19 00:38:11 +01:00
const s = Math . floor ( ms / 1000 );
const h = Math . floor ( s / 3600 );
const m = Math . floor (( s % 3600 ) / 60 );
const sec = s % 60 ;
return ` ${ h . toString (). padStart ( 2 , '0' ) } : ${ m . toString (). padStart ( 2 , '0' ) } : ${ sec . toString (). padStart ( 2 , '0' ) } ` ;
2026-01-18 05:14:34 +01:00
}
2026-01-19 01:26:38 +01:00
// ========== Tokens ==========
2026-01-18 22:29:23 +01:00
2026-01-19 00:38:11 +01:00
updateCost () {
2026-01-19 01:26:38 +01:00
// Now updates tokens instead of cost
this . updateTokens ();
}
updateTokens () {
let total = 0 ;
this . sessions . forEach ( s => {
if ( s . tokens ) {
total += s . tokens . total || 0 ;
}
});
this . totalTokens = total ;
const display = total >= 1000 ? ` ${ ( total / 1000 ). toFixed ( 1 ) } k` : total ;
2026-01-19 11:48:02 +01:00
this . $ ( 'headerTokens' ). textContent = ` ${ display } tokens` ;
2026-01-19 01:26:38 +01:00
}
// ========== Session Options Modal ==========
openSessionOptions ( sessionId ) {
const session = this . sessions . get ( sessionId );
if ( ! session ) return ;
this . editingSessionId = sessionId ;
2026-01-19 18:25:31 +01:00
// Reset to Respawn tab
this . switchOptionsTab ( 'respawn' );
2026-01-19 03:40:57 +01:00
// Update respawn status display and buttons
2026-01-19 02:06:37 +01:00
const respawnStatus = document . getElementById ( 'sessionRespawnStatus' );
2026-01-19 03:40:57 +01:00
const enableBtn = document . getElementById ( 'modalEnableRespawnBtn' );
const stopBtn = document . getElementById ( 'modalStopRespawnBtn' );
2026-01-19 02:06:37 +01:00
if ( this . respawnStatus [ sessionId ]) {
respawnStatus . classList . add ( 'active' );
respawnStatus . querySelector ( '.respawn-status-text' ). textContent =
2026-01-19 03:40:57 +01:00
this . respawnStatus [ sessionId ]. state || 'Active' ;
enableBtn . style . display = 'none' ;
2026-01-19 02:06:37 +01:00
stopBtn . style . display = '' ;
} else {
respawnStatus . classList . remove ( 'active' );
respawnStatus . querySelector ( '.respawn-status-text' ). textContent = 'Not active' ;
2026-01-19 03:40:57 +01:00
enableBtn . style . display = '' ;
2026-01-19 02:06:37 +01:00
stopBtn . style . display = 'none' ;
}
// Only show respawn section for claude mode sessions with a running process
const respawnSection = document . getElementById ( 'sessionRespawnSection' );
if ( session . mode === 'claude' && session . pid ) {
respawnSection . style . display = '' ;
} else {
respawnSection . style . display = 'none' ;
}
2026-01-19 14:37:20 +01:00
// Reset duration presets to default (unlimited)
this . selectDurationPreset ( '' );
2026-01-19 18:25:31 +01:00
// Populate Ralph Wiggum form with current session values
const innerState = this . innerStates . get ( sessionId );
this . populateRalphForm ({
2026-01-19 18:56:34 +01:00
enabled : innerState ? . loop ? . enabled ?? session . innerConfig ? . enabled ?? false ,
2026-01-19 18:25:31 +01:00
completionPhrase : innerState ? . loop ? . completionPhrase || session . innerLoop ? . completionPhrase || '' ,
maxIterations : innerState ? . loop ? . maxIterations || session . innerLoop ? . maxIterations || 0 ,
maxTodos : session . innerConfig ? . maxTodos || 50 ,
todoExpirationMinutes : session . innerConfig ? . todoExpirationMinutes || 60
});
2026-01-19 01:26:38 +01:00
document . getElementById ( 'sessionOptionsModal' ). classList . add ( 'active' );
}
2026-01-19 14:37:20 +01:00
// Handle duration preset selection
selectDurationPreset ( value ) {
// Remove active from all buttons
document . querySelectorAll ( '.duration-preset-btn' ). forEach ( btn => btn . classList . remove ( 'active' ));
// Find and activate the clicked button
const btn = document . querySelector ( `.duration-preset-btn[data-minutes=" ${ value } "]` );
if ( btn ) btn . classList . add ( 'active' );
// Show/hide custom input
const customInput = document . querySelector ( '.duration-custom-input' );
const durationInput = document . getElementById ( 'modalRespawnDuration' );
if ( value === 'custom' ) {
customInput . classList . add ( 'visible' );
durationInput . focus ();
} else {
customInput . classList . remove ( 'visible' );
durationInput . value = '' ; // Clear custom value when using preset
}
}
// Get selected duration from preset buttons or custom input
getSelectedDuration () {
const customInput = document . querySelector ( '.duration-custom-input' );
const durationInput = document . getElementById ( 'modalRespawnDuration' );
if ( customInput . classList . contains ( 'visible' )) {
// Custom mode - use input value
return durationInput . value ? parseInt ( durationInput . value ) : null ;
} else {
// Preset mode - get from active button
const activeBtn = document . querySelector ( '.duration-preset-btn.active' );
const minutes = activeBtn ? . dataset . minutes ;
return minutes ? parseInt ( minutes ) : null ;
}
}
2026-01-19 03:40:57 +01:00
// Get respawn config from modal inputs
getModalRespawnConfig () {
const updatePrompt = document . getElementById ( 'modalRespawnPrompt' ). value ;
const sendClear = document . getElementById ( 'modalRespawnSendClear' ). checked ;
const sendInit = document . getElementById ( 'modalRespawnSendInit' ). checked ;
2026-01-19 13:08:53 +01:00
const kickstartPrompt = document . getElementById ( 'modalRespawnKickstart' ). value . trim () || undefined ;
2026-01-19 14:37:20 +01:00
const durationMinutes = this . getSelectedDuration ();
2026-01-19 13:08:53 +01:00
// Auto-compact settings
const autoCompactEnabled = document . getElementById ( 'modalAutoCompactEnabled' ). checked ;
const autoCompactThreshold = parseInt ( document . getElementById ( 'modalAutoCompactThreshold' ). value ) || 110000 ;
const autoCompactPrompt = document . getElementById ( 'modalAutoCompactPrompt' ). value . trim () || undefined ;
// Auto-clear settings
2026-01-19 03:40:57 +01:00
const autoClearEnabled = document . getElementById ( 'modalAutoClearEnabled' ). checked ;
2026-01-19 13:08:53 +01:00
const autoClearThreshold = parseInt ( document . getElementById ( 'modalAutoClearThreshold' ). value ) || 140000 ;
2026-01-19 03:40:57 +01:00
return {
respawnConfig : {
updatePrompt ,
sendClear ,
sendInit ,
2026-01-19 13:08:53 +01:00
kickstartPrompt ,
2026-01-19 03:40:57 +01:00
},
durationMinutes ,
2026-01-19 13:08:53 +01:00
autoCompactEnabled ,
autoCompactThreshold ,
autoCompactPrompt ,
2026-01-19 03:40:57 +01:00
autoClearEnabled ,
autoClearThreshold
};
}
async enableRespawnFromModal () {
if ( ! this . editingSessionId ) {
this . showToast ( 'No session selected' , 'warning' );
return ;
}
2026-01-19 13:08:53 +01:00
const {
respawnConfig ,
durationMinutes ,
autoCompactEnabled ,
autoCompactThreshold ,
autoCompactPrompt ,
autoClearEnabled ,
autoClearThreshold
} = this . getModalRespawnConfig ();
2026-01-19 03:40:57 +01:00
try {
// Enable respawn on the session
const res = await fetch ( `/api/sessions/ ${ this . editingSessionId } /respawn/enable` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ config : respawnConfig , durationMinutes })
});
const data = await res . json ();
if ( data . error ) throw new Error ( data . error );
2026-01-19 13:08:53 +01:00
// Set auto-compact if enabled
if ( autoCompactEnabled ) {
await fetch ( `/api/sessions/ ${ this . editingSessionId } /auto-compact` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ enabled : true , threshold : autoCompactThreshold , prompt : autoCompactPrompt })
});
}
2026-01-19 03:40:57 +01:00
// Set auto-clear if enabled
if ( autoClearEnabled ) {
await fetch ( `/api/sessions/ ${ this . editingSessionId } /auto-clear` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ enabled : true , threshold : autoClearThreshold })
});
}
// Update UI
const respawnStatus = document . getElementById ( 'sessionRespawnStatus' );
respawnStatus . classList . add ( 'active' );
respawnStatus . querySelector ( '.respawn-status-text' ). textContent = 'WATCHING' ;
document . getElementById ( 'modalEnableRespawnBtn' ). style . display = 'none' ;
document . getElementById ( 'modalStopRespawnBtn' ). style . display = '' ;
this . showToast ( 'Respawn enabled' , 'success' );
} catch ( err ) {
this . showToast ( 'Failed to enable respawn: ' + err . message , 'error' );
}
}
async stopRespawnFromModal () {
2026-01-19 02:06:37 +01:00
if ( ! this . editingSessionId ) return ;
try {
await fetch ( `/api/sessions/ ${ this . editingSessionId } /respawn/stop` , { method : 'POST' });
delete this . respawnTimers [ this . editingSessionId ];
// Update the modal display
const respawnStatus = document . getElementById ( 'sessionRespawnStatus' );
respawnStatus . classList . remove ( 'active' );
respawnStatus . querySelector ( '.respawn-status-text' ). textContent = 'Not active' ;
2026-01-19 03:40:57 +01:00
document . getElementById ( 'modalEnableRespawnBtn' ). style . display = '' ;
document . getElementById ( 'modalStopRespawnBtn' ). style . display = 'none' ;
2026-01-19 02:06:37 +01:00
this . showToast ( 'Respawn stopped' , 'success' );
} catch ( err ) {
this . showToast ( 'Failed to stop respawn' , 'error' );
}
}
2026-01-19 01:26:38 +01:00
closeSessionOptions () {
this . editingSessionId = null ;
document . getElementById ( 'sessionOptionsModal' ). classList . remove ( 'active' );
}
2026-01-19 16:39:45 +01:00
saveSessionOptions () {
// Session options are applied immediately via individual controls
// This just closes the modal
2026-01-19 15:06:41 +01:00
this . closeSessionOptions ();
2026-01-18 05:14:34 +01:00
}
2026-01-19 18:25:31 +01:00
// ========== Session Options Modal Tabs ==========
switchOptionsTab ( tabName ) {
// Toggle active class on tab buttons
document . querySelectorAll ( '.modal-tab-btn' ). forEach ( btn => {
btn . classList . toggle ( 'active' , btn . dataset . tab === tabName );
});
// Toggle hidden class on tab content
document . getElementById ( 'respawn-tab' ). classList . toggle ( 'hidden' , tabName !== 'respawn' );
document . getElementById ( 'ralph-tab' ). classList . toggle ( 'hidden' , tabName !== 'ralph' );
}
getRalphConfig () {
return {
2026-01-19 18:56:34 +01:00
enabled : document . getElementById ( 'modalRalphEnabled' ). checked ,
2026-01-19 18:25:31 +01:00
completionPhrase : document . getElementById ( 'modalRalphPhrase' ). value . trim (),
maxIterations : parseInt ( document . getElementById ( 'modalRalphMaxIterations' ). value ) || 0 ,
maxTodos : parseInt ( document . getElementById ( 'modalRalphMaxTodos' ). value ) || 50 ,
todoExpirationMinutes : parseInt ( document . getElementById ( 'modalRalphTodoExpiration' ). value ) || 60
};
}
populateRalphForm ( config ) {
2026-01-19 18:56:34 +01:00
document . getElementById ( 'modalRalphEnabled' ). checked = config ? . enabled ?? false ;
2026-01-19 18:25:31 +01:00
document . getElementById ( 'modalRalphPhrase' ). value = config ? . completionPhrase || '' ;
document . getElementById ( 'modalRalphMaxIterations' ). value = config ? . maxIterations || 0 ;
document . getElementById ( 'modalRalphMaxTodos' ). value = config ? . maxTodos || 50 ;
document . getElementById ( 'modalRalphTodoExpiration' ). value = config ? . todoExpirationMinutes || 60 ;
}
async saveRalphConfig () {
if ( ! this . editingSessionId ) {
this . showToast ( 'No session selected' , 'warning' );
return ;
}
const config = this . getRalphConfig ();
try {
const res = await fetch ( `/api/sessions/ ${ this . editingSessionId } /inner-config` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ( config )
});
const data = await res . json ();
if ( data . error ) throw new Error ( data . error );
this . showToast ( 'Ralph config saved' , 'success' );
} catch ( err ) {
this . showToast ( 'Failed to save Ralph config: ' + err . message , 'error' );
}
}
2026-01-19 01:42:47 +01:00
// Inline rename on right-click
startInlineRename ( sessionId ) {
const session = this . sessions . get ( sessionId );
if ( ! session ) return ;
const tabName = document . querySelector ( `.tab-name[data-session-id=" ${ sessionId } "]` );
if ( ! tabName ) return ;
const currentName = this . getSessionName ( session );
const input = document . createElement ( 'input' );
input . type = 'text' ;
input . value = session . name || '' ;
input . placeholder = currentName ;
input . className = 'tab-rename-input' ;
input . style . cssText = 'width: 80px; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;' ;
const originalContent = tabName . textContent ;
tabName . textContent = '' ;
tabName . appendChild ( input );
input . focus ();
input . select ();
const finishRename = async () => {
const newName = input . value . trim ();
tabName . textContent = newName || originalContent ;
if ( newName && newName !== session . name ) {
try {
await fetch ( `/api/sessions/ ${ sessionId } /name` , {
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ name : newName })
});
} catch ( err ) {
tabName . textContent = originalContent ;
this . showToast ( 'Failed to rename' , 'error' );
}
}
};
input . addEventListener ( 'blur' , finishRename );
input . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Enter' ) {
e . preventDefault ();
input . blur ();
} else if ( e . key === 'Escape' ) {
input . value = '' ;
input . blur ();
}
});
}
2026-01-19 02:08:58 +01:00
// ========== App Settings Modal ==========
openAppSettings () {
// Load current settings
const settings = this . loadAppSettingsFromStorage ();
document . getElementById ( 'appSettingsClaudeMdPath' ). value = settings . defaultClaudeMdPath || '' ;
document . getElementById ( 'appSettingsDefaultDir' ). value = settings . defaultWorkingDir || '' ;
2026-01-19 18:56:34 +01:00
document . getElementById ( 'appSettingsRalphEnabled' ). checked = settings . ralphTrackerEnabled ?? false ;
2026-01-19 19:22:28 +01:00
// Header visibility settings (default to true/enabled)
document . getElementById ( 'appSettingsShowFontControls' ). checked = settings . showFontControls ?? true ;
document . getElementById ( 'appSettingsShowSystemStats' ). checked = settings . showSystemStats ?? true ;
document . getElementById ( 'appSettingsShowTokenCount' ). checked = settings . showTokenCount ?? true ;
2026-01-20 17:54:59 +01:00
document . getElementById ( 'appSettingsShowMonitor' ). checked = settings . showMonitor ?? true ;
2026-01-21 04:19:34 +01:00
// Claude CLI settings
const claudeModeSelect = document . getElementById ( 'appSettingsClaudeMode' );
const allowedToolsRow = document . getElementById ( 'allowedToolsRow' );
claudeModeSelect . value = settings . claudeMode || 'dangerously-skip-permissions' ;
document . getElementById ( 'appSettingsAllowedTools' ). value = settings . allowedTools || '' ;
allowedToolsRow . style . display = claudeModeSelect . value === 'allowedTools' ? '' : 'none' ;
// Toggle allowed tools row visibility based on mode selection
claudeModeSelect . onchange = () => {
allowedToolsRow . style . display = claudeModeSelect . value === 'allowedTools' ? '' : 'none' ;
};
2026-01-21 13:07:45 +01:00
// Reset to first tab and wire up tab switching
this . switchSettingsTab ( 'settings-display' );
const modal = document . getElementById ( 'appSettingsModal' );
modal . querySelectorAll ( '.modal-tabs .modal-tab-btn' ). forEach ( btn => {
btn . onclick = () => this . switchSettingsTab ( btn . dataset . tab );
});
modal . classList . add ( 'active' );
}
switchSettingsTab ( tabName ) {
const modal = document . getElementById ( 'appSettingsModal' );
// Toggle active class on tab buttons
modal . querySelectorAll ( '.modal-tabs .modal-tab-btn' ). forEach ( btn => {
btn . classList . toggle ( 'active' , btn . dataset . tab === tabName );
});
// Toggle hidden class on tab content
modal . querySelectorAll ( '.modal-tab-content' ). forEach ( content => {
content . classList . toggle ( 'hidden' , content . id !== tabName );
});
2026-01-19 02:08:58 +01:00
}
closeAppSettings () {
document . getElementById ( 'appSettingsModal' ). classList . remove ( 'active' );
}
async saveAppSettings () {
const settings = {
defaultClaudeMdPath : document . getElementById ( 'appSettingsClaudeMdPath' ). value . trim (),
defaultWorkingDir : document . getElementById ( 'appSettingsDefaultDir' ). value . trim (),
2026-01-19 18:56:34 +01:00
ralphTrackerEnabled : document . getElementById ( 'appSettingsRalphEnabled' ). checked ,
2026-01-19 19:22:28 +01:00
// Header visibility settings
showFontControls : document . getElementById ( 'appSettingsShowFontControls' ). checked ,
showSystemStats : document . getElementById ( 'appSettingsShowSystemStats' ). checked ,
showTokenCount : document . getElementById ( 'appSettingsShowTokenCount' ). checked ,
2026-01-20 17:54:59 +01:00
showMonitor : document . getElementById ( 'appSettingsShowMonitor' ). checked ,
2026-01-21 04:19:34 +01:00
// Claude CLI settings
claudeMode : document . getElementById ( 'appSettingsClaudeMode' ). value ,
allowedTools : document . getElementById ( 'appSettingsAllowedTools' ). value . trim (),
2026-01-19 02:08:58 +01:00
};
// Save to localStorage
localStorage . setItem ( 'claudeman-app-settings' , JSON . stringify ( settings ));
2026-01-19 19:22:28 +01:00
// Apply header visibility immediately
this . applyHeaderVisibilitySettings ();
2026-01-20 17:54:59 +01:00
this . applyMonitorVisibility ();
2026-01-19 19:22:28 +01:00
2026-01-19 02:08:58 +01:00
// Also save to server
try {
await fetch ( '/api/settings' , {
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ( settings )
});
this . showToast ( 'Settings saved' , 'success' );
} catch ( err ) {
// Server save failed but localStorage succeeded
this . showToast ( 'Settings saved locally' , 'warning' );
}
this . closeAppSettings ();
}
2026-01-19 18:56:34 +01:00
// Get the global Ralph tracker enabled setting
isRalphTrackerEnabledByDefault () {
const settings = this . loadAppSettingsFromStorage ();
return settings . ralphTrackerEnabled ?? false ;
}
2026-01-19 02:08:58 +01:00
loadAppSettingsFromStorage () {
try {
const saved = localStorage . getItem ( 'claudeman-app-settings' );
if ( saved ) {
return JSON . parse ( saved );
}
} catch ( err ) {
console . error ( 'Failed to load app settings:' , err );
}
return {};
}
2026-01-19 19:22:28 +01:00
applyHeaderVisibilitySettings () {
const settings = this . loadAppSettingsFromStorage ();
// Default all to true (enabled) if not set
const showFontControls = settings . showFontControls ?? true ;
const showSystemStats = settings . showSystemStats ?? true ;
const showTokenCount = settings . showTokenCount ?? true ;
const fontControlsEl = document . querySelector ( '.header-font-controls' );
const systemStatsEl = document . getElementById ( 'headerSystemStats' );
const tokenCountEl = document . getElementById ( 'headerTokens' );
if ( fontControlsEl ) {
fontControlsEl . style . display = showFontControls ? '' : 'none' ;
}
if ( systemStatsEl ) {
systemStatsEl . style . display = showSystemStats ? '' : 'none' ;
}
if ( tokenCountEl ) {
tokenCountEl . style . display = showTokenCount ? '' : 'none' ;
}
}
2026-01-20 17:54:59 +01:00
applyMonitorVisibility () {
const settings = this . loadAppSettingsFromStorage ();
const showMonitor = settings . showMonitor ?? true ;
const monitorPanel = document . getElementById ( 'monitorPanel' );
if ( monitorPanel ) {
monitorPanel . style . display = showMonitor ? '' : 'none' ;
}
}
closeMonitor () {
// Hide the monitor panel
const monitorPanel = document . getElementById ( 'monitorPanel' );
if ( monitorPanel ) {
monitorPanel . classList . remove ( 'open' );
monitorPanel . style . display = 'none' ;
}
// Save the setting
const settings = this . loadAppSettingsFromStorage ();
settings . showMonitor = false ;
localStorage . setItem ( 'claudeman-app-settings' , JSON . stringify ( settings ));
}
2026-01-19 02:08:58 +01:00
async loadAppSettingsFromServer () {
try {
const res = await fetch ( '/api/settings' );
if ( res . ok ) {
const settings = await res . json ();
// Merge with localStorage (server takes precedence)
const localSettings = this . loadAppSettingsFromStorage ();
const merged = { ... localSettings , ... settings };
localStorage . setItem ( 'claudeman-app-settings' , JSON . stringify ( merged ));
return merged ;
}
} catch ( err ) {
console . error ( 'Failed to load settings from server:' , err );
}
return this . loadAppSettingsFromStorage ();
}
2026-01-19 00:38:11 +01:00
// ========== Help Modal ==========
showHelp () {
document . getElementById ( 'helpModal' ). classList . add ( 'active' );
2026-01-18 05:14:34 +01:00
}
2026-01-19 00:38:11 +01:00
closeHelp () {
document . getElementById ( 'helpModal' ). classList . remove ( 'active' );
}
2026-01-18 05:14:34 +01:00
2026-01-19 00:38:11 +01:00
closeAllPanels () {
2026-01-19 01:26:38 +01:00
this . closeSessionOptions ();
2026-01-19 02:08:58 +01:00
this . closeAppSettings ();
2026-01-19 02:10:44 +01:00
this . cancelCloseSession ();
2026-01-19 02:48:28 +01:00
document . getElementById ( 'monitorPanel' ). classList . remove ( 'open' );
2026-01-19 00:38:11 +01:00
}
2026-01-18 05:14:34 +01:00
2026-01-19 02:48:28 +01:00
// ========== Monitor Panel (combined Screen Sessions + Background Tasks) ==========
2026-01-19 00:38:11 +01:00
2026-01-19 02:48:28 +01:00
async toggleMonitorPanel () {
const panel = document . getElementById ( 'monitorPanel' );
2026-01-19 04:22:41 +01:00
const toggleBtn = document . getElementById ( 'monitorToggleBtn' );
2026-01-19 00:38:11 +01:00
panel . classList . toggle ( 'open' );
2026-01-19 02:48:28 +01:00
2026-01-19 00:38:11 +01:00
if ( panel . classList . contains ( 'open' )) {
2026-01-19 02:48:28 +01:00
// Load screens and start stats collection
await this . loadScreens ();
await fetch ( '/api/screens/stats/start' , { method : 'POST' });
2026-01-19 00:38:11 +01:00
this . renderTaskPanel ();
2026-01-19 04:22:41 +01:00
if ( toggleBtn ) toggleBtn . innerHTML = '▼' ; // Down arrow when open
2026-01-19 02:48:28 +01:00
} else {
// Stop stats collection when panel is closed
await fetch ( '/api/screens/stats/stop' , { method : 'POST' });
2026-01-19 04:22:41 +01:00
if ( toggleBtn ) toggleBtn . innerHTML = '▲' ; // Up arrow when closed
2026-01-19 00:38:11 +01:00
}
}
2026-01-19 02:48:28 +01:00
// Legacy alias for task panel toggle (used by session tab badge)
toggleTaskPanel () {
this . toggleMonitorPanel ();
}
2026-01-19 10:36:30 +01:00
// ========== Monitor Panel Detach & Drag ==========
toggleMonitorDetach () {
const panel = document . getElementById ( 'monitorPanel' );
const detachBtn = document . getElementById ( 'monitorDetachBtn' );
if ( panel . classList . contains ( 'detached' )) {
// Re-attach to bottom
panel . classList . remove ( 'detached' );
panel . style . top = '' ;
panel . style . left = '' ;
panel . style . width = '' ;
panel . style . height = '' ;
if ( detachBtn ) {
2026-01-19 19:28:46 +01:00
detachBtn . innerHTML = '⧉' ; // Detach icon (two overlapping squares)
2026-01-19 10:36:30 +01:00
detachBtn . title = 'Detach panel' ;
}
} else {
// Detach as floating window
panel . classList . add ( 'detached' );
panel . classList . add ( 'open' ); // Ensure it's visible
if ( detachBtn ) {
2026-01-19 19:28:46 +01:00
detachBtn . innerHTML = '⊞' ; // Attach icon (squared plus - dock back)
2026-01-19 10:36:30 +01:00
detachBtn . title = 'Attach panel' ;
}
// Setup drag functionality
this . setupMonitorDrag ();
}
}
setupMonitorDrag () {
const panel = document . getElementById ( 'monitorPanel' );
const header = document . getElementById ( 'monitorPanelHeader' );
if ( ! panel || ! header ) return ;
let isDragging = false ;
let startX , startY , startLeft , startTop ;
const onMouseDown = ( e ) => {
// Only drag from header, not from buttons
if ( e . target . closest ( 'button' )) return ;
if ( ! panel . classList . contains ( 'detached' )) return ;
isDragging = true ;
startX = e . clientX ;
startY = e . clientY ;
const rect = panel . getBoundingClientRect ();
startLeft = rect . left ;
startTop = rect . top ;
document . addEventListener ( 'mousemove' , onMouseMove );
document . addEventListener ( 'mouseup' , onMouseUp );
e . preventDefault ();
};
const onMouseMove = ( e ) => {
if ( ! isDragging ) return ;
const dx = e . clientX - startX ;
const dy = e . clientY - startY ;
let newLeft = startLeft + dx ;
let newTop = startTop + dy ;
// Keep within viewport bounds
const rect = panel . getBoundingClientRect ();
newLeft = Math . max ( 0 , Math . min ( window . innerWidth - rect . width , newLeft ));
newTop = Math . max ( 0 , Math . min ( window . innerHeight - rect . height , newTop ));
panel . style . left = newLeft + 'px' ;
panel . style . top = newTop + 'px' ;
};
const onMouseUp = () => {
isDragging = false ;
document . removeEventListener ( 'mousemove' , onMouseMove );
document . removeEventListener ( 'mouseup' , onMouseUp );
};
// Remove existing listeners before adding new ones
header . removeEventListener ( 'mousedown' , header . _dragHandler );
header . _dragHandler = onMouseDown ;
header . addEventListener ( 'mousedown' , onMouseDown );
}
2026-01-19 00:38:11 +01:00
renderTaskPanel () {
2026-01-21 06:00:34 +01:00
// Debounce renders at 100ms to prevent excessive DOM updates
if ( this . renderTaskPanelTimeout ) {
clearTimeout ( this . renderTaskPanelTimeout );
}
this . renderTaskPanelTimeout = setTimeout (() => {
this . _renderTaskPanelImmediate ();
}, 100 );
}
_renderTaskPanelImmediate () {
2026-01-19 00:38:11 +01:00
const session = this . sessions . get ( this . activeSessionId );
2026-01-19 02:48:28 +01:00
const body = document . getElementById ( 'backgroundTasksBody' );
2026-01-19 00:38:11 +01:00
const stats = document . getElementById ( 'taskPanelStats' );
2026-01-19 04:02:46 +01:00
const section = document . getElementById ( 'backgroundTasksSection' );
2026-01-19 00:38:11 +01:00
if ( ! session || ! session . taskTree || session . taskTree . length === 0 ) {
2026-01-19 04:02:46 +01:00
// Hide the entire section when there are no background tasks
if ( section ) section . style . display = 'none' ;
body . innerHTML = '' ;
2026-01-19 00:38:11 +01:00
stats . textContent = '0 tasks' ;
2026-01-18 05:14:34 +01:00
return ;
}
2026-01-19 04:02:46 +01:00
// Show the section when there are tasks
if ( section ) section . style . display = '' ;
2026-01-19 00:38:11 +01:00
const taskStats = session . taskStats || { running : 0 , completed : 0 , failed : 0 , total : 0 };
stats . textContent = ` ${ taskStats . running } running, ${ taskStats . completed } done` ;
2026-01-18 07:31:19 +01:00
2026-01-19 00:38:11 +01:00
// Render task tree recursively
const renderTask = ( task , allTasks ) => {
const statusIcon = task . status === 'running' ? '' :
task . status === 'completed' ? '✓' : '✗' ;
const duration = task . endTime
? ` ${ (( task . endTime - task . startTime ) / 1000 ). toFixed ( 1 ) } s`
: ` ${ (( Date . now () - task . startTime ) / 1000 ). toFixed ( 0 ) } s...` ;
2026-01-18 21:36:37 +01:00
2026-01-19 00:38:11 +01:00
let childrenHtml = '' ;
if ( task . children && task . children . length > 0 ) {
childrenHtml = '<div class="task-children">' ;
for ( const childId of task . children ) {
// Find child task in allTasks map
const childTask = allTasks . find ( t => t . id === childId );
if ( childTask ) {
childrenHtml += `<div class="task-node"> ${ renderTask ( childTask , allTasks ) } </div>` ;
}
}
childrenHtml += '</div>' ;
}
2026-01-18 22:23:46 +01:00
2026-01-18 07:31:19 +01:00
return `
2026-01-19 00:38:11 +01:00
<div class="task-item">
<span class="task-status-icon ${ task . status } "> ${ statusIcon } </span>
<div class="task-info">
<div class="task-description"> ${ this . escapeHtml ( task . description ) } </div>
<div class="task-meta">
<span class="task-type"> ${ task . subagentType } </span>
<span> ${ duration } </span>
2026-01-18 21:36:37 +01:00
</div>
2026-01-18 07:31:19 +01:00
</div>
</div>
2026-01-19 00:38:11 +01:00
${ childrenHtml }
2026-01-18 07:31:19 +01:00
` ;
2026-01-19 00:38:11 +01:00
};
2026-01-18 07:31:19 +01:00
2026-01-19 00:38:11 +01:00
// Flatten all tasks for lookup
const allTasks = this . flattenTaskTree ( session . taskTree );
2026-01-18 21:36:37 +01:00
2026-01-19 00:38:11 +01:00
// Render only root tasks (those without parents or with null parentId)
let html = '<div class="task-tree">' ;
for ( const task of session . taskTree ) {
html += `<div class="task-node"> ${ renderTask ( task , allTasks ) } </div>` ;
2026-01-18 21:36:37 +01:00
}
2026-01-19 00:38:11 +01:00
html += '</div>' ;
body . innerHTML = html ;
2026-01-18 21:36:37 +01:00
}
2026-01-19 00:38:11 +01:00
flattenTaskTree ( tasks , result = []) {
for ( const task of tasks ) {
result . push ( task );
// Children are stored as IDs, not nested objects in taskTree
// The task tree from server already has the structure we need
2026-01-18 07:31:19 +01:00
}
2026-01-19 00:38:11 +01:00
return result ;
2026-01-18 05:14:34 +01:00
}
2026-01-19 17:09:56 +01:00
// ========== Enhanced Ralph Wiggum Loop Panel ==========
2026-01-19 13:21:01 +01:00
updateInnerState ( sessionId , updates ) {
const existing = this . innerStates . get ( sessionId ) || { loop : null , todos : [] };
const updated = { ... existing , ... updates };
this . innerStates . set ( sessionId , updated );
// Re-render if this is the active session
if ( sessionId === this . activeSessionId ) {
this . renderInnerStatePanel ();
}
}
toggleInnerStatePanel () {
2026-01-20 17:21:47 +01:00
// Preserve xterm scroll position to prevent jump when panel height changes
const xtermViewport = this . terminal ? . element ? . querySelector ( '.xterm-viewport' );
const scrollTop = xtermViewport ? . scrollTop ;
2026-01-19 13:21:01 +01:00
this . innerStatePanelCollapsed = ! this . innerStatePanelCollapsed ;
this . renderInnerStatePanel ();
2026-01-20 17:21:47 +01:00
// Restore scroll position and refit terminal after layout change
requestAnimationFrame (() => {
// Restore xterm scroll position
if ( xtermViewport && scrollTop !== undefined ) {
xtermViewport . scrollTop = scrollTop ;
}
// Refit terminal to new container size
if ( this . terminal && this . fitAddon ) {
this . fitAddon . fit ();
}
});
2026-01-19 13:21:01 +01:00
}
2026-01-20 17:54:59 +01:00
async closeRalphTracker () {
if ( ! this . activeSessionId ) return ;
// Disable tracker via API
await fetch ( `/api/sessions/ ${ this . activeSessionId } /inner-config` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ enabled : false })
});
// Clear local state and hide panel
this . innerStates . delete ( this . activeSessionId );
this . renderInnerStatePanel ();
}
2026-01-19 18:41:14 +01:00
toggleRalphDetach () {
const panel = this . $ ( 'innerStatePanel' );
const detachBtn = this . $ ( 'ralphDetachBtn' );
if ( ! panel ) return ;
if ( panel . classList . contains ( 'detached' )) {
// Re-attach to original position
panel . classList . remove ( 'detached' );
panel . style . top = '' ;
panel . style . left = '' ;
panel . style . width = '' ;
panel . style . height = '' ;
if ( detachBtn ) {
2026-01-20 17:37:49 +01:00
detachBtn . innerHTML = '⧉' ; // Detach icon (two overlapping squares)
2026-01-19 18:41:14 +01:00
detachBtn . title = 'Detach panel' ;
}
} else {
// Detach as floating window
panel . classList . add ( 'detached' );
// Expand when detaching for better visibility
this . innerStatePanelCollapsed = false ;
panel . classList . remove ( 'collapsed' );
if ( detachBtn ) {
2026-01-20 17:37:49 +01:00
detachBtn . innerHTML = '⊞' ; // Attach icon (squared plus - dock back)
2026-01-19 18:41:14 +01:00
detachBtn . title = 'Attach panel' ;
}
// Setup drag functionality
this . setupRalphDrag ();
}
this . renderInnerStatePanel ();
}
setupRalphDrag () {
const panel = this . $ ( 'innerStatePanel' );
const header = this . $ ( 'ralphSummary' );
if ( ! panel || ! header ) return ;
let isDragging = false ;
let startX , startY , startLeft , startTop ;
const onMouseDown = ( e ) => {
// Only drag from header, not from buttons or toggle
if ( e . target . closest ( 'button' ) || e . target . closest ( '.ralph-toggle' )) return ;
if ( ! panel . classList . contains ( 'detached' )) return ;
isDragging = true ;
startX = e . clientX ;
startY = e . clientY ;
const rect = panel . getBoundingClientRect ();
startLeft = rect . left ;
startTop = rect . top ;
document . addEventListener ( 'mousemove' , onMouseMove );
document . addEventListener ( 'mouseup' , onMouseUp );
e . preventDefault ();
};
const onMouseMove = ( e ) => {
if ( ! isDragging ) return ;
const dx = e . clientX - startX ;
const dy = e . clientY - startY ;
let newLeft = startLeft + dx ;
let newTop = startTop + dy ;
// Keep within viewport bounds
const rect = panel . getBoundingClientRect ();
newLeft = Math . max ( 0 , Math . min ( window . innerWidth - rect . width , newLeft ));
newTop = Math . max ( 0 , Math . min ( window . innerHeight - rect . height , newTop ));
panel . style . left = newLeft + 'px' ;
panel . style . top = newTop + 'px' ;
};
const onMouseUp = () => {
isDragging = false ;
document . removeEventListener ( 'mousemove' , onMouseMove );
document . removeEventListener ( 'mouseup' , onMouseUp );
};
// Remove existing listeners before adding new ones
header . removeEventListener ( 'mousedown' , header . _ralphDragHandler );
header . _ralphDragHandler = onMouseDown ;
header . addEventListener ( 'mousedown' , onMouseDown );
}
2026-01-19 13:21:01 +01:00
renderInnerStatePanel () {
2026-01-21 06:00:34 +01:00
// Debounce renders at 50ms to prevent excessive DOM updates
if ( this . renderInnerStatePanelTimeout ) {
clearTimeout ( this . renderInnerStatePanelTimeout );
}
this . renderInnerStatePanelTimeout = setTimeout (() => {
this . _renderInnerStatePanelImmediate ();
}, 50 );
}
_renderInnerStatePanelImmediate () {
2026-01-19 13:21:01 +01:00
const panel = this . $ ( 'innerStatePanel' );
2026-01-19 17:09:56 +01:00
const toggle = this . $ ( 'ralphToggle' );
2026-01-19 13:21:01 +01:00
2026-01-19 17:09:56 +01:00
if ( ! panel ) return ;
2026-01-19 13:21:01 +01:00
const state = this . innerStates . get ( this . activeSessionId );
// Check if there's anything to show
2026-01-19 18:34:43 +01:00
// Only show panel if tracker is enabled OR there's active state to display
const isEnabled = state ? . loop ? . enabled === true ;
2026-01-19 13:21:01 +01:00
const hasLoop = state ? . loop ? . active || state ? . loop ? . completionPhrase ;
const hasTodos = state ? . todos ? . length > 0 ;
2026-01-19 18:34:43 +01:00
if ( ! isEnabled && ! hasLoop && ! hasTodos ) {
2026-01-19 13:21:01 +01:00
panel . style . display = 'none' ;
return ;
}
panel . style . display = '' ;
2026-01-19 17:09:56 +01:00
// Calculate completion percentage
const todos = state ? . todos || [];
const completed = todos . filter ( t => t . status === 'completed' ). length ;
const total = todos . length ;
const percent = total > 0 ? Math . round (( completed / total ) * 100 ) : 0 ;
// Update progress rings
this . updateRalphRing ( percent );
2026-01-20 16:05:37 +01:00
// Update status badge (pass completion info)
this . updateRalphStatus ( state ? . loop , completed , total );
2026-01-19 17:09:56 +01:00
// Update stats
this . updateRalphStats ( state ? . loop , completed , total );
// Handle collapsed/expanded state
2026-01-19 13:21:01 +01:00
if ( this . innerStatePanelCollapsed ) {
panel . classList . add ( 'collapsed' );
2026-01-20 18:56:55 +01:00
if ( toggle ) toggle . innerHTML = '▼' ; // Down arrow when collapsed (click to expand)
2026-01-19 13:21:01 +01:00
} else {
panel . classList . remove ( 'collapsed' );
2026-01-20 18:56:55 +01:00
if ( toggle ) toggle . innerHTML = '▲' ; // Up arrow when expanded (click to collapse)
2026-01-19 17:09:56 +01:00
// Update expanded view content
this . updateRalphExpandedView ( state );
2026-01-19 13:21:01 +01:00
}
}
2026-01-19 17:09:56 +01:00
updateRalphRing ( percent ) {
// Mini ring (in summary)
const miniProgress = this . $ ( 'ralphRingMiniProgress' );
const miniText = this . $ ( 'ralphRingMiniText' );
if ( miniProgress ) {
// Circumference = 2 * PI * r = 2 * PI * 15.9 ≈ 100
const offset = 100 - percent ;
miniProgress . style . strokeDashoffset = offset ;
}
if ( miniText ) {
miniText . textContent = ` ${ percent } %` ;
2026-01-19 13:21:01 +01:00
}
2026-01-19 17:09:56 +01:00
// Large ring (in expanded view)
const largeProgress = this . $ ( 'ralphRingProgress' );
const largePercent = this . $ ( 'ralphRingPercent' );
if ( largeProgress ) {
// Circumference = 2 * PI * r = 2 * PI * 42 ≈ 264
const offset = 264 - ( 264 * percent / 100 );
largeProgress . style . strokeDashoffset = offset ;
}
if ( largePercent ) {
largePercent . textContent = ` ${ percent } %` ;
2026-01-19 13:21:01 +01:00
}
}
2026-01-20 16:05:37 +01:00
updateRalphStatus ( loop , completed = 0 , total = 0 ) {
2026-01-19 17:09:56 +01:00
const badge = this . $ ( 'ralphStatusBadge' );
const statusText = badge ? . querySelector ( '.ralph-status-text' );
if ( ! badge || ! statusText ) return ;
2026-01-19 13:21:01 +01:00
2026-01-19 18:34:43 +01:00
badge . classList . remove ( 'active' , 'completed' , 'tracking' );
2026-01-19 17:09:56 +01:00
if ( loop ? . active ) {
badge . classList . add ( 'active' );
statusText . textContent = 'Running' ;
2026-01-20 16:05:37 +01:00
} else if ( total > 0 && completed === total ) {
// Only show "Complete" when all todos are actually done
2026-01-19 17:09:56 +01:00
badge . classList . add ( 'completed' );
statusText . textContent = 'Complete' ;
2026-01-20 16:05:37 +01:00
} else if ( loop ? . enabled || total > 0 ) {
2026-01-19 18:34:43 +01:00
badge . classList . add ( 'tracking' );
statusText . textContent = 'Tracking' ;
2026-01-19 17:09:56 +01:00
} else {
statusText . textContent = 'Idle' ;
2026-01-19 13:21:01 +01:00
}
}
2026-01-19 17:09:56 +01:00
updateRalphStats ( loop , completed , total ) {
// Time stat
const timeEl = this . $ ( 'ralphStatTime' );
if ( timeEl ) {
if ( loop ? . elapsedHours !== null && loop ? . elapsedHours !== undefined ) {
timeEl . textContent = this . formatRalphTime ( loop . elapsedHours );
} else if ( loop ? . startedAt ) {
const hours = ( Date . now () - loop . startedAt ) / ( 1000 * 60 * 60 );
timeEl . textContent = this . formatRalphTime ( hours );
} else {
timeEl . textContent = '0m' ;
}
}
// Cycles stat
const cyclesEl = this . $ ( 'ralphStatCycles' );
if ( cyclesEl ) {
if ( loop ? . maxIterations ) {
cyclesEl . textContent = ` ${ loop . cycleCount || 0 } / ${ loop . maxIterations } ` ;
} else {
cyclesEl . textContent = String ( loop ? . cycleCount || 0 );
}
}
// Tasks stat
const tasksEl = this . $ ( 'ralphStatTasks' );
if ( tasksEl ) {
tasksEl . textContent = ` ${ completed } / ${ total } ` ;
}
}
formatRalphTime ( hours ) {
if ( hours < 0.0167 ) return '0m' ; // < 1 minute
if ( hours < 1 ) {
const minutes = Math . round ( hours * 60 );
return ` ${ minutes } m` ;
}
const h = Math . floor ( hours );
const m = Math . round (( hours - h ) * 60 );
if ( m === 0 ) return ` ${ h } h` ;
return ` ${ h } h ${ m } m` ;
}
updateRalphExpandedView ( state ) {
// Update phrase
const phraseEl = this . $ ( 'ralphPhrase' );
if ( phraseEl ) {
phraseEl . textContent = state ? . loop ? . completionPhrase || '--' ;
}
// Update elapsed
const elapsedEl = this . $ ( 'ralphElapsed' );
if ( elapsedEl ) {
if ( state ? . loop ? . elapsedHours !== null && state ? . loop ? . elapsedHours !== undefined ) {
elapsedEl . textContent = this . formatRalphTime ( state . loop . elapsedHours );
} else if ( state ? . loop ? . startedAt ) {
const hours = ( Date . now () - state . loop . startedAt ) / ( 1000 * 60 * 60 );
elapsedEl . textContent = this . formatRalphTime ( hours );
} else {
elapsedEl . textContent = '0m' ;
}
}
// Update iterations
const iterationsEl = this . $ ( 'ralphIterations' );
if ( iterationsEl ) {
if ( state ? . loop ? . maxIterations ) {
iterationsEl . textContent = ` ${ state . loop . cycleCount || 0 } / ${ state . loop . maxIterations } ` ;
} else {
iterationsEl . textContent = String ( state ? . loop ? . cycleCount || 0 );
}
}
// Update tasks count
const todos = state ? . todos || [];
const completed = todos . filter ( t => t . status === 'completed' ). length ;
const tasksCountEl = this . $ ( 'ralphTasksCount' );
if ( tasksCountEl ) {
tasksCountEl . textContent = ` ${ completed } / ${ todos . length } ` ;
}
// Render task cards
this . renderRalphTasks ( todos );
}
renderRalphTasks ( todos ) {
const grid = this . $ ( 'ralphTasksGrid' );
if ( ! grid ) return ;
if ( todos . length === 0 ) {
2026-01-21 06:04:43 +01:00
if ( grid . children . length !== 1 || ! grid . querySelector ( '.inner-state-empty' )) {
grid . innerHTML = '<div class="inner-state-empty">No tasks detected</div>' ;
}
2026-01-19 17:09:56 +01:00
return ;
}
// Sort: in_progress first, then pending, then completed
const sorted = [... todos ]. sort (( a , b ) => {
const order = { in_progress : 0 , pending : 1 , completed : 2 };
return ( order [ a . status ] || 1 ) - ( order [ b . status ] || 1 );
});
2026-01-21 06:04:43 +01:00
// Incremental DOM update - reuse existing elements where possible
const existingCards = grid . querySelectorAll ( '.ralph-task-card' );
const fragment = document . createDocumentFragment ();
let needsRebuild = existingCards . length !== sorted . length ;
// Check if we can do incremental update
if ( ! needsRebuild ) {
// Update existing cards in place
sorted . forEach (( todo , i ) => {
const card = existingCards [ i ];
const statusClass = `task- ${ todo . status . replace ( '_' , '-' ) } ` ;
const icon = this . getRalphTaskIcon ( todo . status );
// Update class if changed
if ( ! card . classList . contains ( statusClass )) {
card . className = `ralph-task-card ${ statusClass } ` ;
}
// Update icon if changed
const iconEl = card . querySelector ( '.ralph-task-icon' );
if ( iconEl && iconEl . textContent !== icon ) {
iconEl . textContent = icon ;
}
// Update content if changed
const contentEl = card . querySelector ( '.ralph-task-content' );
if ( contentEl && contentEl . textContent !== todo . content ) {
contentEl . textContent = todo . content ;
}
});
} else {
// Full rebuild needed - use DocumentFragment for efficiency
sorted . forEach ( todo => {
const card = document . createElement ( 'div' );
const statusClass = `task- ${ todo . status . replace ( '_' , '-' ) } ` ;
card . className = `ralph-task-card ${ statusClass } ` ;
const iconSpan = document . createElement ( 'span' );
iconSpan . className = 'ralph-task-icon' ;
iconSpan . textContent = this . getRalphTaskIcon ( todo . status );
const contentSpan = document . createElement ( 'span' );
contentSpan . className = 'ralph-task-content' ;
contentSpan . textContent = todo . content ;
card . appendChild ( iconSpan );
card . appendChild ( contentSpan );
fragment . appendChild ( card );
});
grid . innerHTML = '' ;
grid . appendChild ( fragment );
}
2026-01-19 17:09:56 +01:00
}
getRalphTaskIcon ( status ) {
2026-01-19 13:21:01 +01:00
switch ( status ) {
case 'completed' : return '✓' ;
case 'in_progress' : return '◐' ;
case 'pending' :
2026-01-19 17:09:56 +01:00
default : return '○' ;
2026-01-19 13:21:01 +01:00
}
}
2026-01-19 17:09:56 +01:00
// Legacy method for backwards compatibility
getTodoIcon ( status ) {
return this . getRalphTaskIcon ( status );
}
2026-01-19 02:48:28 +01:00
// ========== Screen Sessions (in Monitor Panel) ==========
2026-01-19 02:18:51 +01:00
async loadScreens () {
try {
const res = await fetch ( '/api/screens' );
const data = await res . json ();
this . screenSessions = data . screens || [];
2026-01-19 02:48:28 +01:00
this . renderScreenSessions ();
2026-01-19 02:18:51 +01:00
} catch ( err ) {
console . error ( 'Failed to load screens:' , err );
}
}
2026-01-19 10:36:30 +01:00
killAllSessions () {
2026-01-19 07:57:13 +01:00
const count = this . screenSessions ? . length || 0 ;
if ( count === 0 ) {
alert ( 'No sessions to kill' );
return ;
}
2026-01-19 10:36:30 +01:00
// Show the kill all modal
document . getElementById ( 'killAllCount' ). textContent = count ;
document . getElementById ( 'killAllModal' ). classList . add ( 'active' );
}
closeKillAllModal () {
document . getElementById ( 'killAllModal' ). classList . remove ( 'active' );
}
async confirmKillAll ( killScreens ) {
this . closeKillAllModal ();
2026-01-19 07:57:13 +01:00
try {
2026-01-19 10:36:30 +01:00
if ( killScreens ) {
// Kill everything including screens
const res = await fetch ( '/api/sessions' , { method : 'DELETE' });
const data = await res . json ();
if ( data . success ) {
this . sessions . clear ();
this . screenSessions = [];
this . activeSessionId = null ;
this . renderSessionTabs ();
this . renderScreenSessions ();
this . terminal . clear ();
this . terminal . reset ();
this . toast ( 'All sessions and screens killed' , 'success' );
}
} else {
// Just remove tabs, keep screens running
2026-01-19 07:57:13 +01:00
this . sessions . clear ();
this . activeSessionId = null ;
this . renderSessionTabs ();
this . terminal . clear ();
this . terminal . reset ();
2026-01-19 10:36:30 +01:00
this . toast ( 'All tabs removed, screens still running' , 'info' );
2026-01-19 07:57:13 +01:00
}
} catch ( err ) {
2026-01-19 10:36:30 +01:00
console . error ( 'Failed to kill sessions:' , err );
this . toast ( 'Failed to kill sessions: ' + err . message , 'error' );
}
}
// ========== Create Case Modal ==========
showCreateCaseModal () {
document . getElementById ( 'newCaseName' ). value = '' ;
document . getElementById ( 'newCaseDescription' ). value = '' ;
document . getElementById ( 'createCaseModal' ). classList . add ( 'active' );
document . getElementById ( 'newCaseName' ). focus ();
}
closeCreateCaseModal () {
document . getElementById ( 'createCaseModal' ). classList . remove ( 'active' );
}
async createCase () {
const name = document . getElementById ( 'newCaseName' ). value . trim ();
const description = document . getElementById ( 'newCaseDescription' ). value . trim ();
if ( ! name ) {
this . toast ( 'Please enter a case name' , 'error' );
return ;
}
if ( ! /^[a-zA-Z0-9_-]+$/ . test ( name )) {
this . toast ( 'Invalid name. Use only letters, numbers, hyphens, underscores.' , 'error' );
return ;
}
try {
const res = await fetch ( '/api/cases' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ name , description })
});
const data = await res . json ();
if ( data . success ) {
this . closeCreateCaseModal ();
this . toast ( `Case " ${ name } " created` , 'success' );
// Reload cases and select the new one
2026-01-19 18:25:31 +01:00
await this . loadQuickStartCases ( name );
2026-01-19 10:36:30 +01:00
} else {
this . toast ( data . error || 'Failed to create case' , 'error' );
}
} catch ( err ) {
console . error ( 'Failed to create case:' , err );
this . toast ( 'Failed to create case: ' + err . message , 'error' );
2026-01-19 07:57:13 +01:00
}
}
2026-01-19 02:48:28 +01:00
renderScreenSessions () {
2026-01-21 06:00:34 +01:00
// Debounce renders at 100ms to prevent excessive DOM updates
if ( this . renderScreenSessionsTimeout ) {
clearTimeout ( this . renderScreenSessionsTimeout );
}
this . renderScreenSessionsTimeout = setTimeout (() => {
this . _renderScreenSessionsImmediate ();
}, 100 );
}
_renderScreenSessionsImmediate () {
2026-01-19 02:48:28 +01:00
const body = document . getElementById ( 'screenSessionsBody' );
2026-01-19 02:18:51 +01:00
if ( ! this . screenSessions || this . screenSessions . length === 0 ) {
2026-01-19 02:48:28 +01:00
body . innerHTML = '<div class="monitor-empty">No screen sessions</div>' ;
2026-01-19 02:18:51 +01:00
return ;
}
let html = '' ;
for ( const screen of this . screenSessions ) {
const stats = screen . stats || { memoryMB : 0 , cpuPercent : 0 , childCount : 0 };
const modeClass = screen . mode === 'shell' ? 'shell' : '' ;
html += `
<div class="process-item">
<span class="process-mode ${ modeClass } "> ${ screen . mode } </span>
<div class="process-info">
2026-01-19 04:22:41 +01:00
<div class="process-name"> ${ this . escapeHtml ( screen . name || screen . screenName ) } </div>
2026-01-19 02:18:51 +01:00
<div class="process-meta">
<span class="process-stat memory"> ${ stats . memoryMB } MB</span>
<span class="process-stat cpu"> ${ stats . cpuPercent } %</span>
<span class="process-stat children"> ${ stats . childCount } children</span>
<span>PID: ${ screen . pid } </span>
</div>
</div>
<div class="process-actions">
<button class="btn-toolbar btn-sm btn-danger" onclick="app.killScreen(' ${ screen . sessionId } ')" title="Kill screen">Kill</button>
</div>
</div>
` ;
}
body . innerHTML = html ;
}
async killScreen ( sessionId ) {
if ( ! confirm ( 'Kill this screen session?' )) return ;
try {
await fetch ( `/api/screens/ ${ sessionId } ` , { method : 'DELETE' });
this . screenSessions = this . screenSessions . filter ( s => s . sessionId !== sessionId );
2026-01-19 02:48:28 +01:00
this . renderScreenSessions ();
2026-01-19 02:18:51 +01:00
this . showToast ( 'Screen killed' , 'success' );
} catch ( err ) {
this . showToast ( 'Failed to kill screen' , 'error' );
}
}
async reconcileScreens () {
try {
const res = await fetch ( '/api/screens/reconcile' , { method : 'POST' });
const data = await res . json ();
if ( data . dead && data . dead . length > 0 ) {
this . showToast ( `Found ${ data . dead . length } dead screen(s)` , 'warning' );
await this . loadScreens ();
} else {
this . showToast ( 'All screens are alive' , 'success' );
}
} catch ( err ) {
this . showToast ( 'Failed to reconcile screens' , 'error' );
}
}
2026-01-19 00:38:11 +01:00
// ========== Toast ==========
2026-01-19 11:48:02 +01:00
// Cached toast container for performance
_toastContainer = null ;
2026-01-19 18:25:31 +01:00
// Alias for showToast
toast ( message , type = 'info' ) {
return this . showToast ( message , type );
}
2026-01-19 00:38:11 +01:00
showToast ( message , type = 'info' ) {
const toast = document . createElement ( 'div' );
toast . className = `toast toast- ${ type } ` ;
toast . textContent = message ;
2026-01-19 11:48:02 +01:00
// Cache toast container reference
if ( ! this . _toastContainer ) {
this . _toastContainer = document . querySelector ( '.toast-container' );
if ( ! this . _toastContainer ) {
this . _toastContainer = document . createElement ( 'div' );
this . _toastContainer . className = 'toast-container' ;
document . body . appendChild ( this . _toastContainer );
}
2026-01-18 21:36:37 +01:00
}
2026-01-19 11:48:02 +01:00
this . _toastContainer . appendChild ( toast );
2026-01-18 21:36:37 +01:00
2026-01-19 00:38:11 +01:00
requestAnimationFrame (() => toast . classList . add ( 'show' ));
2026-01-18 21:36:37 +01:00
2026-01-19 00:38:11 +01:00
setTimeout (() => {
toast . classList . remove ( 'show' );
setTimeout (() => toast . remove (), 200 );
}, 3000 );
2026-01-18 21:36:37 +01:00
}
2026-01-19 06:51:25 +01:00
// ========== System Stats ==========
startSystemStatsPolling () {
// Initial fetch
this . fetchSystemStats ();
// Poll every 2 seconds
this . systemStatsInterval = setInterval (() => {
this . fetchSystemStats ();
}, 2000 );
}
async fetchSystemStats () {
try {
const res = await fetch ( '/api/system/stats' );
const stats = await res . json ();
this . updateSystemStatsDisplay ( stats );
} catch ( err ) {
// Silently fail - system stats are not critical
}
}
updateSystemStatsDisplay ( stats ) {
2026-01-19 11:48:02 +01:00
const cpuEl = this . $ ( 'statCpu' );
const cpuBar = this . $ ( 'statCpuBar' );
const memEl = this . $ ( 'statMem' );
const memBar = this . $ ( 'statMemBar' );
2026-01-19 06:51:25 +01:00
2026-01-19 10:36:30 +01:00
if ( cpuEl && cpuBar ) {
cpuEl . textContent = ` ${ stats . cpu } %` ;
cpuBar . style . width = ` ${ Math . min ( 100 , stats . cpu ) } %` ;
// Color classes based on usage
cpuBar . classList . remove ( 'medium' , 'high' );
cpuEl . classList . remove ( 'high' );
2026-01-19 06:51:25 +01:00
if ( stats . cpu > 80 ) {
2026-01-19 10:36:30 +01:00
cpuBar . classList . add ( 'high' );
2026-01-19 06:51:25 +01:00
cpuEl . classList . add ( 'high' );
2026-01-19 10:36:30 +01:00
} else if ( stats . cpu > 50 ) {
cpuBar . classList . add ( 'medium' );
2026-01-19 06:51:25 +01:00
}
}
2026-01-19 10:36:30 +01:00
if ( memEl && memBar ) {
2026-01-19 06:51:25 +01:00
const memGB = ( stats . memory . usedMB / 1024 ). toFixed ( 1 );
2026-01-19 10:36:30 +01:00
memEl . textContent = ` ${ memGB } G` ;
memBar . style . width = ` ${ Math . min ( 100 , stats . memory . percent ) } %` ;
// Color classes based on usage
memBar . classList . remove ( 'medium' , 'high' );
memEl . classList . remove ( 'high' );
2026-01-19 06:51:25 +01:00
if ( stats . memory . percent > 80 ) {
2026-01-19 10:36:30 +01:00
memBar . classList . add ( 'high' );
2026-01-19 06:51:25 +01:00
memEl . classList . add ( 'high' );
2026-01-19 10:36:30 +01:00
} else if ( stats . memory . percent > 50 ) {
memBar . classList . add ( 'medium' );
2026-01-19 06:51:25 +01:00
}
}
}
2026-01-19 00:38:11 +01:00
// ========== Utility ==========
2026-01-18 05:14:34 +01:00
2026-01-19 11:48:02 +01:00
// Pre-compiled HTML escape map for performance (avoids DOM element creation)
static _htmlEscapeMap = {
'&' : '&' ,
'<' : '<' ,
'>' : '>' ,
'"' : '"' ,
"'" : '''
};
static _htmlEscapePattern = /[&<>"']/g ;
2026-01-18 05:14:34 +01:00
escapeHtml ( text ) {
if ( ! text ) return '' ;
2026-01-19 11:48:02 +01:00
return text . replace ( ClaudemanApp . _htmlEscapePattern , char => ClaudemanApp . _htmlEscapeMap [ char ]);
2026-01-18 05:14:34 +01:00
}
}
// Initialize
const app = new ClaudemanApp ();