2026-03-14 21:24:22 +01:00
/**
* @fileoverview App settings modal, visibility settings (header/panel/device-specific defaults),
* web push notifications, session lifecycle log (JSONL viewer), tunnel/QR management,
* persistent parent associations, and help modal.
* Includes 13 SSE handlers for hooks and tunnel events.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (CodemanApp class, this.notificationManager, this._tunnelUrl)
* @dependency constants.js (escapeHtml)
* @dependency keyboard-accessory.js (FocusTrap)
* @loadorder 10 of 15 — loaded after ralph-panel.js, before panels-ui.js
*/
Object . assign ( CodemanApp . prototype , {
// Hooks (Claude Code hook events)
_onHookIdlePrompt ( data ) {
2026-09-22 08:10:05 +02:00
// A prompt the server opened ALREADY acknowledged raises no alert here. Today that
// means the session is watching work it started itself (`acknowledgedReason` reads
// "watching 1 monitor"), so the pane is quiet because the agent is waiting for its
// own monitor, not for you. The item still exists and still shows in the drawer;
// only the tab alert and the desktop notification are declined. A page that reloads
// instead of receiving this event reaches the same conclusion from `acknowledgedAt`
// in seedApprovals (approvals-ui.js).
if ( data . acknowledgedReason ) return ;
2026-03-14 21:24:22 +01:00
// Always track pending hook - alert will show when switching away from session
if ( data . sessionId ) {
this . setPendingHook ( data . sessionId , 'idle_prompt' );
}
2026-03-26 22:50:25 +01:00
this . _notifySession ( data . sessionId , 'warning' , 'hook-idle' , 'Waiting for Input' , data . message || 'Claude is idle and waiting for a prompt' );
2026-03-14 21:24:22 +01:00
},
_onHookPermissionPrompt ( data ) {
// Always track pending hook - action alerts need user interaction to clear
if ( data . sessionId ) {
this . setPendingHook ( data . sessionId , 'permission_prompt' );
}
const toolInfo = data . tool ? ` ${ data . tool }${ data . command ? ': ' + data . command : data . file ? ': ' + data . file : '' } ` : '' ;
2026-03-26 22:50:25 +01:00
this . _notifySession ( data . sessionId , 'critical' , 'hook-permission' , 'Permission Required' , toolInfo || 'Claude needs tool approval to continue' );
2026-03-14 21:24:22 +01:00
},
_onHookElicitationDialog ( data ) {
// Always track pending hook - action alerts need user interaction to clear
if ( data . sessionId ) {
this . setPendingHook ( data . sessionId , 'elicitation_dialog' );
}
2026-03-26 22:50:25 +01:00
this . _notifySession ( data . sessionId , 'critical' , 'hook-elicitation' , 'Question Asked' , data . question || 'Claude is asking a question and waiting for your answer' );
2026-03-14 21:24:22 +01:00
},
2026-08-09 14:04:34 +02:00
_onHookElicitationComplete ( data ) {
2026-08-09 15:55:51 +02:00
// Question answered in the terminal: clear the action alert without
2026-08-09 14:04:34 +02:00
// waiting for `stop` (the turn may keep running for a long time).
2026-08-20 12:18:16 +02:00
// ⚠️ BOTH action kinds, matching the server's APPROVAL_RESOLVING_EVENTS,
// which resolves a session's pending item whatever its kind. An
// AskUserQuestion dialog arrives as `permission_prompt` (only MCP
// elicitation is `elicitation_dialog`), so clearing just the elicitation
// entry left the red alert armed on exactly the dialog these events are
// most often about. Normally the server's `approval:resolved` broadcast
// clears it too; this is the path that still works when the store holds no
// item for the session (restart, superseded).
2026-08-09 14:04:34 +02:00
if ( data . sessionId ) {
this . clearPendingHooks ( data . sessionId , 'elicitation_dialog' );
2026-08-20 12:18:16 +02:00
this . clearPendingHooks ( data . sessionId , 'permission_prompt' );
2026-08-09 14:04:34 +02:00
}
},
_onHookElicitationResponse ( data ) {
this . _onHookElicitationComplete ( data );
},
2026-03-14 21:24:22 +01:00
_onHookStop ( data ) {
// Clear all pending hooks when Claude finishes responding
if ( data . sessionId ) {
this . clearPendingHooks ( data . sessionId );
}
2026-03-26 22:50:25 +01:00
this . _notifySession ( data . sessionId , 'info' , 'hook-stop' , 'Response Complete' , data . reason || 'Claude has finished responding' );
2026-03-14 21:24:22 +01:00
},
2026-08-24 03:37:56 +02:00
_onHookAgentWorking ( data ) {
// The agent started a turn, so whatever it was blocked on is gone. Reported
// by the DeepSeek status bridge; a harness turn cannot run while one of its
// own modal approvals is on screen, so this means the dialog was answered in
// the terminal. Same clearing as _onHookElicitationComplete, and notably NOT
// a notification: a turn STARTING is not news.
if ( data . sessionId ) {
this . clearPendingHooks ( data . sessionId , 'elicitation_dialog' );
this . clearPendingHooks ( data . sessionId , 'permission_prompt' );
this . clearPendingHooks ( data . sessionId , 'idle_prompt' );
}
},
2026-03-14 21:24:22 +01:00
_onHookTeammateIdle ( data ) {
const session = this . sessions . get ( data . sessionId );
2026-03-26 22:50:25 +01:00
this . _notifySession ( data . sessionId , 'warning' , 'hook-teammate-idle' , 'Teammate Idle' , `A teammate is idle in ${ session ? . name || data . sessionId } ` );
2026-03-14 21:24:22 +01:00
},
_onHookTaskCompleted ( data ) {
const session = this . sessions . get ( data . sessionId );
2026-03-26 22:50:25 +01:00
this . _notifySession ( data . sessionId , 'info' , 'hook-task-completed' , 'Task Completed' , `A team task completed in ${ session ? . name || data . sessionId } ` );
2026-03-14 21:24:22 +01:00
},
// Tunnel
_onTunnelStarted ( data ) {
console . log ( '[Tunnel] Started:' , data . url );
this . _tunnelUrl = data . url ;
this . _dismissTunnelConnecting ();
this . _updateTunnelUrlDisplay ( data . url );
this . _updateTunnelIndicator ( true );
const welcomeVisible = document . getElementById ( 'welcomeOverlay' ) ? . classList . contains ( 'visible' );
if ( welcomeVisible ) {
// On welcome screen: QR appears inline, expanded first
this . _updateWelcomeTunnelBtn ( true , data . url , true );
this . showToast ( `Tunnel active` , 'success' );
} else {
// Not on welcome screen: popup QR overlay
this . _updateWelcomeTunnelBtn ( true , data . url );
this . showToast ( `Tunnel active: ${ data . url } ` , 'success' );
this . showTunnelQR ();
}
},
_onTunnelStopped () {
console . log ( '[Tunnel] Stopped' );
this . _tunnelUrl = null ;
this . _dismissTunnelConnecting ();
this . _updateTunnelUrlDisplay ( null );
this . _updateWelcomeTunnelBtn ( false );
this . _updateTunnelIndicator ( false );
this . closeTunnelPanel ();
this . closeTunnelQR ();
},
_onTunnelProgress ( data ) {
console . log ( '[Tunnel] Progress:' , data . message );
const toast = document . getElementById ( 'tunnelConnectingToast' );
if ( toast ) {
toast . innerHTML = `<span class="tunnel-spinner"></span> ${ data . message } ` ;
}
// Also update button text if on welcome screen
const btn = document . getElementById ( 'welcomeTunnelBtn' );
if ( btn ? . classList . contains ( 'connecting' )) {
btn . innerHTML = `<span class="tunnel-spinner"></span> ${ data . message } ` ;
}
},
_onTunnelError ( data ) {
console . warn ( '[Tunnel] Error:' , data . message );
this . _dismissTunnelConnecting ();
this . showToast ( `Tunnel error: ${ data . message } ` , 'error' );
const btn = document . getElementById ( 'welcomeTunnelBtn' );
if ( btn ) { btn . disabled = false ; btn . classList . remove ( 'connecting' ); }
},
_onTunnelQrRotated ( data ) {
if ( data . svg ) {
const container = document . getElementById ( 'tunnelQrContainer' );
if ( container ) container . innerHTML = data . svg ;
const welcomeInner = document . getElementById ( 'welcomeQrInner' );
if ( welcomeInner ) welcomeInner . innerHTML = data . svg ;
} else {
this . _refreshTunnelQrFromApi ();
}
this . _resetQrCountdown ();
},
_onTunnelQrRegenerated ( data ) {
if ( data . svg ) {
const container = document . getElementById ( 'tunnelQrContainer' );
if ( container ) container . innerHTML = data . svg ;
const welcomeInner = document . getElementById ( 'welcomeQrInner' );
if ( welcomeInner ) welcomeInner . innerHTML = data . svg ;
} else {
this . _refreshTunnelQrFromApi ();
}
this . _resetQrCountdown ();
},
_onTunnelQrAuthUsed ( data ) {
const ua = data . ua || 'Unknown device' ;
const family = ua . match ( /Chrome|Firefox|Safari|Edge|Mobile/ ) ? .[ 0 ] || 'Browser' ;
this . showToast ( `Device authenticated via QR ( ${ family } , ${ data . ip } ). Not you?` , 'warning' , {
duration : 10000 ,
action : { label : 'Revoke All' , onClick : () => {
fetch ( '/api/auth/revoke' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : '{}' })
. then (() => this . showToast ( 'All sessions revoked' , 'success' ))
. catch (() => this . showToast ( 'Failed to revoke sessions' , 'error' ));
}},
});
},
// ═══════════════════════════════════════════════════════════════
// Web Push
// ═══════════════════════════════════════════════════════════════
registerServiceWorker () {
if ( ! ( 'serviceWorker' in navigator )) return ;
2026-09-04 12:10:20 -04:00
// Behind a sub-path mount the worker is served at <base>/sw.js and controls
// <base>/ (Service-Worker-Allowed is '/', so this narrower scope is permitted).
const _swBase = window . CodemanBase ? . base || '' ;
navigator . serviceWorker . register ( _swBase + '/sw.js' , { scope : _swBase + '/' }). then (( reg ) => {
2026-03-14 21:24:22 +01:00
this . _swRegistration = reg ;
// Listen for messages from service worker (notification clicks)
navigator . serviceWorker . addEventListener ( 'message' , ( event ) => {
if ( event . data ? . type === 'notification-click' ) {
2026-08-09 14:04:34 +02:00
const { sessionId , action , approvalId } = event . data ;
if ( action ) {
2026-08-09 15:55:51 +02:00
// Approve/Deny action buttons on a push: answer via the
2026-08-09 14:04:34 +02:00
// Approvals Inbox instead of just focusing the session.
this . handleNotificationAction ? .( action , approvalId , sessionId );
} else if ( sessionId && this . sessions . has ( sessionId )) {
2026-03-14 21:24:22 +01:00
this . selectSession ( sessionId );
}
window . focus ();
}
});
// Check if already subscribed
reg . pushManager . getSubscription (). then (( sub ) => {
if ( sub ) {
this . _pushSubscription = sub ;
this . _updatePushUI ( true );
}
});
}). catch (() => {
// Service worker registration failed (likely not HTTPS)
});
},
async subscribeToPush () {
if ( ! this . _swRegistration ) {
this . showToast ( 'Service worker not available. HTTPS or localhost required.' , 'error' );
return ;
}
try {
// Get VAPID public key from server
const keyData = await this . _apiJson ( '/api/push/vapid-key' );
2026-06-10 01:30:43 +02:00
if ( ! keyData ) throw new Error ( 'Failed to get VAPID key' );
2026-03-14 21:24:22 +01:00
2026-06-10 01:30:43 +02:00
const applicationServerKey = urlBase64ToUint8Array ( keyData . publicKey );
2026-03-14 21:24:22 +01:00
const subscription = await this . _swRegistration . pushManager . subscribe ({
userVisibleOnly : true ,
applicationServerKey ,
});
// Send subscription to server
const subJson = subscription . toJSON ();
const data = await this . _apiJson ( '/api/push/subscribe' , {
method : 'POST' ,
body : {
endpoint : subJson . endpoint ,
keys : subJson . keys ,
userAgent : navigator . userAgent ,
pushPreferences : this . _buildPushPreferences (),
},
});
2026-06-10 01:30:43 +02:00
if ( ! data ) throw new Error ( 'Failed to register subscription' );
2026-03-14 21:24:22 +01:00
this . _pushSubscription = subscription ;
2026-06-10 01:30:43 +02:00
this . _pushSubscriptionId = data . id ;
localStorage . setItem ( 'codeman-push-subscription-id' , data . id );
2026-03-14 21:24:22 +01:00
this . _updatePushUI ( true );
this . showToast ( 'Push notifications enabled' , 'success' );
} catch ( err ) {
this . showToast ( 'Push subscription failed: ' + ( err . message || err ), 'error' );
}
},
async unsubscribeFromPush () {
try {
if ( this . _pushSubscription ) {
await this . _pushSubscription . unsubscribe ();
}
const subId = this . _pushSubscriptionId || localStorage . getItem ( 'codeman-push-subscription-id' );
if ( subId ) {
await fetch ( `/api/push/subscribe/ ${ subId } ` , { method : 'DELETE' }). catch (() => {});
}
this . _pushSubscription = null ;
this . _pushSubscriptionId = null ;
localStorage . removeItem ( 'codeman-push-subscription-id' );
this . _updatePushUI ( false );
this . showToast ( 'Push notifications disabled' , 'success' );
} catch ( err ) {
this . showToast ( 'Failed to unsubscribe: ' + ( err . message || err ), 'error' );
}
},
async togglePushSubscription () {
if ( this . _pushSubscription ) {
await this . unsubscribeFromPush ();
} else {
await this . subscribeToPush ();
}
},
/** Sync push preferences to server */
async _syncPushPreferences () {
const subId = this . _pushSubscriptionId || localStorage . getItem ( 'codeman-push-subscription-id' );
if ( ! subId ) return ;
try {
await fetch ( `/api/push/subscribe/ ${ subId } ` , {
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ pushPreferences : this . _buildPushPreferences () }),
});
} catch {
// Silently fail — prefs saved locally, will sync on next subscribe
}
},
/** Build push preferences object from current event type checkboxes */
_buildPushPreferences () {
const prefs = {};
const eventMap = {
'hook:permission_prompt' : 'eventPermissionPush' ,
'hook:elicitation_dialog' : 'eventQuestionPush' ,
'hook:idle_prompt' : 'eventIdlePush' ,
'hook:stop' : 'eventStopPush' ,
'respawn:blocked' : 'eventRespawnPush' ,
'session:ralphCompletionDetected' : 'eventRalphPush' ,
};
for ( const [ event , checkboxId ] of Object . entries ( eventMap )) {
const el = document . getElementById ( checkboxId );
prefs [ event ] = el ? el . checked : true ;
}
// session:error always receives push (no per-event toggle, always critical)
prefs [ 'session:error' ] = true ;
return prefs ;
},
_updatePushUI ( subscribed ) {
const btn = document . getElementById ( 'pushSubscribeBtn' );
const status = document . getElementById ( 'pushSubscriptionStatus' );
if ( btn ) btn . textContent = subscribed ? 'Unsubscribe' : 'Subscribe' ;
if ( status ) {
status . textContent = subscribed ? 'active' : 'off' ;
status . classList . remove ( 'granted' , 'denied' );
if ( subscribed ) status . classList . add ( 'granted' );
}
},
// ═══════════════════════════════════════════════════════════════
// App Settings Modal
// ═══════════════════════════════════════════════════════════════
2026-09-14 14:09:13 +02:00
/**
* Point one terminal-weight select at its stored value.
*
* A stored value the picker does not list (a hand-set 350, or a weight from a
* build whose options differ) is ADDED to the select rather than dropped:
* otherwise `select.value = '350'` silently selects nothing, the next save
* reads back '' and the setting resets itself just for having been opened.
* Empty means "use xterm's default for this slot".
*/
populateTerminalFontWeight ( select , value ) {
if ( ! select ) return ;
const stored = value === undefined || value === null ? '' : String ( value ). trim ();
if ( stored && ! Array . from ( select . options ). some (( opt ) => opt . value === stored )) {
const extra = document . createElement ( 'option' );
extra . value = stored ;
extra . textContent = ` ${ stored } (custom)` ;
select . appendChild ( extra );
}
select . value = stored ;
},
/** Read one terminal-weight select back. '' means default; the resolver in constants.js validates. */
readTerminalFontWeight ( select ) {
return select ? . value . trim () || '' ;
},
2026-03-14 21:24:22 +01:00
openAppSettings () {
// Load current settings
const settings = this . loadAppSettingsFromStorage ();
2026-07-21 02:49:25 +08:00
document . getElementById ( 'appSettingsDisplayName' ). value = settings . displayName || 'Codeman' ;
document . getElementById ( 'appSettingsLanguage' ). value = settings . language === 'zh-CN' ? 'zh-CN' : 'en' ;
2026-03-14 21:24:22 +01:00
document . getElementById ( 'appSettingsClaudeMdPath' ). value = settings . defaultClaudeMdPath || '' ;
document . getElementById ( 'appSettingsDefaultDir' ). value = settings . defaultWorkingDir || '' ;
// Use device-aware defaults for display settings (mobile has different defaults)
const defaults = this . getDefaultSettings ();
document . getElementById ( 'appSettingsRalphEnabled' ). checked = settings . ralphTrackerEnabled ?? defaults . ralphTrackerEnabled ?? false ;
// Header visibility settings
document . getElementById ( 'appSettingsShowFontControls' ). checked = settings . showFontControls ?? defaults . showFontControls ?? false ;
document . getElementById ( 'appSettingsShowSystemStats' ). checked = settings . showSystemStats ?? defaults . showSystemStats ?? true ;
2026-07-24 14:50:43 +02:00
document . getElementById ( 'appSettingsShowLifecycleLog' ). checked = settings . showLifecycleLog ?? defaults . showLifecycleLog ?? false ;
2026-06-10 12:16:47 +02:00
document . getElementById ( 'appSettingsShowResponseViewer' ). checked = settings . showResponseViewer ?? defaults . showResponseViewer ?? false ;
2026-07-24 14:50:43 +02:00
document . getElementById ( 'appSettingsShowFileViewerButton' ). checked = settings . showFileViewerButton ?? defaults . showFileViewerButton ?? true ;
2026-06-14 21:21:06 +02:00
document . getElementById ( 'appSettingsShowAttachmentsButton' ). checked = settings . showAttachmentsButton ?? defaults . showAttachmentsButton ?? false ;
2026-06-13 19:46:50 +02:00
document . getElementById ( 'appSettingsSkin' ). value = settings . skin ?? defaults . skin ?? 'daylight-blue' ;
2026-08-04 12:31:52 +02:00
// Entrance animations. Deliberately NOT part of the settings payload: the
// styles persist to their own localStorage keys via setAnimTheme(), which
// keeps them per-device without touching the .strict() SettingsUpdateSchema.
this . _syncEntranceAnimSetting ? .();
2026-07-01 20:41:38 -05:00
// WebGL renderer (desktop only — mobile always uses the DOM renderer, so hide
// the toggle there so it can't promise something that won't apply).
document . getElementById ( 'appSettingsWebglRenderer' ). checked = settings . webglRendererEnabled ?? defaults . webglRendererEnabled ?? true ;
const webglItem = document . getElementById ( 'appSettingsWebglRendererItem' );
if ( webglItem ) webglItem . style . display = MobileDetection . getDeviceType () === 'desktop' ? '' : 'none' ;
2026-06-10 11:12:19 +02:00
document . getElementById ( 'appSettingsShowMonitor' ). checked = settings . showMonitor ?? defaults . showMonitor ?? false ;
2026-03-14 21:24:22 +01:00
document . getElementById ( 'appSettingsShowProjectInsights' ). checked = settings . showProjectInsights ?? defaults . showProjectInsights ?? false ;
document . getElementById ( 'appSettingsShowFileBrowser' ). checked = settings . showFileBrowser ?? defaults . showFileBrowser ?? false ;
document . getElementById ( 'appSettingsShowSubagents' ). checked = settings . showSubagents ?? defaults . showSubagents ?? false ;
2026-06-15 08:40:05 +02:00
document . getElementById ( 'appSettingsShowUltracodeAgents' ). checked = settings . showUltracodeAgents ?? defaults . showUltracodeAgents ?? false ;
2026-08-09 15:55:51 +02:00
// Approvals Inbox: synced, default OFF (opt-in; only an explicit true enables).
document . getElementById ( 'appSettingsApprovalsInbox' ). checked = settings . approvalsInboxEnabled === true ;
2026-09-15 10:12:31 +08:00
// Custom Model Endpoint Profiles: synced, default OFF. The toggle governs both
// the Run-menu picker's generated entries and this settings panel's visibility;
2026-09-16 07:00:55 +08:00
// the endpoint list itself is server state, loaded on demand below.
2026-09-15 10:12:31 +08:00
document . getElementById ( 'appSettingsCustomModelEndpoints' ). checked = settings . customModelEndpointsEnabled === true ;
2026-09-16 07:00:55 +08:00
// Assigning .checked above does not fire onchange, so the body's visibility
// (and its lazy load) needs an explicit sync on every open, not just a save.
this . applyCustomModelEndpointsVisibility ();
2026-09-24 07:48:26 +08:00
// CLI management (docs/cli-enable-disable-plan.md): synced, default OFF.
document . getElementById ( 'appSettingsCliManagement' ). checked = settings . cliManagementEnabled === true ;
// Same reasoning as applyCustomModelEndpointsVisibility above: assigning
// .checked fires no onchange, so the list's visibility (and lazy load)
// needs an explicit sync on every open, not just a save.
this . applyCliManagementVisibility ();
2026-10-02 21:15:31 +08:00
// MCP server sync: synced, default OFF; same explicit-sync reasoning as above.
2026-10-04 23:43:36 +02:00
// The routes read the SAVED setting, so remember what it was on open: switching it on
// here does nothing server-side until Save (see mcpSync()).
this . _mcpSyncSavedOn = settings . mcpSyncEnabled === true ;
document . getElementById ( 'appSettingsMcpSync' ). checked = this . _mcpSyncSavedOn ;
2026-10-02 21:15:31 +08:00
this . applyMcpSyncVisibility ();
2026-10-02 22:06:29 +08:00
this . loadWebhook ();
2026-08-09 23:05:11 +02:00
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
document . getElementById ( 'appSettingsReadMyMind' ). checked = settings . readMyMindEnabled === true ;
2026-06-15 16:33:54 +02:00
document . getElementById ( 'appSettingsUltracodeFloatingWindows' ). checked =
settings . ultracodeFloatingWindows ?? defaults . ultracodeFloatingWindows ?? false ;
2026-06-08 06:01:15 +02:00
document . getElementById ( 'appSettingsShowMultiMonitorButton' ). checked = settings . showMultiMonitorButton ?? defaults . showMultiMonitorButton ?? false ;
2026-09-15 22:13:00 -05:00
document . getElementById ( 'appSettingsShowSplitButton' ). checked = settings . showSplitButton ?? defaults . showSplitButton ?? false ;
2026-07-30 14:27:45 +02:00
document . getElementById ( 'appSettingsShowPlanUsageLimits' ). checked = this . planUsageChipEnabled ( settings );
2026-06-17 23:19:54 +08:00
document . getElementById ( 'appSettingsShowRedrawButton' ). checked = settings . showRedrawButton ?? defaults . showRedrawButton ?? false ;
2026-09-07 22:36:50 -04:00
// Phone overview home screen: only meaningful under 600px, so the row is
2026-08-04 15:02:46 +02:00
// hidden elsewhere rather than offering a toggle that changes nothing.
2026-08-12 02:30:08 +02:00
// Spawn lineage lines: desktop-only (the overlay sits UNDER the fixed mobile
// header), so the row is hidden elsewhere rather than offering a toggle that
// changes nothing. Default ON — only an explicit false turns it off.
document . getElementById ( 'appSettingsLineageLines' ). checked = settings . sessionLineageLines ?? defaults . sessionLineageLines ?? true ;
2026-09-15 17:59:16 +02:00
// Auto-name sessions: synced, default OFF (opt-in; only an explicit true enables).
document . getElementById ( 'appSettingsAutoNameSessions' ). checked = settings . autoNameSessions === true ;
2026-08-12 02:30:08 +02:00
const lineageItem = document . getElementById ( 'appSettingsLineageLinesItem' );
if ( lineageItem ) lineageItem . style . display = MobileDetection . getDeviceType () === 'desktop' ? '' : 'none' ;
2026-08-04 15:02:46 +02:00
document . getElementById ( 'appSettingsMobileOverview' ). checked = settings . mobileOverviewEnabled ?? defaults . mobileOverviewEnabled ?? false ;
const mobileOverviewItem = document . getElementById ( 'appSettingsMobileOverviewItem' );
2026-08-10 03:59:43 +02:00
if ( mobileOverviewItem ) mobileOverviewItem . style . display = MobileDetection . getDeviceType () === 'mobile' ? '' : 'none' ;
2026-07-30 14:27:45 +02:00
// Session Manager, Away Digest and Cron buttons all default OFF (opt-in under
// Display → Header Displays; the Cron button also ships with btn-cron--hidden
// in the template, so an unchecked box and a hidden button stay consistent).
2026-07-19 17:59:23 +02:00
document . getElementById ( 'appSettingsShowSessionButton' ). checked = settings . showSessionButton ?? defaults . showSessionButton ?? false ;
document . getElementById ( 'appSettingsShowAwayDigestButton' ). checked = settings . showAwayDigestButton ?? defaults . showAwayDigestButton ?? false ;
2026-07-20 14:36:18 +02:00
document . getElementById ( 'appSettingsShowCronButton' ). checked = settings . showCronButton ?? defaults . showCronButton ?? false ;
2026-06-08 17:01:17 +02:00
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
// can't promise something that won't work.
const gestureItem = document . getElementById ( 'appSettingsGestureControlItem' );
if ( gestureItem ) gestureItem . style . display = window . __codemanGestureAvailable ? '' : 'none' ;
2026-06-08 06:01:15 +02:00
document . getElementById ( 'appSettingsGestureControl' ). checked = settings . gestureControlEnabled ?? defaults . gestureControlEnabled ?? false ;
2026-03-14 21:24:22 +01:00
document . getElementById ( 'appSettingsSubagentTracking' ). checked = settings . subagentTrackingEnabled ?? defaults . subagentTrackingEnabled ?? true ;
document . getElementById ( 'appSettingsSubagentActiveTabOnly' ). checked = settings . subagentActiveTabOnly ?? defaults . subagentActiveTabOnly ?? true ;
document . getElementById ( 'appSettingsImageWatcherEnabled' ). checked = settings . imageWatcherEnabled ?? defaults . imageWatcherEnabled ?? false ;
document . getElementById ( 'appSettingsTunnelEnabled' ). checked = settings . tunnelEnabled ?? false ;
this . loadTunnelStatus ();
document . getElementById ( 'appSettingsLocalEcho' ). checked = settings . localEchoEnabled ?? MobileDetection . isTouchDevice ();
2026-08-20 10:46:11 +02:00
// Auto Copy (copy-on-select): per-device, default OFF everywhere. It quietly
// overwrites the system clipboard on a gesture the user may have meant only as
// a way to read, so it is opt-in rather than a default anyone has to discover.
document . getElementById ( 'appSettingsAutoCopySelection' ). checked = settings . autoCopySelection === true ;
2026-09-22 08:15:57 +02:00
// Default ON, so an absent key reads as enabled rather than as off.
document . getElementById ( 'appSettingsCopyStripMargin' ). checked = settings . copyStripMargin !== false ;
2026-08-19 00:45:19 +02:00
document . getElementById ( 'appSettingsTerminalFont' ). value = settings . terminalFontFamily || '' ;
2026-09-14 14:09:13 +02:00
this . populateTerminalFontWeight ( document . getElementById ( 'appSettingsTerminalFontWeight' ), settings . terminalFontWeight );
this . populateTerminalFontWeight (
document . getElementById ( 'appSettingsTerminalFontWeightBold' ),
settings . terminalFontWeightBold
);
2026-07-16 09:33:39 +02:00
document . getElementById ( 'appSettingsTerminalWheelLocal' ). checked =
settings . terminalWheelLocalScrollback ?? defaults . terminalWheelLocalScrollback ?? false ;
2026-06-09 10:26:09 -04:00
document . getElementById ( 'appSettingsCjkInput' ). checked = settings . cjkInputEnabled ?? defaults . cjkInputEnabled ?? false ;
2026-04-03 04:17:33 +02:00
document . getElementById ( 'appSettingsExtendedKeyboardBar' ). checked = settings . extendedKeyboardBar ?? false ;
2026-03-14 21:24:22 +01:00
document . getElementById ( 'appSettingsTabTwoRows' ). checked = settings . tabTwoRows ?? defaults . tabTwoRows ?? false ;
2026-08-23 12:21:02 -04:00
document . getElementById ( 'appSettingsTabOrientation' ). value =
settings . tabOrientation ?? defaults . tabOrientation ?? 'horizontal' ;
const tabRailWidth = window . CodemanTabRail ? . resolveWidth ({
2026-08-24 04:45:02 +02:00
// Same default resolution as applyTabRailWidth(): a rail that has never
// been sized shows the width it is actually rendering at, which for
2026-08-25 18:26:07 +02:00
// detailed rows is the Wide preset rather than 256. The rich-aware
// default must come BEFORE the per-device defaults blob: the handheld
// blob carries tabRailWidth: 256, which applyTabRailWidth() never reads,
// so consulting it first showed a tablet's unsized rich rail as 256 while
// it rendered at 320 — and a routine Save then PERSISTED the 256.
width : settings . tabRailWidth ?? this . _defaultTabRailWidth ? .() ?? defaults . tabRailWidth ?? 256 ,
2026-08-23 12:21:02 -04:00
}) ?? 256 ;
this . syncTabRailWidthSetting ? .( tabRailWidth );
2026-08-24 04:45:02 +02:00
document . getElementById ( 'appSettingsTabRailDetail' ). value =
settings . tabRailDetail ?? defaults . tabRailDetail ?? 'rich' ;
2026-09-12 06:09:28 +02:00
document . getElementById ( 'appSettingsTabRailSort' ). value =
settings . tabRailSort ?? defaults . tabRailSort ?? 'activity' ;
2026-08-08 13:34:05 +02:00
document . getElementById ( 'appSettingsShowTabDetachButton' ). checked = settings . showTabDetachButton ?? defaults . showTabDetachButton ?? false ;
2026-08-04 12:38:36 +02:00
document . getElementById ( 'appSettingsSessionListLayout' ). value =
settings . sessionListLayout ?? defaults . sessionListLayout ?? 'header' ;
2026-08-23 12:21:02 -04:00
const sessionSidebarFontSize = this . resolveSessionSidebarFontSize (
settings . sessionSidebarFontSize ?? defaults . sessionSidebarFontSize
);
document . getElementById ( 'appSettingsSessionSidebarFontSize' ). value = String ( sessionSidebarFontSize );
document . getElementById ( 'appSettingsSessionSidebarFontSizeValue' ). textContent = ` ${ sessionSidebarFontSize } px` ;
2026-03-14 21:24:22 +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-08-05 01:14:53 +02:00
// Codex CLI settings. The inputs are always populated (and always read back
// by saveAppSettings), even when the tab is hidden below, so a user without
// codex installed can never silently wipe the codex prefs of an instance
// that does have it.
2026-06-10 09:31:58 -04:00
document . getElementById ( 'appSettingsCodexDangerouslyBypassApprovals' ). checked =
settings . codexDangerouslyBypassApprovals ?? false ;
2026-07-29 03:39:07 +03:00
document . getElementById ( 'appSettingsCodexAnimations' ). checked =
settings . codexAnimationsEnabled ?? false ;
2026-08-05 01:14:53 +02:00
this . _applyCodexSettingsVisibility ();
2026-03-14 21:24:22 +01:00
// Claude Permissions settings
document . getElementById ( 'appSettingsAgentTeams' ). checked = settings . agentTeamsEnabled ?? false ;
2026-08-09 03:30:15 +02:00
document . getElementById ( 'appSettingsAgentSkill' ). checked = settings . agentSkillEnabled ?? false ;
2026-08-16 07:05:10 +02:00
// Default ON: an absent key is a user who has never seen this setting, and OFF
// for them means no tab alerts in any workspace Codeman did not scaffold.
document . getElementById ( 'appSettingsWorkspaceHooks' ). checked = settings . workspaceHooksEnabled !== false ;
2026-06-10 16:47:05 +02:00
document . getElementById ( 'appSettingsClaudeModel' ). value = settings . claudeModel ?? '' ;
2026-03-28 16:49:55 +01:00
document . getElementById ( 'appSettingsOpusContext1m' ). checked = settings . opusContext1mEnabled ?? false ;
2026-06-13 14:26:06 -04:00
document . getElementById ( 'appSettingsRemoteAutoReconnect' ). checked = settings . remoteAutoReconnect ?? true ;
2026-04-16 15:54:59 +08:00
document . getElementById ( 'appSettingsThinkingEffort' ). value = settings . thinkingEffort ?? '' ;
2026-10-04 19:05:43 +02:00
document . getElementById ( 'appSettingsClaudeAdvisor' ). value = settings . claudeAdvisorModel ?? '' ;
2026-03-14 21:24:22 +01:00
// CPU Priority settings
const niceSettings = settings . nice || {};
document . getElementById ( 'appSettingsNiceEnabled' ). checked = niceSettings . enabled ?? false ;
document . getElementById ( 'appSettingsNiceValue' ). value = niceSettings . niceValue ?? 10 ;
// Model configuration (loaded from server)
this . loadModelConfigForSettings ();
2026-09-16 07:00:55 +08:00
// Custom Model Endpoint Profiles' own load is gated on the toggle above (see
// applyCustomModelEndpointsVisibility) — unlike model config, this GET is
// pointless work with the feature off, so it is not fired unconditionally.
2026-03-14 21:24:22 +01:00
// Notification settings
const notifPrefs = this . notificationManager ? . preferences || {};
document . getElementById ( 'appSettingsNotifEnabled' ). checked = notifPrefs . enabled ?? true ;
document . getElementById ( 'appSettingsNotifBrowser' ). checked = notifPrefs . browserNotifications ?? false ;
document . getElementById ( 'appSettingsNotifAudio' ). checked = notifPrefs . audioAlerts ?? false ;
document . getElementById ( 'appSettingsNotifStuckMins' ). value = Math . round (( notifPrefs . stuckThresholdMs || 600000 ) / 60000 );
document . getElementById ( 'appSettingsNotifCritical' ). checked = ! notifPrefs . muteCritical ;
document . getElementById ( 'appSettingsNotifWarning' ). checked = ! notifPrefs . muteWarning ;
document . getElementById ( 'appSettingsNotifInfo' ). checked = ! notifPrefs . muteInfo ;
// Push notification settings
document . getElementById ( 'appSettingsPushEnabled' ). checked = !! this . _pushSubscription ;
this . _updatePushUI ( !! this . _pushSubscription );
// Per-event-type preferences
const eventTypes = notifPrefs . eventTypes || {};
// Permission prompts
const permPref = eventTypes . permission_prompt || {};
document . getElementById ( 'eventPermissionEnabled' ). checked = permPref . enabled ?? true ;
document . getElementById ( 'eventPermissionBrowser' ). checked = permPref . browser ?? true ;
document . getElementById ( 'eventPermissionPush' ). checked = permPref . push ?? false ;
document . getElementById ( 'eventPermissionAudio' ). checked = permPref . audio ?? true ;
// Questions (elicitation_dialog)
const questionPref = eventTypes . elicitation_dialog || {};
document . getElementById ( 'eventQuestionEnabled' ). checked = questionPref . enabled ?? true ;
document . getElementById ( 'eventQuestionBrowser' ). checked = questionPref . browser ?? true ;
document . getElementById ( 'eventQuestionPush' ). checked = questionPref . push ?? false ;
document . getElementById ( 'eventQuestionAudio' ). checked = questionPref . audio ?? true ;
// Session idle (idle_prompt)
const idlePref = eventTypes . idle_prompt || {};
document . getElementById ( 'eventIdleEnabled' ). checked = idlePref . enabled ?? true ;
document . getElementById ( 'eventIdleBrowser' ). checked = idlePref . browser ?? true ;
document . getElementById ( 'eventIdlePush' ). checked = idlePref . push ?? false ;
document . getElementById ( 'eventIdleAudio' ). checked = idlePref . audio ?? false ;
// Response complete (stop)
const stopPref = eventTypes . stop || {};
2026-07-28 23:20:01 +03:00
document . getElementById ( 'eventStopEnabled' ). checked = stopPref . enabled ?? false ;
2026-03-14 21:24:22 +01:00
document . getElementById ( 'eventStopBrowser' ). checked = stopPref . browser ?? false ;
document . getElementById ( 'eventStopPush' ). checked = stopPref . push ?? false ;
document . getElementById ( 'eventStopAudio' ). checked = stopPref . audio ?? false ;
// Respawn cycles
const respawnPref = eventTypes . respawn_cycle || {};
document . getElementById ( 'eventRespawnEnabled' ). checked = respawnPref . enabled ?? true ;
document . getElementById ( 'eventRespawnBrowser' ). checked = respawnPref . browser ?? false ;
document . getElementById ( 'eventRespawnPush' ). checked = respawnPref . push ?? false ;
document . getElementById ( 'eventRespawnAudio' ). checked = respawnPref . audio ?? false ;
// Task complete (ralph_complete)
const ralphPref = eventTypes . ralph_complete || {};
document . getElementById ( 'eventRalphEnabled' ). checked = ralphPref . enabled ?? true ;
document . getElementById ( 'eventRalphBrowser' ). checked = ralphPref . browser ?? true ;
document . getElementById ( 'eventRalphPush' ). checked = ralphPref . push ?? false ;
document . getElementById ( 'eventRalphAudio' ). checked = ralphPref . audio ?? true ;
// Subagent activity (subagent_spawn and subagent_complete)
const subagentPref = eventTypes . subagent_spawn || {};
document . getElementById ( 'eventSubagentEnabled' ). checked = subagentPref . enabled ?? false ;
document . getElementById ( 'eventSubagentBrowser' ). checked = subagentPref . browser ?? false ;
document . getElementById ( 'eventSubagentPush' ). checked = subagentPref . push ?? false ;
document . getElementById ( 'eventSubagentAudio' ). checked = subagentPref . audio ?? false ;
// Update permission status display (compact format for new grid layout)
const permStatus = document . getElementById ( 'notifPermissionStatus' );
if ( permStatus && typeof Notification !== 'undefined' ) {
const perm = Notification . permission ;
permStatus . textContent = perm === 'granted' ? '\u2713' : perm === 'denied' ? '\u2717' : '?' ;
permStatus . classList . remove ( 'granted' , 'denied' );
if ( perm === 'granted' ) permStatus . classList . add ( 'granted' );
else if ( perm === 'denied' ) permStatus . classList . add ( 'denied' );
}
// Voice settings (loaded from localStorage only)
const voiceCfg = VoiceInput . _getDeepgramConfig ();
document . getElementById ( 'voiceDeepgramKey' ). value = voiceCfg . apiKey || '' ;
document . getElementById ( 'voiceLanguage' ). value = voiceCfg . language || 'en-US' ;
2026-08-10 11:19:51 +02:00
document . getElementById ( 'voiceKeyterms' ). value = voiceCfg . keyterms || DEFAULT_VOICE_KEYTERMS ;
2026-03-14 21:24:22 +01:00
document . getElementById ( 'voiceInsertMode' ). value = voiceCfg . insertMode || 'direct' ;
2026-08-10 11:19:51 +02:00
document . getElementById ( 'voiceProvider' ). value = voiceCfg . provider || 'auto' ;
document . getElementById ( 'appSettingsClaudeVoice' ). checked = settings . claudeVoiceEnabled ?? false ;
2026-03-14 21:24:22 +01:00
// Reset key visibility to hidden
const keyInput = document . getElementById ( 'voiceDeepgramKey' );
keyInput . type = 'password' ;
document . getElementById ( 'voiceKeyToggleBtn' ). textContent = 'Show' ;
2026-08-10 11:19:51 +02:00
// Update provider status. The Claude row needs a fresh server probe: the
// setting is synced, so another device may have flipped it since page load.
this . _renderVoiceProviderStatus ();
VoiceInput . refreshClaudeStatus (). then (() => this . _renderVoiceProviderStatus ());
2026-03-14 21:24:22 +01:00
2026-06-09 01:55:54 +02:00
// Updates section — show current version, reset transient result/progress UI.
this . _initUpdatesSection ();
2026-08-10 03:59:43 +02:00
// Model cards + effort segment are views over the hidden <select>s above,
// so they must be synced AFTER those have been given their stored values.
this . _initSettingsNav ();
this . _syncSettingsChips ();
this . _syncModelCards ();
this . _syncEffortSegment ();
2026-10-04 19:05:43 +02:00
this . _syncAdvisorSegment ();
2026-08-10 12:29:00 +02:00
// Back to the top of the document (one scroll, not a tab reset). Updates is
// first now: the version this install is running, and whether a newer one is
// waiting, are the two things worth seeing before any preference. The rest of
// the system settings (paths, automation, remote access) tail the document.
this . switchSettingsTab ( 'settings-updates' );
2026-03-14 21:24:22 +01:00
const modal = document . getElementById ( 'appSettingsModal' );
modal . classList . add ( 'active' );
// Activate focus trap
this . activeFocusTrap = new FocusTrap ( modal );
this . activeFocusTrap . activate ();
},
2026-08-05 01:14:53 +02:00
/**
2026-08-10 03:59:43 +02:00
* Show the App Settings "Codex" group only on instances where the codex binary
* actually resolves. Both settings in it (approval bypass, animated status
* effects) are passed to `codex` at launch, so on a box without codex the
* group is a promise nothing can keep.
2026-08-05 01:14:53 +02:00
*
2026-08-05 01:53:43 +02:00
* Availability comes from the injected `window.__codemanCliAvailable`, shared
2026-08-10 03:59:43 +02:00
* with the welcome buttons and the run-mode dropdown, so the group never
* flickers in and back out. The inputs stay in the DOM either way, so a user
* without codex can never silently wipe the codex prefs of an instance that
* has it (openAppSettings/saveAppSettings still read and write them).
2026-08-05 01:53:43 +02:00
*
* Note the inverted default versus the run buttons: an UNKNOWN flag hides this
2026-08-10 03:59:43 +02:00
* group. Hiding it costs a user nothing, whereas hiding a run button would
* leave a working install with nothing to click.
2026-08-05 01:14:53 +02:00
*/
_applyCodexSettingsVisibility () {
2026-08-10 03:59:43 +02:00
const group = document . getElementById ( 'appSettingsCodexGroup' );
if ( group ) group . style . display = window . __codemanCliAvailable ? . codex === true ? '' : 'none' ;
2026-08-05 01:14:53 +02:00
},
2026-08-10 03:59:43 +02:00
/**
* Scroll the settings document to a section.
*
* Kept under the historical `switchSettingsTab` name because it is the shared
* entry point: openAppSettings() calls it, and admin-ui.js's injected Users
* entry routes through it too. Sections are never hidden any more — the rail
* is a table of contents over ONE document, so "switching" is a scroll.
*/
switchSettingsTab ( sectionId ) {
// The Shortcuts list renders lazily so it reflects the CURRENT registry
// (defaults + overrides) every time it is reached.
if ( sectionId === 'settings-shortcuts' ) this . renderShortcutSettingsList ? .();
const doc = document . getElementById ( 'appSettingsDoc' );
const section = document . getElementById ( sectionId );
if ( doc && section && typeof section . offsetTop === 'number' ) {
// On phones the jump pill is sticky at the top of the document, so land
// the section head below it instead of underneath it.
const jump = document . getElementById ( 'appSettingsJump' );
const inset = jump && jump . offsetParent ? jump . offsetHeight + 16 : 6 ;
doc . scrollTop = Math . max ( 0 , section . offsetTop - inset );
}
this . _setActiveSettingsSection ( sectionId );
},
/** Paint the rail + jump pill for the section currently in view. */
_setActiveSettingsSection ( sectionId ) {
2026-03-14 21:24:22 +01:00
const modal = document . getElementById ( 'appSettingsModal' );
2026-08-10 03:59:43 +02:00
if ( ! modal || typeof modal . querySelectorAll !== 'function' ) return ;
let active = null ;
modal . querySelectorAll ( '.set-rail-item' ). forEach ( item => {
const on = item . dataset . section === sectionId ;
item . classList . toggle ( 'active' , on );
if ( on ) active = item ;
2026-03-14 21:24:22 +01:00
});
2026-08-10 03:59:43 +02:00
modal . querySelectorAll ( '.set-jump-row' ). forEach ( row => {
row . classList . toggle ( 'active' , row . dataset . section === sectionId );
2026-03-14 21:24:22 +01:00
});
2026-08-10 03:59:43 +02:00
const label = document . getElementById ( 'appSettingsJump' ) ? . querySelector ( '.set-jump-label' );
if ( label && active ) label . textContent = active . textContent . trim ();
const ico = document . getElementById ( 'appSettingsJump' ) ? . querySelector ( '.set-jump-ico' );
const src = active ? . querySelector ( 'svg' );
if ( ico && src ) ico . innerHTML = src . innerHTML ;
},
/**
* Wire the settings navigation once per page: rail clicks, the phone jump
* menu, scroll-spy, live search, chip/card/segment views over the real inputs,
* and the collapsible Advanced group. Idempotent — openAppSettings() calls it
* on every open, and re-registering listeners on every open would multiply
* them across a long-lived tab.
*/
_initSettingsNav () {
const modal = document . getElementById ( 'appSettingsModal' );
const doc = document . getElementById ( 'appSettingsDoc' );
if ( ! modal || ! doc || typeof modal . querySelectorAll !== 'function' ) return ;
this . _buildModelCards ();
this . _buildEffortSegment ();
2026-10-04 19:05:43 +02:00
this . _buildAdvisorSegment ();
2026-08-10 03:59:43 +02:00
// Rebuilt on every open: admin-ui.js appends its Users entry to the rail
// after the first open, and the menu must not drift from the rail.
this . _buildSettingsJumpMenu ();
if ( modal . dataset . navReady === '1' ) return ;
modal . dataset . navReady = '1' ;
// Delegated so rail entries injected later (Users) work without rewiring.
modal . querySelector ( '.set-rail-items' ) ? . addEventListener ( 'click' , e => {
const item = e . target . closest ? .( '.set-rail-item' );
if ( item ? . dataset . section ) this . switchSettingsTab ( item . dataset . section );
});
document . getElementById ( 'appSettingsJumpMenu' ) ? . addEventListener ( 'click' , e => {
const row = e . target . closest ? .( '.set-jump-row' );
if ( ! row ? . dataset . section ) return ;
this . _toggleSettingsJump ( false );
this . switchSettingsTab ( row . dataset . section );
});
document . getElementById ( 'appSettingsJump' ) ? . addEventListener ( 'click' , () => this . _toggleSettingsJump ());
document . getElementById ( 'appSettingsJumpVeil' ) ? . addEventListener ( 'click' , () => this . _toggleSettingsJump ( false ));
// Scroll-spy: the rail follows the document rather than driving it.
doc . addEventListener ( 'scroll' , () => {
if ( this . _settingsSpyQueued ) return ;
this . _settingsSpyQueued = true ;
requestAnimationFrame (() => {
this . _settingsSpyQueued = false ;
const sections = [... doc . querySelectorAll ( '.set-section' )]. filter ( s => s . offsetParent !== null );
if ( ! sections . length ) return ;
let current = sections [ 0 ]. id ;
for ( const s of sections ) {
if ( s . offsetTop - doc . scrollTop <= 140 ) current = s . id ;
}
this . _setActiveSettingsSection ( current );
});
});
const search = document . getElementById ( 'appSettingsSearch' );
search ? . addEventListener ( 'input' , () => this . _filterSettings ( search . value ));
// Chips are labels wrapping the real checkbox; mirror the checked state onto
// the label so the styling does not depend on :has() support.
modal . querySelectorAll ( '.set-chip input' ). forEach ( input => {
input . addEventListener ( 'change' , () => this . _syncSettingsChips ());
});
const advHead = modal . querySelector ( '.set-group-head-toggle' );
const advGroup = advHead ? . closest ( '.set-group-advanced' );
if ( advHead && advGroup ) {
const toggle = () => {
const open = advGroup . classList . toggle ( 'open' );
advHead . setAttribute ( 'aria-expanded' , open ? 'true' : 'false' );
};
advHead . addEventListener ( 'click' , toggle );
advHead . addEventListener ( 'keydown' , e => {
if ( e . key === 'Enter' || e . key === ' ' ) {
e . preventDefault ();
toggle ();
}
});
}
document . getElementById ( 'appSettingsOpusContext1m' ) ? . addEventListener ( 'change' , () => this . _applyModelSelection ());
},
/** Phone jump menu, mirrored from the rail so the two can never drift. */
_buildSettingsJumpMenu () {
const modal = document . getElementById ( 'appSettingsModal' );
const menu = document . getElementById ( 'appSettingsJumpMenu' );
if ( ! modal || ! menu ) return ;
menu . innerHTML = '' ;
modal . querySelectorAll ( '.set-rail-item' ). forEach ( item => {
const row = document . createElement ( 'button' );
row . type = 'button' ;
row . className = 'set-jump-row' ;
row . dataset . section = item . dataset . section ;
row . innerHTML = item . innerHTML ;
const section = document . getElementById ( item . dataset . section );
const count = section ? section . querySelectorAll ( 'input, select' ). length : 0 ;
if ( count ) {
const n = document . createElement ( 'span' );
n . className = 'set-jump-count' ;
n . textContent = String ( count );
row . appendChild ( n );
}
menu . appendChild ( row );
});
},
_toggleSettingsJump ( force ) {
const modal = document . getElementById ( 'appSettingsModal' );
if ( ! modal ) return ;
const open = force === undefined ? ! modal . classList . contains ( 'jump-open' ) : force ;
modal . classList . toggle ( 'jump-open' , open );
document . getElementById ( 'appSettingsJump' ) ? . setAttribute ( 'aria-expanded' , open ? 'true' : 'false' );
},
2026-08-10 11:19:56 +02:00
/**
* Mirror checkbox state onto the chip labels (see _initSettingsNav).
*
* Covers Session Options too: it shares the `set-*` surface, and its cycle-step
* chips would otherwise depend on `:has()` alone for their checked styling.
*/
2026-08-10 03:59:43 +02:00
_syncSettingsChips () {
2026-08-10 11:19:56 +02:00
document . querySelectorAll ( '#appSettingsModal .set-chip, #sessionOptionsModal .set-chip' ). forEach ( chip => {
2026-08-10 03:59:43 +02:00
chip . classList . toggle ( 'is-on' , !! chip . querySelector ( 'input' ) ? . checked );
});
2026-08-10 10:58:05 +02:00
this . _syncLayoutPreview ();
},
/**
* Redraw the Header & Panels live preview from the chips above it.
*
* The preview is a scale model of the app, not a second list of settings, so
* every icon is CLONED from the chip that owns it (`.set-chip-ico`): each icon
* has exactly ONE copy in index.html and a chip can never drift from the button
* it previews. A chip joins the preview purely by carrying `data-preview`
* (which slot) and `data-preview-order` (where in that slot); nothing here
* needs to know the setting's name.
*
* `data-preview-text` replaces the icon with a text token for the header
* entries that are readouts rather than buttons (plan usage, CPU, font size).
*/
_syncLayoutPreview () {
const modal = document . getElementById ( 'appSettingsModal' );
if ( ! modal || typeof modal . querySelectorAll !== 'function' ) return ;
const slots = {
header : document . getElementById ( 'appSettingsPreviewHeader' ),
panel : document . getElementById ( 'appSettingsPreviewPanels' ),
toolbar : document . getElementById ( 'appSettingsPreviewToolbar' ),
float : document . getElementById ( 'appSettingsPreviewFloats' ),
};
if ( ! slots . header ) return ;
Object . values ( slots ). forEach ( el => {
if ( el ) el . innerHTML = '' ;
});
const chips = [... modal . querySelectorAll ( '.set-chip[data-preview]' )]
. filter ( chip => chip . querySelector ( 'input' ) ? . checked )
. sort (( a , b ) => ( Number ( a . dataset . previewOrder ) || 0 ) - ( Number ( b . dataset . previewOrder ) || 0 ));
let shown = 0 ;
for ( const chip of chips ) {
const kind = chip . dataset . preview ;
const slot = slots [ kind ];
if ( ! slot ) continue ;
// The label is the chip's own text; the icon span (if any) is skipped by
// taking the LAST span, which is always the label.
const spans = chip . querySelectorAll ( 'span' );
const label = ( spans [ spans . length - 1 ] ? . textContent || '' ). trim ();
const el = document . createElement ( 'span' );
el . title = label ;
if ( kind === 'header' ) {
const text = chip . dataset . previewText ;
el . className = text ? 'set-preview-chip' : 'set-preview-btn' ;
if ( text ) el . textContent = text ;
else this . _appendPreviewIcon ( el , chip );
} else {
el . className = `set-preview- ${ kind } ` ;
this . _appendPreviewIcon ( el , chip );
const name = document . createElement ( 'span' );
name . textContent = label ;
el . appendChild ( name );
}
slot . appendChild ( el );
shown ++ ;
}
const empty = document . getElementById ( 'appSettingsPreviewEmpty' );
if ( empty ) empty . hidden = shown > 0 ;
},
/** Clone a chip's icon into a preview element (see _syncLayoutPreview). */
_appendPreviewIcon ( target , chip ) {
const icon = chip . querySelector ( '.set-chip-ico' );
if ( ! icon ) return ;
const clone = icon . cloneNode ( true );
clone . classList . remove ( 'set-chip-ico' );
clone . classList . add ( 'set-preview-ico' );
target . appendChild ( clone );
2026-08-10 03:59:43 +02:00
},
/**
* Build the model picker cards from the hidden <select>'s own options, so the
* select stays the single source of truth that openAppSettings/saveAppSettings
* read and write by id. The `[1m]` variants are folded away: context width is a
* property of the chosen model (the "1M context window" switch), not a rival
* setting that silently loses to it.
*/
_buildModelCards () {
const select = document . getElementById ( 'appSettingsClaudeModel' );
const grid = document . getElementById ( 'appSettingsModelCards' );
if ( ! select || ! grid || grid . dataset . built === '1' || ! select . options ) return ;
grid . innerHTML = '' ;
[... select . options ]
. filter ( opt => opt . dataset . variant !== '1m' )
. forEach ( opt => {
const card = document . createElement ( 'button' );
card . type = 'button' ;
card . className = 'set-modelcard' ;
card . setAttribute ( 'role' , 'radio' );
card . dataset . value = opt . value ;
if ( opt . dataset . ctx === '1' ) card . dataset . ctx = '1' ;
const top = document . createElement ( 'span' );
top . className = 'set-mc-top' ;
const name = document . createElement ( 'span' );
name . className = 'set-mc-name' ;
name . textContent = opt . textContent ;
top . appendChild ( name );
const dot = document . createElement ( 'span' );
dot . className = 'set-mc-dot' ;
top . appendChild ( dot );
card . appendChild ( top );
const meta = document . createElement ( 'span' );
meta . className = 'set-mc-meta' ;
meta . textContent = opt . dataset . meta || '' ;
card . appendChild ( meta );
if ( opt . dataset . ctx === '1' ) {
const ctx = document . createElement ( 'span' );
ctx . className = 'set-mc-ctx' ;
ctx . textContent = '1M capable' ;
card . appendChild ( ctx );
}
card . addEventListener ( 'click' , () => {
this . _settingsModelBase = opt . value ;
this . _applyModelSelection ();
});
grid . appendChild ( card );
});
grid . dataset . built = '1' ;
},
/** Derive card + context-switch state from the select's stored value. */
_syncModelCards () {
const select = document . getElementById ( 'appSettingsClaudeModel' );
if ( ! select ) return ;
const value = select . value || '' ;
this . _settingsModelBase = value . endsWith ( '[1m]' ) ? value . slice ( 0 , - 4 ) : value ;
if ( value . endsWith ( '[1m]' )) {
const ctx = document . getElementById ( 'appSettingsOpusContext1m' );
if ( ctx ) ctx . checked = true ;
}
this . _applyModelSelection ();
},
/** Compose card + context switch back into the select's value. */
_applyModelSelection () {
const select = document . getElementById ( 'appSettingsClaudeModel' );
const grid = document . getElementById ( 'appSettingsModelCards' );
if ( ! select || ! grid ) return ;
const base = this . _settingsModelBase || '' ;
let capable = false ;
grid . querySelectorAll ( '.set-modelcard' ). forEach ( card => {
const on = card . dataset . value === base ;
card . classList . toggle ( 'selected' , on );
card . setAttribute ( 'aria-checked' , on ? 'true' : 'false' );
if ( on ) capable = card . dataset . ctx === '1' ;
});
const ctxOn = !! document . getElementById ( 'appSettingsOpusContext1m' ) ? . checked ;
select . value = base && capable && ctxOn ? ` ${ base } [1m]` : base ;
// A model with no 1M variant makes the switch inert; say so instead of
// leaving a toggle that looks like it does something.
const row = document . getElementById ( 'appSettingsContextRow' );
const desc = document . getElementById ( 'appSettingsContextDesc' );
const inert = !! base && ! capable ;
row ? . classList . toggle ( 'set-row-disabled' , inert );
if ( desc ) {
desc . textContent = inert
? 'The selected model has no 1M variant.'
: base
2026-09-24 01:57:35 +02:00
? 'Available for Fable 5.1, Fable 5, Opus 5.5, Opus and Opus 4.6.'
2026-08-10 03:59:43 +02:00
: 'With no model pinned, this starts new sessions on Opus with a 1M window.' ;
}
},
_buildEffortSegment () {
2026-10-04 19:05:43 +02:00
this . _buildSelectSegment ( 'appSettingsThinkingEffort' , 'appSettingsEffortSegment' );
},
_syncEffortSegment () {
this . _syncSelectSegment ( 'appSettingsThinkingEffort' , 'appSettingsEffortSegment' );
},
_buildAdvisorSegment () {
this . _buildSelectSegment ( 'appSettingsClaudeAdvisor' , 'appSettingsAdvisorSegment' );
},
_syncAdvisorSegment () {
this . _syncSelectSegment ( 'appSettingsClaudeAdvisor' , 'appSettingsAdvisorSegment' );
},
/**
* Build a radio segment as a view over a hidden <select>, which stays the single
* source of truth for load/save (the same contract as the model cards).
*/
_buildSelectSegment ( selectId , segId ) {
const select = document . getElementById ( selectId );
const seg = document . getElementById ( segId );
2026-08-10 03:59:43 +02:00
if ( ! select || ! seg || seg . dataset . built === '1' || ! select . options ) return ;
seg . innerHTML = '' ;
[... select . options ]. forEach ( opt => {
const btn = document . createElement ( 'button' );
btn . type = 'button' ;
btn . setAttribute ( 'role' , 'radio' );
btn . dataset . value = opt . value ;
btn . textContent = opt . textContent ;
btn . addEventListener ( 'click' , () => {
select . value = opt . value ;
2026-10-04 19:05:43 +02:00
this . _syncSelectSegment ( selectId , segId );
2026-08-10 03:59:43 +02:00
});
seg . appendChild ( btn );
});
seg . dataset . built = '1' ;
},
2026-10-04 19:05:43 +02:00
_syncSelectSegment ( selectId , segId ) {
const select = document . getElementById ( selectId );
const seg = document . getElementById ( segId );
2026-08-10 03:59:43 +02:00
if ( ! select || ! seg ) return ;
seg . querySelectorAll ( 'button' ). forEach ( btn => {
const on = btn . dataset . value === ( select . value || '' );
btn . classList . toggle ( 'selected' , on );
btn . setAttribute ( 'aria-checked' , on ? 'true' : 'false' );
});
},
/**
* Live filter across every section. Everything stays mounted (that is the
* point of the single-document layout), so a search only hides units that do
* not match, then collapses groups and sections left with nothing visible.
*/
_filterSettings ( query ) {
const doc = document . getElementById ( 'appSettingsDoc' );
if ( ! doc ) return ;
const q = ( query || '' ). trim (). toLowerCase ();
const UNIT = '.set-row, .set-chip, .set-modelgrid, .set-minigrid, .event-type-grid, #appSettingsShortcutsList' ;
const units = [... doc . querySelectorAll ( UNIT )];
let anyVisible = false ;
units . forEach ( unit => {
if ( ! q ) {
unit . classList . remove ( 'set-hit-hidden' );
return ;
}
const hay = ` ${ unit . dataset ? . search || '' } ${ unit . textContent || '' } ` . toLowerCase ();
const hit = hay . includes ( q );
unit . classList . toggle ( 'set-hit-hidden' , ! hit );
if ( hit ) anyVisible = true ;
});
// A chip wrapper is only empty when every chip inside it is hidden.
doc . querySelectorAll ( '.set-chips' ). forEach ( wrap => {
const hasVisible = [... wrap . querySelectorAll ( '.set-chip' )]. some ( c => ! c . classList . contains ( 'set-hit-hidden' ));
wrap . classList . toggle ( 'set-hit-hidden' , !! q && ! hasVisible );
});
doc . querySelectorAll ( '.set-group' ). forEach ( group => {
const hasVisible = [... group . querySelectorAll ( UNIT )]. some ( u => ! u . classList . contains ( 'set-hit-hidden' ));
group . classList . toggle ( 'set-hit-hidden' , !! q && ! hasVisible );
// An Advanced group that matches must open, or the hit stays invisible.
if ( q && hasVisible ) group . classList . add ( 'open' );
});
doc . querySelectorAll ( '.set-section' ). forEach ( section => {
const hasVisible = [... section . querySelectorAll ( '.set-group' )]. some ( g => ! g . classList . contains ( 'set-hit-hidden' ));
section . classList . toggle ( 'set-hit-hidden' , !! q && ! hasVisible );
});
2026-08-10 10:58:05 +02:00
// The live preview sits outside any group, so it survives the sweep above;
// a search is asking for one row, not for the scale model around it.
doc . querySelectorAll ( '.set-preview' ). forEach ( pv => pv . classList . toggle ( 'set-hit-hidden' , !! q ));
2026-08-10 03:59:43 +02:00
const empty = document . getElementById ( 'appSettingsSearchEmpty' );
if ( empty ) empty . hidden = ! q || anyVisible ;
if ( ! q ) doc . querySelectorAll ( '.set-group-advanced' ). forEach ( g => g . classList . remove ( 'open' ));
2026-03-14 21:24:22 +01:00
},
closeAppSettings () {
2026-08-10 03:59:43 +02:00
this . _toggleSettingsJump ( false );
2026-03-14 21:24:22 +01:00
document . getElementById ( 'appSettingsModal' ). classList . remove ( 'active' );
// Deactivate focus trap and restore focus
if ( this . activeFocusTrap ) {
this . activeFocusTrap . deactivate ();
this . activeFocusTrap = null ;
}
},
2026-06-09 01:55:54 +02:00
// ───────────────────────────────────────────────────────────────
// Self-Update (App Settings → Updates). Backend: src/web/self-update.ts.
// ───────────────────────────────────────────────────────────────
/** Friendly label for an in-flight update phase. */
_updatePhaseText ( phase ) {
return {
queued : 'Queued…' ,
preparing : 'Preparing…' ,
stashing : 'Stashing local changes…' ,
fetching : 'Fetching release…' ,
checkout : 'Checking out release…' ,
installing : 'Installing dependencies…' ,
building : 'Building…' ,
restarting : 'Restarting Codeman…' ,
}[ phase ] || phase ;
},
/** Populate the version row and clear transient UI when the modal opens. */
_initUpdatesSection () {
const verEl = this . $ ( 'updateCurrentVersion' );
if ( verEl ) verEl . textContent = ( this . $ ( 'versionDisplay' ) ? . textContent || '' ). trim () || '—' ;
for ( const id of [ 'updateResult' , 'updateActionRow' , 'updateNotes' , 'updateProgress' ]) {
const el = this . $ ( id );
if ( el ) el . style . display = 'none' ;
}
this . _updateCheck = null ;
},
2026-10-02 21:53:31 +08:00
/**
* Settings → Terminal & Input → Key tester: prints what the browser reports for each key event.
* Read-only and local; it never reaches a session. keypress is shown on purpose: that event is
* why a Shift-only Enter used to submit (xterm drops Ctrl/Alt keypresses, not Shift ones).
*/
keyTesterEvent ( ev ) {
const log = document . getElementById ( 'keyTesterLog' );
if ( ! log ) return ;
// Never preventDefault on keydown: that suppresses the keypress this panel exists to show.
// The field is readonly, so nothing is typed into it either way.
const mods = [ 'ctrlKey' , 'shiftKey' , 'altKey' , 'metaKey' ]. filter (( m ) => ev [ m ]). map (( m ) => m . replace ( 'Key' , '' ));
const line =
` ${ ev . type . padEnd ( 8 ) } key= ${ JSON . stringify ( ev . key ) } code= ${ ev . code || '-' } ` +
`mods= ${ mods . join ( '+' ) || 'none' } ` +
( ev . type === 'keypress' ? ` charCode= ${ ev . charCode } ` : '' ) +
( ev . repeat ? ' (repeat)' : '' );
const lines = ( log . textContent ? log . textContent . split ( '\n' ) : []). concat ( line );
log . textContent = lines . slice ( - 14 ). join ( '\n' );
log . style . display = 'block' ;
},
2026-10-02 21:15:31 +08:00
/**
* MCP sync is opt-in (`mcpSyncEnabled`): with the flag off the action row is hidden rather than
* shown disabled, because both endpoints would only answer 403. Called on open and from the
* checkbox's own onchange (assigning .checked fires no change event).
*/
applyMcpSyncVisibility () {
const on = document . getElementById ( 'appSettingsMcpSync' ) ? . checked ?? false ;
const row = document . getElementById ( 'mcpSyncActionRow' );
if ( row ) row . style . display = on ? '' : 'none' ;
const out = this . $ ( 'mcpSyncResult' );
if ( ! on && out ) { out . style . display = 'none' ; out . innerHTML = '' ; }
2026-10-04 23:43:36 +02:00
this . _applyMcpSyncAdminGate ();
},
/**
* Both /api/mcp-sync verbs are admin-only in multi-user mode (they write files in the server
* user's home), so a non-admin gets no MCP group at all, switch included, the same way
* _applyCliManagementAdminGate hides the CLI list. Also wired to `codeman:me`, because
* `window.__codemanUser`'s real role can resolve after settings were opened once.
*/
_applyMcpSyncAdminGate () {
const group = document . getElementById ( 'mcpSyncGroup' );
if ( ! group ) return ;
const me = window . __codemanUser || {};
group . style . display = me . multiUser && me . role !== 'admin' ? 'none' : '' ;
2026-10-02 21:15:31 +08:00
},
2026-10-02 18:01:12 +08:00
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
async mcpSync ( apply ) {
const out = this . $ ( 'mcpSyncResult' );
const show = ( html ) => {
if ( out ) { out . style . display = 'block' ; out . innerHTML = html ; }
};
2026-10-04 23:43:36 +02:00
// Switched on in this modal but not saved yet: the routes would only answer "disabled".
if ( ! this . _mcpSyncSavedOn ) {
show ( 'Save settings to turn MCP sync on first, then reopen Settings to preview or sync.' );
return ;
}
2026-10-02 21:15:31 +08:00
if ( apply && ! confirm ( 'Add missing MCP servers to every installed, enabled CLI\'s config file? Env values and headers on those servers are copied too.' )) return ;
2026-10-02 18:01:12 +08:00
show ( 'Working…' );
const res = apply ? await this . _apiPost ( '/api/mcp-sync' , {}) : await this . _api ( '/api/mcp-sync' );
let body = null ;
try { body = res ? await res . json () : null ; } catch { /* fall through */ }
if ( ! res || ! res . ok || ! body || body . success === false ) {
show ( escapeHtml ( body ? . error || 'MCP sync failed.' ));
return ;
}
const data = body . data ;
const rows = data . targets . map (( t ) => {
2026-10-02 21:15:31 +08:00
if ( t . status === 'absent' ) return `<li><b> ${ escapeHtml ( t . label ) } </b>: not installed, skipped</li>` ;
2026-10-04 23:43:36 +02:00
if ( t . status === 'skipped' ) return `<li><b> ${ escapeHtml ( t . label ) } </b>: not touched ( ${ escapeHtml ( t . error || 'config location unknown' ) } )</li>` ;
2026-10-02 21:15:31 +08:00
if ( t . status === 'unreadable' ) return `<li><b> ${ escapeHtml ( t . label ) } </b>: not touched, file can't be read safely ( ${ escapeHtml ( t . error || 'unreadable' ) } )</li>` ;
if ( t . status === 'failed' ) return `<li><b> ${ escapeHtml ( t . label ) } </b>: failed ( ${ escapeHtml ( t . error || 'error' ) } ); the file may be unchanged</li>` ;
2026-10-02 18:01:12 +08:00
const verb = data . applied ? 'added' : 'would add' ;
const parts = [ t . added . length ? ` ${ verb } ${ t . added . map ( escapeHtml ). join ( ', ' ) } ` : 'up to date' ];
if ( t . skipped . length ) parts . push ( `can't express ${ t . skipped . map ( escapeHtml ). join ( ', ' ) } ` );
2026-10-04 23:43:36 +02:00
const count = ` ${ t . servers . length } server ${ t . servers . length === 1 ? '' : 's' } ` ;
return `<li><b> ${ escapeHtml ( t . label ) } </b> ( ${ count } ): ${ parts . join ( '; ' ) } </li>` ;
2026-10-02 18:01:12 +08:00
});
const conflicts = data . conflicts . length
2026-10-02 21:15:31 +08:00
? `<p>Defined differently across CLIs (each existing definition is kept; the first CLI's is copied where the name is missing): ${ data . conflicts . map ( escapeHtml ). join ( ', ' ) } </p>`
: '' ;
const disabled = data . disabled ? . length
? `<p>Switched off in their own CLI, so not copied: ${ data . disabled . map ( escapeHtml ). join ( ', ' ) } </p>`
2026-10-02 18:01:12 +08:00
: '' ;
2026-10-02 18:11:13 +08:00
const unsupported = data . unsupported ? . length
? `<p>No MCP config support for: ${ data . unsupported . map ( escapeHtml ). join ( ', ' ) } </p>`
: '' ;
2026-10-02 21:15:31 +08:00
show ( `<ul> ${ rows . join ( '' ) } </ul> ${ conflicts }${ disabled }${ unsupported } ` );
2026-10-02 18:01:12 +08:00
},
2026-10-02 22:06:29 +08:00
/**
* Webhook notifications (Settings → Notifications). Server-side config behind /api/webhook, not a
* settings-payload field: the URL is a secret, so it never round-trips through settings.json or
* this page. The URL box is write-only; the status line shows scheme + host only.
2026-10-04 23:42:47 +02:00
*
* Three ways to save, one PUT: the group's own Save, Send test (saves pending edits first, so it
* never tests the old URL while the box shows a new one), and the modal's main Save, which calls
* saveWebhook() beside the settings PUT the same way it saves the model config
* (saveModelConfigFromSettings). `_webhookLoaded` is what loadWebhook() put on screen, so
* `_webhookPending()` can tell an edited group from an untouched one.
2026-10-02 22:06:29 +08:00
*/
_webhookSay ( text , bad = false ) {
const out = document . getElementById ( 'webhookResult' );
if ( ! out ) return ;
out . textContent = text ;
out . style . display = text ? 'block' : 'none' ;
out . style . color = bad ? 'var(--danger, #e5534b)' : '' ;
},
async loadWebhook () {
const group = document . getElementById ( 'webhookGroup' );
if ( ! group ) return ;
const res = await this . _api ( '/api/webhook' );
if ( ! res || ! res . ok ) {
2026-10-04 23:42:47 +02:00
this . _webhookLoaded = null ;
2026-10-02 22:06:29 +08:00
group . style . display = 'none' ; // not an admin in multi-user mode, or the server predates the route
return ;
}
let body = null ;
try { body = await res . json (); } catch { /* leave hidden */ }
2026-10-04 23:42:47 +02:00
if ( ! body || body . success === false ) { this . _webhookLoaded = null ; group . style . display = 'none' ; return ; }
2026-10-02 22:06:29 +08:00
const d = body . data ;
group . style . display = '' ;
document . getElementById ( 'webhookEnabled' ). checked = d . enabled === true ;
document . getElementById ( 'webhookKind' ). value = d . kind ;
document . getElementById ( 'webhookScope' ). value = d . scope ;
const url = document . getElementById ( 'webhookUrl' );
url . value = '' ;
url . placeholder = d . hasUrl ? 'Saved. Paste a new URL to replace it' : 'https://ntfy.sh/your-topic' ;
document . getElementById ( 'webhookUrlHint' ). textContent = d . hasUrl ? `Saved: ${ d . urlMasked } ` : 'Nothing saved yet.' ;
2026-10-04 23:42:47 +02:00
const clearBtn = document . getElementById ( 'webhookClearBtn' );
if ( clearBtn ) clearBtn . style . display = d . hasUrl ? '' : 'none' ;
// Read back from the controls, so a value the <select> does not offer compares as what is shown.
this . _webhookLoaded = {
enabled : document . getElementById ( 'webhookEnabled' ). checked ,
kind : document . getElementById ( 'webhookKind' ). value ,
scope : document . getElementById ( 'webhookScope' ). value ,
};
2026-10-02 22:06:29 +08:00
if ( d . lastResult ) {
const when = new Date ( d . lastResult . at ). toLocaleString ();
this . _webhookSay (
d . lastResult . ok ? `Last delivery succeeded ( ${ when } ).` : `Last delivery failed ( ${ when } ): ${ d . lastResult . error } ` ,
! d . lastResult . ok
);
} else {
this . _webhookSay ( '' );
}
},
2026-10-04 23:42:47 +02:00
/** True when the visible webhook group differs from what loadWebhook() last showed. */
_webhookPending () {
const group = document . getElementById ( 'webhookGroup' );
const loaded = this . _webhookLoaded ;
if ( ! group || group . style . display === 'none' || ! loaded ) return false ;
return (
document . getElementById ( 'webhookUrl' ). value . trim () !== '' ||
document . getElementById ( 'webhookEnabled' ). checked !== loaded . enabled ||
document . getElementById ( 'webhookKind' ). value !== loaded . kind ||
document . getElementById ( 'webhookScope' ). value !== loaded . scope
);
},
/** PUT the group's state. Resolves to '' on success, else the error (also shown in the group). */
2026-10-02 22:06:29 +08:00
async saveWebhook () {
const payload = {
enabled : document . getElementById ( 'webhookEnabled' ). checked ,
kind : document . getElementById ( 'webhookKind' ). value ,
scope : document . getElementById ( 'webhookScope' ). value ,
};
const url = document . getElementById ( 'webhookUrl' ). value . trim ();
2026-10-04 23:42:47 +02:00
if ( url ) payload . url = url ; // blank = keep the saved one (Remove URL is the way to clear it)
2026-10-02 22:06:29 +08:00
const res = await this . _api ( '/api/webhook' , { method : 'PUT' , body : payload });
let body = null ;
try { body = res ? await res . json () : null ; } catch { /* fall through */ }
if ( ! res || ! res . ok || ! body || body . success === false ) {
2026-10-04 23:42:47 +02:00
const error = body ? . error || 'Could not save the webhook.' ;
this . _webhookSay ( error , true );
return error ;
2026-10-02 22:06:29 +08:00
}
await this . loadWebhook ();
this . _webhookSay ( 'Saved.' );
2026-10-04 23:42:47 +02:00
return '' ;
},
/** Delete the saved URL from the server (the API clears on `url: ""`), which also turns the channel off. */
async clearWebhook () {
if ( ! confirm ( 'Remove the saved webhook URL from the server? Webhook alerts stop until you save a new one.' )) return ;
const res = await this . _api ( '/api/webhook' , { method : 'PUT' , body : { url : '' , enabled : false } });
let body = null ;
try { body = res ? await res . json () : null ; } catch { /* fall through */ }
if ( ! res || ! res . ok || ! body || body . success === false ) {
this . _webhookSay ( body ? . error || 'Could not remove the webhook URL.' , true );
return ;
}
await this . loadWebhook ();
this . _webhookSay ( 'Webhook URL removed.' );
2026-10-02 22:06:29 +08:00
},
async testWebhook () {
const btn = document . getElementById ( 'webhookTestBtn' );
if ( btn ) btn . disabled = true ;
try {
2026-10-04 23:42:47 +02:00
if ( this . _webhookPending () && ( await this . saveWebhook ())) return ; // the save's error is already shown
this . _webhookSay ( 'Sending…' );
2026-10-02 22:06:29 +08:00
const res = await this . _apiPost ( '/api/webhook/test' , {});
let body = null ;
try { body = res ? await res . json () : null ; } catch { /* fall through */ }
if ( ! res || ! res . ok || ! body || body . success === false ) {
this . _webhookSay ( body ? . error || 'Could not send the test.' , true );
return ;
}
const r = body . data ;
this . _webhookSay ( r . ok ? 'Test sent. Check your phone or channel.' : `Delivery failed: ${ r . error } ` , ! r . ok );
} finally {
if ( btn ) btn . disabled = false ;
}
},
2026-06-09 01:55:54 +02:00
_setUpdateResult ( html ) {
const el = this . $ ( 'updateResult' );
if ( el ) { el . style . display = 'block' ; el . innerHTML = html ; }
},
_setUpdateProgress ( html ) {
const el = this . $ ( 'updateProgress' );
if ( el ) { el . style . display = 'block' ; el . innerHTML = html ; }
},
/** Manual "Check for updates" — asks the server to query GitHub. */
async checkForUpdate () {
const btn = this . $ ( 'updateCheckBtn' );
if ( btn ) { btn . disabled = true ; btn . textContent = 'Checking…' ; }
const data = await this . _apiJson ( '/api/system/update/check' );
if ( btn ) { btn . disabled = false ; btn . textContent = 'Check now' ; }
const actionRow = this . $ ( 'updateActionRow' );
const notes = this . $ ( 'updateNotes' );
if ( actionRow ) actionRow . style . display = 'none' ;
if ( notes ) notes . style . display = 'none' ;
if ( ! data ) {
this . _setUpdateResult ( 'Could not check for updates. Try again later.' );
return ;
}
this . _updateCheck = data ;
const verEl = this . $ ( 'updateCurrentVersion' );
if ( verEl && data . currentVersion ) verEl . textContent = `v ${ data . currentVersion } ` ;
2026-09-02 19:33:32 +08:00
// `docker-compose` self-updates in place like `git` does — the container
// restarts itself. Anything else cannot.
if ( data . installKind && data . installKind !== 'git' && data . installKind !== 'docker-compose' ) {
const hint =
data . supervisor === 'docker-compose'
? 'Update from the Docker host with <code>docker/Start-Codeman.sh</code>.'
: 'Update with <code>npm i -g aicodeman@latest</code>.' ;
this . _setUpdateResult ( `This install can't update itself ( ${ escapeHtml ( data . installKind ) } ). ${ hint } ` );
2026-06-09 01:55:54 +02:00
return ;
}
if ( data . selfUpdateEnabled === false ) {
this . _setUpdateResult ( 'In-app updates are disabled on this server (CODEMAN_DISABLE_SELF_UPDATE=1).' );
return ;
}
if ( data . error && ! data . updateAvailable ) {
this . _setUpdateResult ( escapeHtml ( data . error ));
return ;
}
2026-09-02 19:33:32 +08:00
// A container release that changes the ENVIRONMENT (Dockerfile, compose file
// or new .env keys) cannot be applied by the container restarting itself, so
// the update button is never offered — the host command is, instead. The
// server re-checks this on POST, so hiding the button is UX, not the gate.
const blockers = data . environment ? . blockers || [];
if ( data . updateAvailable && blockers . length > 0 ) {
const reasons = blockers
. map (( b ) => {
const details = b . details ? . length ? `<br><code> ${ escapeHtml ( b . details . join ( ' ' )) } </code>` : '' ;
return `<li> ${ escapeHtml ( b . message ) }${ details } </li>` ;
})
. join ( '' );
this . _setUpdateResult (
`<strong>v ${ escapeHtml ( data . latestVersion || '' ) } </strong> needs a rebuild on the Docker host` +
` (current v ${ escapeHtml ( data . currentVersion || '' ) } ):<ul> ${ reasons } </ul>` +
`Run <code> ${ escapeHtml ( data . environment ? . hostCommand || 'docker/Start-Codeman.sh' ) } </code> there to apply it.`
);
if ( notes && data . notes ) {
notes . style . display = 'block' ;
notes . textContent = data . notes ;
}
return ;
}
2026-06-09 01:55:54 +02:00
if ( data . updateAvailable && data . latestVersion ) {
this . _setUpdateResult (
`Update available: <strong>v ${ escapeHtml ( data . latestVersion ) } </strong> (current v ${ escapeHtml ( data . currentVersion || '' ) } )`
);
const label = this . $ ( 'updateActionLabel' );
if ( label ) label . textContent = `Update to v ${ data . latestVersion } ` ;
if ( actionRow ) actionRow . style . display = 'flex' ;
const nowBtn = this . $ ( 'updateNowBtn' );
if ( nowBtn ) { nowBtn . disabled = false ; nowBtn . textContent = 'Update now' ; }
if ( notes && data . notes ) {
notes . style . display = 'block' ;
notes . textContent = data . notes ;
}
} else {
this . _setUpdateResult ( `You're up to date (v ${ escapeHtml ( data . currentVersion || '' ) } ).` );
}
},
/** Start the update, then poll status across the service restart. */
async startSelfUpdate () {
const target = this . _updateCheck ? . latestVersion ? `v ${ this . _updateCheck . latestVersion } ` : 'the latest release' ;
if ( ! confirm ( `Update Codeman to ${ target } ? The server will restart and this page will reload.` )) return ;
const btn = this . $ ( 'updateNowBtn' );
if ( btn ) { btn . disabled = true ; btn . textContent = 'Starting…' ; }
const res = await this . _apiPost ( '/api/system/update' , {});
if ( ! res || ! res . ok ) {
let msg = 'Failed to start the update.' ;
2026-06-10 03:41:04 +02:00
try { const j = await res . json (); if ( typeof j ? . error === 'string' && j . error ) msg = j . error ; } catch {}
2026-06-09 01:55:54 +02:00
this . _setUpdateProgress ( `<span style="color:var(--danger,#e5534b)"> ${ escapeHtml ( msg ) } </span>` );
if ( btn ) { btn . disabled = false ; btn . textContent = 'Update now' ; }
return ;
}
const actionRow = this . $ ( 'updateActionRow' );
if ( actionRow ) actionRow . style . display = 'none' ;
const notes = this . $ ( 'updateNotes' );
if ( notes ) notes . style . display = 'none' ;
this . _setUpdateProgress ( 'Starting update…' );
this . _pollUpdateStatus ();
},
_stopUpdatePolling () {
if ( this . _updatePollTimer ) { clearInterval ( this . _updatePollTimer ); this . _updatePollTimer = null ; }
},
/**
* Poll the status file every 1.5s. Survives the connection drop while the
* server restarts (fetch throws → "restarting"), then reads the reconciled
* terminal state from the freshly-booted server.
*/
_pollUpdateStatus () {
this . _stopUpdatePolling ();
const terminal = new Set ([ 'completed' , 'completed-needs-manual-restart' , 'failed' , 'idle' ]);
const poll = async () => {
let data = null ;
try {
const res = await fetch ( '/api/system/update/status' );
2026-06-10 01:30:43 +02:00
if ( res . ok ) {
const env = await res . json ();
data = env && env . success === true ? env . data : env ;
}
2026-06-09 01:55:54 +02:00
} catch { /* server restarting — keep polling */ }
if ( ! data ) {
this . _setUpdateProgress ( '↻ Restarting Codeman…' );
return ;
}
if ( ! terminal . has ( data . phase )) {
2026-06-09 03:36:39 +02:00
// Prefer the live status message — the updater's heartbeat enriches it with
// the latest npm/build output line so a slow step doesn't look frozen — and
// fall back to the static phase label. Append total elapsed so the counter
// keeps ticking between heartbeats: a clear "still working" signal.
const label = ( data . message && data . message . trim ()) ? data . message . trim () : this . _updatePhaseText ( data . phase );
let elapsed = '' ;
if ( data . startedAt ) {
const secs = Math . max ( 0 , Math . round (( Date . now () - data . startedAt ) / 1000 ));
elapsed = ` <span style="color:var(--text-secondary)">· ${ secs } s</span>` ;
}
this . _setUpdateProgress ( `<span class="tunnel-spinner"></span> ${ escapeHtml ( label ) }${ elapsed } ` );
2026-06-09 01:55:54 +02:00
return ;
}
this . _stopUpdatePolling ();
if ( data . phase === 'completed' ) {
let html = `<span style="color:var(--success,#3fb950)">✓ Updated to v ${ escapeHtml ( data . toVersion || '' ) } . Reloading…</span>` ;
if ( data . stashRef ) {
html += `<br><span style="color:var(--text-secondary)">Local changes stashed as <code> ${ escapeHtml ( data . stashRef ) } </code> — run <code>git stash pop</code> to restore.</span>` ;
}
this . _setUpdateProgress ( html );
setTimeout (() => location . reload (), 2500 );
} else if ( data . phase === 'completed-needs-manual-restart' ) {
this . _setUpdateProgress (
`Update staged. Restart Codeman to apply:<br><code> ${ escapeHtml ( data . manualRestartCommand || 'restart codeman web' ) } </code>`
);
} else if ( data . phase === 'failed' ) {
let html = `<span style="color:var(--danger,#e5534b)">✗ ${ escapeHtml ( data . message || 'Update failed' ) } .</span>` ;
if ( data . error ) html += `<br><span style="color:var(--text-secondary)"> ${ escapeHtml ( data . error ) } </span>` ;
html += `<br><span style="color:var(--text-secondary)">The previous version is still running.</span>` ;
if ( data . stashRef ) {
html += `<br><span style="color:var(--text-secondary)">Local changes stashed as <code> ${ escapeHtml ( data . stashRef ) } </code>.</span>` ;
}
this . _setUpdateProgress ( html );
const nowBtn = this . $ ( 'updateNowBtn' );
const actionRow = this . $ ( 'updateActionRow' );
if ( nowBtn ) { nowBtn . disabled = false ; nowBtn . textContent = 'Try again' ; }
if ( actionRow ) actionRow . style . display = 'flex' ;
}
};
poll ();
this . _updatePollTimer = setInterval ( poll , 1500 );
},
2026-08-05 01:53:43 +02:00
/**
* Is `tool` installed on the server? Reads `window.__codemanCliAvailable`,
* injected by renderIndexHtml (see the comment there for why this is injected
* rather than fetched per surface).
*
* Unknown reads as AVAILABLE. A missing flag means the page was rendered by a
* build that predates the injection, or by a solo popup: hiding every run
* button on a doubt would leave nothing to click, and the pre-existing failure
* mode for a genuinely missing CLI is just an error toast.
*/
isCliAvailable ( tool ) {
const flags = window . __codemanCliAvailable ;
if ( ! flags || typeof flags !== 'object' ) return true ;
return flags [ tool ] !== false ;
2026-08-01 21:55:33 -05:00
},
2026-09-24 07:48:26 +08:00
/** Render the registry's enabled, available CLIs as welcome-screen actions. */
renderWelcomeCliActions () {
const container = document . getElementById ( 'welcomeCliActions' );
if ( ! container ) return ;
const catalog = Array . isArray ( window . __codemanCliCatalog ) ? window . __codemanCliCatalog : [];
container . replaceChildren ();
for ( const cli of catalog ) {
if ( ! cli . enabled || ! this . isCliAvailable ( cli . id )) continue ;
const btn = document . createElement ( 'button' );
btn . type = 'button' ;
btn . className = `welcome-btn welcome-btn-cli welcome-btn- ${ cli . id } ` ;
btn . dataset . mode = cli . id ;
const icon = document . createElementNS ( 'http://www.w3.org/2000/svg' , 'svg' );
icon . setAttribute ( 'width' , '20' );
icon . setAttribute ( 'height' , '20' );
icon . setAttribute ( 'viewBox' , '0 0 24 24' );
icon . setAttribute ( 'fill' , 'none' );
icon . setAttribute ( 'stroke' , 'currentColor' );
icon . setAttribute ( 'stroke-width' , '2' );
icon . setAttribute ( 'aria-hidden' , 'true' );
const play = document . createElementNS ( 'http://www.w3.org/2000/svg' , 'polygon' );
play . setAttribute ( 'points' , '5 3 19 12 5 21 5 3' );
icon . appendChild ( play );
btn . appendChild ( icon );
// Same "Run <label>" text the static buttons had ("Run Claude Code", "Run Shell"),
// left translatable on purpose: i18n.js carries these strings, and a custom CLI's
// label simply has no dictionary entry, so it renders as typed.
btn . append ( `Run ${ cli . kind === 'shell' ? 'Shell' : cli . label } ` );
btn . onclick = () => {
this . setRunMode ( cli . id );
void this . run ();
};
container . appendChild ( btn );
}
},
2026-08-05 01:53:43 +02:00
/**
2026-09-24 07:48:26 +08:00
* #200: show a welcome-screen action only where the thing it launches exists.
* The registry catalog is injected with the initial document and is updated in
* place after a Settings toggle, so the page never offers a disabled CLI.
2026-08-05 01:53:43 +02:00
*/
applyWelcomeCliVisibility () {
2026-09-24 07:48:26 +08:00
this . renderWelcomeCliActions ();
// Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
// without cloudflared can only ever produce "cloudflared not found".
const tunnel = document . getElementById ( 'welcomeTunnelBtn' );
if ( tunnel ) tunnel . style . display = this . isCliAvailable ( 'cloudflared' ) ? 'flex' : 'none' ;
2026-07-31 11:20:15 -05:00
},
2026-03-14 21:24:22 +01:00
async loadTunnelStatus () {
try {
const res = await fetch ( '/api/tunnel/status' );
2026-06-10 01:30:43 +02:00
const env = await res . json ();
const status = env ? . success === true ? env . data : env ;
2026-03-14 21:24:22 +01:00
const active = status . running && status . url ;
this . _tunnelUrl = active ? status . url : null ;
this . _updateTunnelUrlDisplay ( this . _tunnelUrl );
this . _updateWelcomeTunnelBtn ( !! active , this . _tunnelUrl );
this . _updateTunnelIndicator ( !! active );
} catch {
this . _tunnelUrl = null ;
this . _updateTunnelUrlDisplay ( null );
this . _updateWelcomeTunnelBtn ( false );
this . _updateTunnelIndicator ( false );
}
},
2026-03-26 22:50:25 +01:00
_updateTunnelUrlRow ( rowId , displayId , url , suffix = '' ) {
const row = document . getElementById ( rowId );
const display = document . getElementById ( displayId );
2026-03-14 21:24:22 +01:00
if ( ! row || ! display ) return ;
if ( url ) {
2026-03-26 22:50:25 +01:00
const fullUrl = url + suffix ;
2026-03-14 21:24:22 +01:00
row . style . display = '' ;
2026-03-26 22:50:25 +01:00
display . textContent = fullUrl ;
2026-03-14 21:24:22 +01:00
display . onclick = () => {
2026-03-26 22:50:25 +01:00
navigator . clipboard . writeText ( fullUrl ). then (() => {
this . showToast ( ` ${ suffix ? 'Upload' : 'Tunnel' } URL copied` , 'success' );
2026-03-14 21:24:22 +01:00
});
};
} else {
row . style . display = 'none' ;
display . textContent = '' ;
display . onclick = null ;
}
2026-03-26 22:50:25 +01:00
},
_updateTunnelUrlDisplay ( url ) {
this . _updateTunnelUrlRow ( 'tunnelUrlRow' , 'tunnelUrlDisplay' , url );
this . _updateTunnelUrlRow ( 'tunnelUploadUrlRow' , 'tunnelUploadUrlDisplay' , url , '/upload.html' );
2026-03-14 21:24:22 +01:00
},
showTunnelQR () {
// Close existing popup if open
this . closeTunnelQR ();
const overlay = document . createElement ( 'div' );
overlay . id = 'tunnelQrOverlay' ;
overlay . style . cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:5000;display:flex;align-items:center;justify-content:center;cursor:pointer' ;
overlay . onclick = ( e ) => { if ( e . target === overlay ) this . closeTunnelQR (); };
const card = document . createElement ( 'div' );
card . style . cssText = 'background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:24px;text-align:center;max-width:340px;width:90vw;box-shadow:var(--shadow-lg);cursor:default' ;
card . innerHTML = `
<div style="font-size:14px;font-weight:600;color:var(--text-primary);margin-bottom:16px">Scan to connect</div>
<div id="tunnelQrContainer" style="background:#fff;border-radius:8px;padding:16px;display:inline-block">
<div style="color:#666;font-size:12px">Loading...</div>
</div>
<div id="tunnelQrUrl" style="margin-top:12px;font-family:monospace;font-size:11px;color:var(--text-muted);word-break:break-all;cursor:pointer" title="Click to copy"></div>
<button onclick="app.closeTunnelQR()" style="margin-top:16px;padding:6px 20px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);cursor:pointer;font-size:13px">Close</button>
` ;
overlay . appendChild ( card );
document . body . appendChild ( overlay );
// Fetch QR SVG from server
fetch ( '/api/tunnel/qr' )
. then ( res => {
if ( ! res . ok ) throw new Error ( 'Tunnel not running' );
return res . json ();
})
2026-06-10 01:30:43 +02:00
. then ( env => {
const data = env ? . success === true ? env . data : env ;
2026-03-14 21:24:22 +01:00
const container = document . getElementById ( 'tunnelQrContainer' );
if ( container && data . svg ) container . innerHTML = data . svg ;
// Show auth badge, countdown, and regenerate button when auth is enabled
if ( data . authEnabled ) {
const badge = document . createElement ( 'div' );
badge . id = 'tunnelQrBadge' ;
badge . style . cssText = 'margin-top:8px;font-size:11px;color:var(--text-muted)' ;
badge . textContent = 'Single-use auth \u00b7 expires in 60s' ;
const regenBtn = document . createElement ( 'button' );
regenBtn . textContent = 'Regenerate QR' ;
regenBtn . style . cssText = 'margin-top:8px;padding:4px 12px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:4px;color:var(--text-secondary);cursor:pointer;font-size:11px' ;
regenBtn . onclick = () => {
fetch ( '/api/tunnel/qr/regenerate' , { method : 'POST' })
. then (() => this . showToast ( 'QR code regenerated' , 'success' ))
. catch (() => this . showToast ( 'Failed to regenerate QR' , 'error' ));
};
const card = container . parentElement ;
if ( card ) {
card . appendChild ( badge );
card . appendChild ( regenBtn );
}
this . _resetQrCountdown ();
}
})
. catch (() => {
const container = document . getElementById ( 'tunnelQrContainer' );
if ( container ) container . innerHTML = '<div style="color:#c00;font-size:12px;padding:20px">Tunnel not active</div>' ;
});
// Fetch URL for display
fetch ( '/api/tunnel/status' )
. then ( r => r . json ())
2026-06-10 01:30:43 +02:00
. then ( env => {
const status = env ? . success === true ? env . data : env ;
2026-03-14 21:24:22 +01:00
const urlEl = document . getElementById ( 'tunnelQrUrl' );
if ( urlEl && status . url ) {
urlEl . textContent = status . url ;
urlEl . onclick = () => {
navigator . clipboard . writeText ( status . url ). then (() => {
this . showToast ( 'Tunnel URL copied' , 'success' );
});
};
}
})
. catch (() => {});
// Close on Escape
this . _tunnelQrEscHandler = ( e ) => { if ( e . key === 'Escape' ) this . closeTunnelQR (); };
document . addEventListener ( 'keydown' , this . _tunnelQrEscHandler );
},
closeTunnelQR () {
const overlay = document . getElementById ( 'tunnelQrOverlay' );
if ( overlay ) overlay . remove ();
if ( this . _tunnelQrEscHandler ) {
document . removeEventListener ( 'keydown' , this . _tunnelQrEscHandler );
this . _tunnelQrEscHandler = null ;
}
this . _clearQrCountdown ();
},
/** Fallback: fetch QR SVG from API when SSE payload lacks it */
_refreshTunnelQrFromApi () {
fetch ( '/api/tunnel/qr' )
. then ( res => res . ok ? res . json () : null )
2026-06-10 01:30:43 +02:00
. then ( env => {
const data = env ? . success === true ? env . data : env ;
2026-03-14 21:24:22 +01:00
if ( ! data ? . svg ) return ;
const container = document . getElementById ( 'tunnelQrContainer' );
if ( container ) container . innerHTML = data . svg ;
const welcomeInner = document . getElementById ( 'welcomeQrInner' );
if ( welcomeInner ) welcomeInner . innerHTML = data . svg ;
})
. catch (() => {});
},
/** Start or reset the 60s countdown on the QR badge */
_resetQrCountdown () {
this . _clearQrCountdown ();
this . _qrCountdownSec = 60 ;
this . _updateQrCountdownText ();
this . _qrCountdownTimer = setInterval (() => {
this . _qrCountdownSec -- ;
if ( this . _qrCountdownSec <= 0 ) {
this . _clearQrCountdown ();
return ;
}
this . _updateQrCountdownText ();
}, 1000 );
},
_updateQrCountdownText () {
const badge = document . getElementById ( 'tunnelQrBadge' );
if ( badge ) {
badge . textContent = `Single-use auth \u00b7 expires in ${ this . _qrCountdownSec } s` ;
}
},
_clearQrCountdown () {
if ( this . _qrCountdownTimer ) {
clearInterval ( this . _qrCountdownTimer );
this . _qrCountdownTimer = null ;
}
},
async toggleTunnelFromWelcome () {
const btn = document . getElementById ( 'welcomeTunnelBtn' );
if ( ! btn ) return ;
const isActive = btn . classList . contains ( 'active' );
btn . disabled = true ;
try {
const newEnabled = ! isActive ;
2026-06-10 12:25:26 -04:00
const res = await fetch ( '/api/settings' , {
2026-03-14 21:24:22 +01:00
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ tunnelEnabled : newEnabled }),
});
2026-06-10 12:25:26 -04:00
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if ( newEnabled && ( await this . _handleTunnelEnableRefusal ( res ))) {
this . _dismissTunnelConnecting ();
this . _updateWelcomeTunnelBtn ( false );
btn . disabled = false ;
return ;
}
2026-03-14 21:24:22 +01:00
if ( newEnabled ) {
this . _showTunnelConnecting ();
// Poll tunnel status as fallback in case SSE event is missed
this . _pollTunnelStatus ();
} else {
this . _dismissTunnelConnecting ();
this . showToast ( 'Tunnel stopped' , 'info' );
this . _updateWelcomeTunnelBtn ( false );
btn . disabled = false ;
}
} catch ( err ) {
this . _dismissTunnelConnecting ();
this . showToast ( 'Failed to toggle tunnel' , 'error' );
btn . disabled = false ;
}
},
_showTunnelConnecting () {
// Remove any existing connecting toast first (without resetting button state)
const oldToast = document . getElementById ( 'tunnelConnectingToast' );
if ( oldToast ) {
oldToast . remove ();
}
const btn = document . getElementById ( 'welcomeTunnelBtn' );
if ( btn ) {
btn . classList . add ( 'connecting' );
btn . innerHTML = `
<span class="tunnel-spinner"></span>
Connecting...` ;
}
// Persistent toast with spinner
const toast = document . createElement ( 'div' );
toast . className = 'toast toast-info show' ;
toast . id = 'tunnelConnectingToast' ;
toast . innerHTML = '<span class="tunnel-spinner"></span> Cloudflare Tunnel connecting...' ;
toast . style . pointerEvents = 'auto' ;
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 );
}
}
this . _toastContainer . appendChild ( toast );
},
_dismissTunnelConnecting () {
clearTimeout ( this . _tunnelPollTimer );
this . _tunnelPollTimer = null ;
const toast = document . getElementById ( 'tunnelConnectingToast' );
if ( toast ) {
toast . classList . remove ( 'show' );
setTimeout (() => toast . remove (), 200 );
}
const btn = document . getElementById ( 'welcomeTunnelBtn' );
if ( btn ) btn . classList . remove ( 'connecting' );
},
_pollTunnelStatus ( attempt = 0 ) {
if ( attempt > 15 ) return ; // give up after ~30s
this . _tunnelPollTimer = setTimeout ( async () => {
try {
const res = await fetch ( '/api/tunnel/status' );
2026-06-10 01:30:43 +02:00
const env = await res . json ();
const status = env ? . success === true ? env . data : env ;
2026-03-14 21:24:22 +01:00
if ( status . running && status . url ) {
// Tunnel is up — update UI
this . _dismissTunnelConnecting ();
this . _updateTunnelUrlDisplay ( status . url );
const welcomeVisible = document . getElementById ( 'welcomeOverlay' ) ? . classList . contains ( 'visible' );
if ( welcomeVisible ) {
this . _updateWelcomeTunnelBtn ( true , status . url , true );
this . showToast ( 'Tunnel active' , 'success' );
} else {
this . _updateWelcomeTunnelBtn ( true , status . url );
this . showToast ( `Tunnel active: ${ status . url } ` , 'success' );
this . showTunnelQR ();
}
return ;
}
} catch { /* ignore */ }
this . _pollTunnelStatus ( attempt + 1 );
}, 2000 );
},
_updateWelcomeTunnelBtn ( active , url , firstAppear = false ) {
const btn = document . getElementById ( 'welcomeTunnelBtn' );
if ( btn ) {
btn . disabled = false ;
if ( active ) {
btn . classList . remove ( 'connecting' );
btn . classList . add ( 'active' );
btn . innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
Tunnel Active` ;
} else {
btn . classList . remove ( 'active' , 'connecting' );
btn . innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
Cloudflare Tunnel` ;
}
}
// Update welcome QR code
const qrWrap = document . getElementById ( 'welcomeQr' );
const qrInner = document . getElementById ( 'welcomeQrInner' );
const qrUrl = document . getElementById ( 'welcomeQrUrl' );
if ( ! qrWrap || ! qrInner ) return ;
if ( active ) {
qrWrap . classList . add ( 'visible' );
// First appear: start expanded, auto-shrink after 8s
if ( firstAppear ) {
qrWrap . classList . add ( 'expanded' );
clearTimeout ( this . _welcomeQrShrinkTimer );
this . _welcomeQrShrinkTimer = setTimeout (() => {
qrWrap . classList . remove ( 'expanded' );
}, 8000 );
}
if ( url ) {
qrUrl . textContent = url ;
qrUrl . title = 'Click QR to enlarge' ;
}
fetch ( '/api/tunnel/qr' )
. then ( r => { if ( ! r . ok ) throw new Error (); return r . json (); })
2026-06-10 01:30:43 +02:00
. then ( env => { const data = env ? . success === true ? env . data : env ; if ( data . svg ) qrInner . innerHTML = data . svg ; })
2026-03-14 21:24:22 +01:00
. catch (() => { qrInner . innerHTML = '<div style="color:#999;font-size:11px;padding:20px">QR unavailable</div>' ; });
} else {
clearTimeout ( this . _welcomeQrShrinkTimer );
qrWrap . classList . remove ( 'visible' , 'expanded' );
qrInner . innerHTML = '' ;
if ( qrUrl ) qrUrl . textContent = '' ;
}
},
toggleWelcomeQrSize () {
const qrWrap = document . getElementById ( 'welcomeQr' );
if ( qrWrap ) {
clearTimeout ( this . _welcomeQrShrinkTimer );
qrWrap . classList . toggle ( 'expanded' );
}
},
// ═══════════════════════════════════════════════════════════════
// Tunnel Header Indicator & Panel (desktop only)
// ═══════════════════════════════════════════════════════════════
_updateTunnelIndicator ( active ) {
if ( MobileDetection . getDeviceType () === 'mobile' ) return ;
const indicator = document . getElementById ( 'tunnelIndicator' );
if ( ! indicator ) return ;
indicator . style . display = active ? 'flex' : 'none' ;
indicator . classList . remove ( 'connecting' );
},
toggleTunnelPanel () {
const existing = document . getElementById ( 'tunnelPanel' );
if ( existing ) {
this . closeTunnelPanel ();
return ;
}
this . _openTunnelPanel ();
},
async _openTunnelPanel () {
const panel = document . createElement ( 'div' );
panel . className = 'tunnel-panel' ;
panel . id = 'tunnelPanel' ;
panel . innerHTML = `
<div class="tunnel-panel-header">
<h3>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
Cloudflare Tunnel
<span class="tunnel-panel-status" id="tunnelPanelStatus">Loading...</span>
</h3>
</div>
<div class="tunnel-panel-body" id="tunnelPanelBody">
<div style="font-size:12px;color:var(--text-muted);padding:8px 0">Loading...</div>
</div>
` ;
document . body . appendChild ( panel );
// Close on outside click
this . _tunnelPanelClickHandler = ( e ) => {
if ( ! panel . contains ( e . target ) && e . target . id !== 'tunnelIndicator' && ! e . target . closest ( '.tunnel-indicator' )) {
this . closeTunnelPanel ();
}
};
setTimeout (() => document . addEventListener ( 'click' , this . _tunnelPanelClickHandler ), 0 );
// Close on Escape
this . _tunnelPanelEscHandler = ( e ) => { if ( e . key === 'Escape' ) this . closeTunnelPanel (); };
document . addEventListener ( 'keydown' , this . _tunnelPanelEscHandler );
// Fetch tunnel info
try {
const res = await fetch ( '/api/tunnel/info' );
2026-06-10 01:30:43 +02:00
const env = await res . json ();
const info = env ? . success === true ? env . data : env ;
2026-03-14 21:24:22 +01:00
this . _renderTunnelPanel ( info );
} catch {
const body = document . getElementById ( 'tunnelPanelBody' );
if ( body ) body . innerHTML = '<div style="font-size:12px;color:var(--red);padding:8px 0">Failed to load tunnel info</div>' ;
}
},
_renderTunnelPanel ( info ) {
const statusEl = document . getElementById ( 'tunnelPanelStatus' );
const body = document . getElementById ( 'tunnelPanelBody' );
if ( ! statusEl || ! body ) return ;
statusEl . textContent = info . running ? 'Connected' : 'Offline' ;
statusEl . className = 'tunnel-panel-status' + ( info . running ? '' : ' offline' );
let html = '' ;
// URL section
if ( info . url ) {
html += `
<div class="tunnel-panel-section">
<div class="tunnel-panel-label">URL</div>
<div class="tunnel-panel-url" id="tunnelPanelUrl" title="Click to copy"> ${ escapeHtml ( info . url ) } </div>
</div>` ;
}
// Clients section
html += `
<div class="tunnel-panel-section">
<div class="tunnel-panel-label">Connections</div>
<div class="tunnel-panel-stat">
<span>Remote Clients</span>
<span class="tunnel-panel-stat-value"> ${ info . sseClients } </span>
</div>` ;
if ( info . authEnabled ) {
html += `
<div class="tunnel-panel-stat">
<span>Auth Sessions</span>
<span class="tunnel-panel-stat-value"> ${ info . authSessions . length } </span>
</div>` ;
}
html += '</div>' ;
// Auth sessions detail
if ( info . authEnabled && info . authSessions . length > 0 ) {
html += '<div class="tunnel-panel-section"><div class="tunnel-panel-label">Authenticated Devices</div>' ;
for ( const s of info . authSessions ) {
const ua = s . ua || 'Unknown' ;
const browser = ua . match ( /Chrome|Firefox|Safari|Edge|Mobile/ ) ? .[ 0 ] || 'Browser' ;
const ago = this . _formatTimeAgo ( s . createdAt );
html += `
<div class="tunnel-panel-session">
<span class="tunnel-panel-session-dot"></span>
<span class="tunnel-panel-session-info" title=" ${ escapeHtml ( ua ) } "> ${ escapeHtml ( browser ) } · ${ escapeHtml ( s . ip ) } · ${ ago } </span>
<span class="tunnel-panel-session-method"> ${ s . method } </span>
</div>` ;
}
html += '</div>' ;
}
// Actions
html += '<div class="tunnel-panel-actions">' ;
if ( info . running ) {
html += `
<button class="tunnel-panel-btn btn-qr" onclick="app.showTunnelQR();app.closeTunnelPanel()">QR Code</button>
<button class="tunnel-panel-btn btn-stop" onclick="app._tunnelPanelToggle(false)">Stop Tunnel</button>` ;
} else {
html += `<button class="tunnel-panel-btn btn-start" onclick="app._tunnelPanelToggle(true)">Start Tunnel</button>` ;
}
html += '</div>' ;
// Revoke all sessions button
if ( info . authEnabled && info . authSessions . length > 0 ) {
html += `
<div style="padding-top:8px">
<button class="tunnel-panel-btn btn-revoke" style="width:100%" onclick="app._tunnelPanelRevokeAll()">Revoke All Sessions</button>
</div>` ;
}
body . innerHTML = html ;
// Bind URL copy handler
const urlEl = document . getElementById ( 'tunnelPanelUrl' );
if ( urlEl ) {
urlEl . onclick = () => {
navigator . clipboard . writeText ( info . url ). then (() => this . showToast ( 'Tunnel URL copied' , 'success' ));
};
}
},
_formatTimeAgo ( timestamp ) {
const diff = Date . now () - timestamp ;
const mins = Math . floor ( diff / 60000 );
if ( mins < 1 ) return 'just now' ;
if ( mins < 60 ) return ` ${ mins } m ago` ;
const hrs = Math . floor ( mins / 60 );
if ( hrs < 24 ) return ` ${ hrs } h ago` ;
return ` ${ Math . floor ( hrs / 24 ) } d ago` ;
},
2026-06-10 12:25:26 -04:00
/**
* COD-55: detect the server's refusal to start an unauthenticated public tunnel.
* The PUT /api/settings route returns a 4xx with { success:false, error } when no
* CODEMAN_PASSWORD is set and the unauthenticated-network opt-in is not acknowledged.
* Shows the server's (actionable) message as an error toast.
* @param {Response|null} res - the fetch Response from the settings PUT
* @returns {Promise<boolean>} true if the tunnel-enable was refused (caller should abort)
*/
async _handleTunnelEnableRefusal ( res ) {
if ( ! res || res . ok ) return false ;
let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.' ;
try {
const body = await res . json ();
if ( body && body . error ) message = body . error ;
} catch {
/* non-JSON body — use the default message */
}
2026-06-16 18:44:03 +02:00
// 403 = the no-password safety refusal (COD-55). Warn loudly and let the
// operator acknowledge the risk; on confirm, retry with explicit acknowledgment.
if ( res . status === 403 ) {
const confirmed = confirm (
'⚠️ SECURITY WARNING — no password set\n\n' +
'Enabling the Cloudflare tunnel will publish THIS machine to a public URL with ' +
'NO login. Anyone who gets the URL has full terminal control — effectively remote ' +
'code execution on your computer.\n\n' +
'Strongly recommended: set CODEMAN_PASSWORD instead.\n\n' +
'Enable the unauthenticated public tunnel anyway?'
);
if ( ! confirmed ) {
this . _dismissTunnelConnecting ? .();
this . showToast ( 'Tunnel not enabled' , 'info' );
return true ;
}
try {
const retry = await fetch ( '/api/settings' , {
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ tunnelEnabled : true , acknowledgeUnauthTunnel : true }),
});
if ( retry . ok ) {
this . showToast ( 'Public tunnel enabling — no password set ⚠️' , 'warning' );
return false ; // proceed with the caller's success/connecting path
}
let m = 'Failed to enable tunnel.' ;
try {
const b = await retry . json ();
if ( b && b . error ) m = b . error ;
} catch {
/* non-JSON */
}
this . _dismissTunnelConnecting ? .();
this . showToast ( m , 'error' );
return true ;
} catch {
this . _dismissTunnelConnecting ? .();
this . showToast ( 'Failed to enable tunnel' , 'error' );
return true ;
}
}
2026-06-10 12:25:26 -04:00
this . _dismissTunnelConnecting ? .();
this . showToast ( message , 'error' );
return true ;
},
2026-03-14 21:24:22 +01:00
async _tunnelPanelToggle ( enable ) {
try {
2026-06-10 12:25:26 -04:00
const res = await fetch ( '/api/settings' , {
2026-03-14 21:24:22 +01:00
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ tunnelEnabled : enable }),
});
2026-06-10 12:25:26 -04:00
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if ( enable && ( await this . _handleTunnelEnableRefusal ( res ))) {
this . closeTunnelPanel ();
return ;
}
2026-03-14 21:24:22 +01:00
if ( enable ) {
this . _updateTunnelIndicator ( false );
const indicator = document . getElementById ( 'tunnelIndicator' );
if ( indicator ) {
indicator . style . display = 'flex' ;
indicator . classList . add ( 'connecting' );
}
this . showToast ( 'Tunnel starting...' , 'info' );
this . _showTunnelConnecting ();
this . _pollTunnelStatus ();
} else {
this . showToast ( 'Tunnel stopped' , 'info' );
}
this . closeTunnelPanel ();
} catch {
this . showToast ( 'Failed to toggle tunnel' , 'error' );
}
},
async _tunnelPanelRevokeAll () {
try {
await fetch ( '/api/auth/revoke' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : '{}' });
this . showToast ( 'All sessions revoked' , 'success' );
// Refresh panel
const res = await fetch ( '/api/tunnel/info' );
2026-06-10 01:30:43 +02:00
const env = await res . json ();
const info = env ? . success === true ? env . data : env ;
2026-03-14 21:24:22 +01:00
this . _renderTunnelPanel ( info );
} catch {
this . showToast ( 'Failed to revoke sessions' , 'error' );
}
},
closeTunnelPanel () {
const panel = document . getElementById ( 'tunnelPanel' );
if ( panel ) panel . remove ();
if ( this . _tunnelPanelClickHandler ) {
document . removeEventListener ( 'click' , this . _tunnelPanelClickHandler );
this . _tunnelPanelClickHandler = null ;
}
if ( this . _tunnelPanelEscHandler ) {
document . removeEventListener ( 'keydown' , this . _tunnelPanelEscHandler );
this . _tunnelPanelEscHandler = null ;
}
},
toggleDeepgramKeyVisibility () {
const input = document . getElementById ( 'voiceDeepgramKey' );
const btn = document . getElementById ( 'voiceKeyToggleBtn' );
if ( input . type === 'password' ) {
input . type = 'text' ;
btn . textContent = 'Hide' ;
} else {
input . type = 'password' ;
btn . textContent = 'Show' ;
}
},
// ═══════════════════════════════════════════════════════════════
// Session Lifecycle Log
// ═══════════════════════════════════════════════════════════════
openLifecycleLog () {
const win = document . getElementById ( 'lifecycleWindow' );
win . style . display = 'block' ;
// Reset transform so it appears centered initially
if ( ! win . _dragInitialized ) {
win . style . left = '50%' ;
win . style . transform = 'translateX(-50%)' ;
this . _initLifecycleDrag ( win );
win . _dragInitialized = true ;
}
this . loadLifecycleLog ();
},
closeLifecycleLog () {
document . getElementById ( 'lifecycleWindow' ). style . display = 'none' ;
},
_initLifecycleDrag ( win ) {
const header = document . getElementById ( 'lifecycleWindowHeader' );
let isDragging = false ;
let startX , startY , startLeft , startTop ;
header . addEventListener ( 'mousedown' , ( e ) => {
if ( e . target . tagName === 'SELECT' || e . target . tagName === 'INPUT' || e . target . tagName === 'BUTTON' ) return ;
isDragging = true ;
// Clear transform so left/top work in absolute pixels
const rect = win . getBoundingClientRect ();
win . style . transform = 'none' ;
win . style . left = rect . left + 'px' ;
win . style . top = rect . top + 'px' ;
startX = e . clientX ;
startY = e . clientY ;
startLeft = rect . left ;
startTop = rect . top ;
e . preventDefault ();
});
document . addEventListener ( 'mousemove' , ( e ) => {
if ( ! isDragging ) return ;
win . style . left = ( startLeft + e . clientX - startX ) + 'px' ;
win . style . top = ( startTop + e . clientY - startY ) + 'px' ;
});
document . addEventListener ( 'mouseup' , () => {
isDragging = false ;
});
},
async loadLifecycleLog () {
const eventFilter = document . getElementById ( 'lifecycleFilterEvent' ). value ;
const sessionFilter = document . getElementById ( 'lifecycleFilterSession' ). value . trim ();
const params = new URLSearchParams ();
if ( eventFilter ) params . set ( 'event' , eventFilter );
if ( sessionFilter ) params . set ( 'sessionId' , sessionFilter );
params . set ( 'limit' , '300' );
try {
const res = await fetch ( `/api/session-lifecycle? ${ params } ` );
2026-06-10 01:30:43 +02:00
const env = await res . json ();
const data = env ? . success === true ? env . data : env ;
2026-03-14 21:24:22 +01:00
const tbody = document . getElementById ( 'lifecycleTableBody' );
const empty = document . getElementById ( 'lifecycleEmpty' );
if ( ! data . entries || data . entries . length === 0 ) {
tbody . innerHTML = '' ;
empty . style . display = '' ;
return ;
}
empty . style . display = 'none' ;
const eventColors = {
created : '#4ade80' , started : '#4ade80' , recovered : '#4ade80' ,
exit : '#fbbf24' , mux_died : '#f87171' , deleted : '#f87171' , stale_cleaned : '#f87171' ,
server_started : '#666' , server_stopped : '#666' ,
};
tbody . innerHTML = data . entries . map ( e => {
const time = new Date ( e . ts ). toLocaleString ();
const color = eventColors [ e . event ] || '#888' ;
const name = e . name || ( e . sessionId === '*' ? '—' : this . getShortId ( e . sessionId ));
const extra = [];
if ( e . exitCode !== undefined && e . exitCode !== null ) extra . push ( `code= ${ e . exitCode } ` );
if ( e . mode ) extra . push ( e . mode );
return `<tr style="border-bottom:1px solid #1a1a2e">
<td style="padding:3px 8px;color:#888;white-space:nowrap"> ${ time } </td>
<td style="padding:3px 8px;color: ${ color } ;font-weight:600"> ${ e . event } </td>
<td style="padding:3px 8px;color:#e0e0e0" title=" ${ e . sessionId } "> ${ name } </td>
<td style="padding:3px 8px;color:#aaa"> ${ e . reason || '' } </td>
<td style="padding:3px 8px;color:#666"> ${ extra . join ( ', ' ) } </td>
</tr>` ;
}). join ( '' );
} catch ( err ) {
console . error ( 'Failed to load lifecycle log:' , err );
}
},
2026-08-10 11:19:51 +02:00
/**
* Paint both Voice status rows: which provider a mic press would use, and what
* the server reports about its Claude login. Called on open and again once the
* /api/voice/status probe resolves.
*/
_renderVoiceProviderStatus () {
const providerEl = document . getElementById ( 'voiceProviderStatus' );
if ( providerEl ) {
const providerName = VoiceInput . getActiveProviderName ();
providerEl . textContent = providerName ;
const live = providerName . startsWith ( 'Deepgram Nova' ) || providerName . startsWith ( 'Claude (this' );
providerEl . className = 'voice-provider-status' + ( live ? ' active' : '' );
}
const claudeEl = document . getElementById ( 'voiceClaudeStatus' );
if ( ! claudeEl ) return ;
const status = VoiceInput . _claudeStatus ;
const text = ! status
? 'Checking...'
: status . available
? `Ready ${ status . subscriptionType ? ` ( ${ status . subscriptionType } )` : '' } `
: status . reason === 'expired'
? 'Login expired - run a Claude session to refresh'
: status . reason === 'no-credentials'
? 'No Claude Code login on the server'
: status . reason === 'malformed'
? 'Claude credentials unreadable'
: 'Off - enable it above' ;
claudeEl . textContent = text ;
claudeEl . className = 'voice-provider-status' + ( status ? . available ? ' active' : '' );
},
2026-03-14 21:24:22 +01:00
async saveAppSettings () {
2026-06-08 06:01:15 +02:00
// Gesture overlay is injected at page render (server-side), so a change to it
// only takes effect on reload — remember the prior value to decide below.
2026-06-14 07:33:52 +02:00
const _prev = this . loadAppSettingsFromStorage ();
const _prevGestureEnabled = ( _prev . gestureControlEnabled ?? false ) === true ;
2026-07-12 12:42:36 +02:00
// WebGL toggle: default ON (desktop), so only an explicit stored false counts
// as "previously off" — used below to detect a real OFF→ON flip.
const _prevWebglEnabled = ( _prev . webglRendererEnabled ?? true ) === true ;
2026-03-14 21:24:22 +01:00
const settings = {
2026-07-21 02:49:25 +08:00
displayName : window . CodemanI18n ? . normalizeDisplayName (
document . getElementById ( 'appSettingsDisplayName' ). value
) || 'Codeman' ,
language : window . CodemanI18n ? . normalizeLanguage (
document . getElementById ( 'appSettingsLanguage' ). value
) || 'en' ,
2026-03-14 21:24:22 +01:00
defaultClaudeMdPath : document . getElementById ( 'appSettingsClaudeMdPath' ). value . trim (),
defaultWorkingDir : document . getElementById ( 'appSettingsDefaultDir' ). value . trim (),
ralphTrackerEnabled : document . getElementById ( 'appSettingsRalphEnabled' ). checked ,
// Header visibility settings
showFontControls : document . getElementById ( 'appSettingsShowFontControls' ). checked ,
showSystemStats : document . getElementById ( 'appSettingsShowSystemStats' ). checked ,
showLifecycleLog : document . getElementById ( 'appSettingsShowLifecycleLog' ). checked ,
2026-06-10 12:10:31 +02:00
showResponseViewer : document . getElementById ( 'appSettingsShowResponseViewer' ). checked ,
2026-07-20 01:28:53 +02:00
showFileViewerButton : document . getElementById ( 'appSettingsShowFileViewerButton' ). checked ,
2026-06-14 21:21:06 +02:00
showAttachmentsButton : document . getElementById ( 'appSettingsShowAttachmentsButton' ). checked ,
2026-03-14 21:24:22 +01:00
showMonitor : document . getElementById ( 'appSettingsShowMonitor' ). checked ,
showProjectInsights : document . getElementById ( 'appSettingsShowProjectInsights' ). checked ,
showFileBrowser : document . getElementById ( 'appSettingsShowFileBrowser' ). checked ,
showSubagents : document . getElementById ( 'appSettingsShowSubagents' ). checked ,
2026-06-15 08:40:05 +02:00
showUltracodeAgents : document . getElementById ( 'appSettingsShowUltracodeAgents' ). checked ,
2026-08-09 14:04:34 +02:00
approvalsInboxEnabled : document . getElementById ( 'appSettingsApprovalsInbox' ). checked ,
2026-09-15 10:12:31 +08:00
customModelEndpointsEnabled : document . getElementById ( 'appSettingsCustomModelEndpoints' ). checked ,
2026-09-24 07:48:26 +08:00
cliManagementEnabled : document . getElementById ( 'appSettingsCliManagement' ). checked ,
2026-10-02 21:15:31 +08:00
mcpSyncEnabled : document . getElementById ( 'appSettingsMcpSync' ). checked ,
2026-08-09 23:05:11 +02:00
readMyMindEnabled : document . getElementById ( 'appSettingsReadMyMind' ). checked ,
2026-06-15 16:33:54 +02:00
ultracodeFloatingWindows : document . getElementById ( 'appSettingsUltracodeFloatingWindows' ). checked ,
2026-06-08 06:01:15 +02:00
showMultiMonitorButton : document . getElementById ( 'appSettingsShowMultiMonitorButton' ). checked ,
2026-09-15 22:13:00 -05:00
showSplitButton : document . getElementById ( 'appSettingsShowSplitButton' ). checked ,
2026-06-14 05:00:28 +02:00
showPlanUsageLimits : document . getElementById ( 'appSettingsShowPlanUsageLimits' ). checked ,
2026-06-17 23:19:54 +08:00
showRedrawButton : document . getElementById ( 'appSettingsShowRedrawButton' ). checked ,
2026-08-04 15:02:46 +02:00
mobileOverviewEnabled : document . getElementById ( 'appSettingsMobileOverview' ). checked ,
2026-08-12 02:30:08 +02:00
sessionLineageLines : document . getElementById ( 'appSettingsLineageLines' ). checked ,
2026-09-15 17:59:16 +02:00
autoNameSessions : document . getElementById ( 'appSettingsAutoNameSessions' ). checked ,
2026-07-19 17:59:23 +02:00
showSessionButton : document . getElementById ( 'appSettingsShowSessionButton' ). checked ,
showAwayDigestButton : document . getElementById ( 'appSettingsShowAwayDigestButton' ). checked ,
showCronButton : document . getElementById ( 'appSettingsShowCronButton' ). checked ,
2026-06-08 06:01:15 +02:00
gestureControlEnabled : document . getElementById ( 'appSettingsGestureControl' ). checked ,
2026-03-14 21:24:22 +01:00
subagentTrackingEnabled : document . getElementById ( 'appSettingsSubagentTracking' ). checked ,
subagentActiveTabOnly : document . getElementById ( 'appSettingsSubagentActiveTabOnly' ). checked ,
imageWatcherEnabled : document . getElementById ( 'appSettingsImageWatcherEnabled' ). checked ,
tunnelEnabled : document . getElementById ( 'appSettingsTunnelEnabled' ). checked ,
localEchoEnabled : document . getElementById ( 'appSettingsLocalEcho' ). checked ,
2026-08-20 10:46:11 +02:00
autoCopySelection : document . getElementById ( 'appSettingsAutoCopySelection' ). checked ,
2026-09-22 08:15:57 +02:00
copyStripMargin : document . getElementById ( 'appSettingsCopyStripMargin' ). checked ,
2026-08-19 00:45:19 +02:00
terminalFontFamily : document . getElementById ( 'appSettingsTerminalFont' ). value . trim (),
2026-09-14 14:09:13 +02:00
terminalFontWeight : this . readTerminalFontWeight ( document . getElementById ( 'appSettingsTerminalFontWeight' )),
terminalFontWeightBold : this . readTerminalFontWeight (
document . getElementById ( 'appSettingsTerminalFontWeightBold' )
),
2026-07-16 09:33:39 +02:00
terminalWheelLocalScrollback : document . getElementById ( 'appSettingsTerminalWheelLocal' ). checked ,
2026-03-24 09:22:06 +08:00
cjkInputEnabled : document . getElementById ( 'appSettingsCjkInput' ). checked ,
2026-07-01 20:41:38 -05:00
webglRendererEnabled : document . getElementById ( 'appSettingsWebglRenderer' ). checked ,
2026-04-03 04:17:33 +02:00
extendedKeyboardBar : document . getElementById ( 'appSettingsExtendedKeyboardBar' ). checked ,
2026-03-14 21:24:22 +01:00
tabTwoRows : document . getElementById ( 'appSettingsTabTwoRows' ). checked ,
2026-08-23 12:21:02 -04:00
tabOrientation : document . getElementById ( 'appSettingsTabOrientation' ). value ,
tabRailWidth : this . readTabRailWidthSetting ? .() ?? 256 ,
2026-08-24 04:45:02 +02:00
tabRailDetail : document . getElementById ( 'appSettingsTabRailDetail' ). value ,
2026-09-12 06:09:28 +02:00
tabRailSort : document . getElementById ( 'appSettingsTabRailSort' ). value ,
2026-08-08 13:34:05 +02:00
showTabDetachButton : document . getElementById ( 'appSettingsShowTabDetachButton' ). checked ,
2026-08-04 12:38:36 +02:00
sessionListLayout : document . getElementById ( 'appSettingsSessionListLayout' ). value ,
2026-08-23 12:21:02 -04:00
sessionSidebarFontSize : this . resolveSessionSidebarFontSize (
document . getElementById ( 'appSettingsSessionSidebarFontSize' ). value
),
2026-06-13 19:46:50 +02:00
skin : document . getElementById ( 'appSettingsSkin' ). value ,
2026-03-14 21:24:22 +01:00
// Claude CLI settings
claudeMode : document . getElementById ( 'appSettingsClaudeMode' ). value ,
allowedTools : document . getElementById ( 'appSettingsAllowedTools' ). value . trim (),
2026-06-10 09:31:58 -04:00
// Codex CLI settings
codexDangerouslyBypassApprovals : document . getElementById ( 'appSettingsCodexDangerouslyBypassApprovals' ). checked ,
2026-07-29 03:39:07 +03:00
codexAnimationsEnabled : document . getElementById ( 'appSettingsCodexAnimations' ). checked ,
2026-03-14 21:24:22 +01:00
// Claude Permissions settings
agentTeamsEnabled : document . getElementById ( 'appSettingsAgentTeams' ). checked ,
2026-08-09 03:30:15 +02:00
agentSkillEnabled : document . getElementById ( 'appSettingsAgentSkill' ). checked ,
2026-08-16 07:05:10 +02:00
workspaceHooksEnabled : document . getElementById ( 'appSettingsWorkspaceHooks' ). checked ,
2026-08-10 11:19:51 +02:00
claudeVoiceEnabled : document . getElementById ( 'appSettingsClaudeVoice' ). checked ,
2026-06-10 16:47:05 +02:00
claudeModel : document . getElementById ( 'appSettingsClaudeModel' ). value ,
2026-03-28 16:49:55 +01:00
opusContext1mEnabled : document . getElementById ( 'appSettingsOpusContext1m' ). checked ,
2026-06-13 14:26:06 -04:00
remoteAutoReconnect : document . getElementById ( 'appSettingsRemoteAutoReconnect' ). checked ,
2026-04-16 15:54:59 +08:00
thinkingEffort : document . getElementById ( 'appSettingsThinkingEffort' ). value ,
2026-10-04 19:05:43 +02:00
claudeAdvisorModel : document . getElementById ( 'appSettingsClaudeAdvisor' ). value ,
2026-03-14 21:24:22 +01:00
// CPU Priority settings
nice : {
enabled : document . getElementById ( 'appSettingsNiceEnabled' ). checked ,
niceValue : parseInt ( document . getElementById ( 'appSettingsNiceValue' ). value ) || 10 ,
},
};
2026-06-14 07:33:52 +02:00
// The "Token Count" / "Show Cost ($)" header toggles were removed from the
// UI, but their features still read settings.showTokenCount / settings.showCost
// (applyHeaderVisibilitySettings, the header cost render). saveAppSettings
// rebuilds `settings` fresh from the DOM (a full replacement, not a merge), so
// without this these keys would be DROPPED on every save and fall back to their
// defaults — silently re-enabling the token chip for anyone who'd turned it off,
// with no UI left to turn it back off. Preserve the prior stored preference.
if ( _prev . showTokenCount !== undefined ) settings . showTokenCount = _prev . showTokenCount ;
if ( _prev . showCost !== undefined ) settings . showCost = _prev . showCost ;
2026-07-12 19:58:23 +02:00
// Shortcut overrides are edited from the Shortcuts tab (not rebuilt from the
// general-settings DOM), so the fresh rebuild would drop them on every save.
if ( _prev . shortcutOverrides !== undefined ) settings . shortcutOverrides = _prev . shortcutOverrides ;
2026-06-14 07:33:52 +02:00
2026-03-14 21:24:22 +01:00
// Save to localStorage
this . saveAppSettingsToStorage ( settings );
this . _updateLocalEchoState ();
2026-08-19 00:45:19 +02:00
this . applyTerminalFontFamily ? .( settings . terminalFontFamily );
2026-09-14 14:09:13 +02:00
this . applyTerminalFontWeights ? .( settings );
2026-03-14 21:24:22 +01:00
2026-07-12 12:42:36 +02:00
// A real OFF→ON flip of the WebGL toggle retires the GPU-stall auto-fallback
// marker so the next reload actually re-tries WebGL. Only the transition
// clears it — an incidental save with the checkbox default-checked must NOT
// defeat the sticky safety net (shouldSkipWebGL treats stored true like the
// untouched default at page load).
if ( ! _prevWebglEnabled && settings . webglRendererEnabled ) {
try { localStorage . removeItem ( 'codeman-webgl-disabled' ); } catch {}
}
2026-03-14 21:24:22 +01:00
// Save voice settings to localStorage + include in server payload for cross-device sync
const voiceSettings = {
2026-08-10 11:19:51 +02:00
provider : document . getElementById ( 'voiceProvider' ). value ,
2026-03-14 21:24:22 +01:00
apiKey : document . getElementById ( 'voiceDeepgramKey' ). value . trim (),
language : document . getElementById ( 'voiceLanguage' ). value ,
keyterms : document . getElementById ( 'voiceKeyterms' ). value . trim (),
insertMode : document . getElementById ( 'voiceInsertMode' ). value ,
};
VoiceInput . _saveDeepgramConfig ( voiceSettings );
// Save notification preferences separately
const notifPrefsToSave = {
enabled : document . getElementById ( 'appSettingsNotifEnabled' ). checked ,
browserNotifications : document . getElementById ( 'appSettingsNotifBrowser' ). checked ,
audioAlerts : document . getElementById ( 'appSettingsNotifAudio' ). checked ,
stuckThresholdMs : ( parseInt ( document . getElementById ( 'appSettingsNotifStuckMins' ). value ) || 10 ) * 60000 ,
muteCritical : ! document . getElementById ( 'appSettingsNotifCritical' ). checked ,
muteWarning : ! document . getElementById ( 'appSettingsNotifWarning' ). checked ,
muteInfo : ! document . getElementById ( 'appSettingsNotifInfo' ). checked ,
// Per-event-type preferences
eventTypes : {
permission_prompt : {
enabled : document . getElementById ( 'eventPermissionEnabled' ). checked ,
browser : document . getElementById ( 'eventPermissionBrowser' ). checked ,
push : document . getElementById ( 'eventPermissionPush' ). checked ,
audio : document . getElementById ( 'eventPermissionAudio' ). checked ,
},
elicitation_dialog : {
enabled : document . getElementById ( 'eventQuestionEnabled' ). checked ,
browser : document . getElementById ( 'eventQuestionBrowser' ). checked ,
push : document . getElementById ( 'eventQuestionPush' ). checked ,
audio : document . getElementById ( 'eventQuestionAudio' ). checked ,
},
idle_prompt : {
enabled : document . getElementById ( 'eventIdleEnabled' ). checked ,
browser : document . getElementById ( 'eventIdleBrowser' ). checked ,
push : document . getElementById ( 'eventIdlePush' ). checked ,
audio : document . getElementById ( 'eventIdleAudio' ). checked ,
},
stop : {
enabled : document . getElementById ( 'eventStopEnabled' ). checked ,
browser : document . getElementById ( 'eventStopBrowser' ). checked ,
push : document . getElementById ( 'eventStopPush' ). checked ,
audio : document . getElementById ( 'eventStopAudio' ). checked ,
},
session_error : {
enabled : true ,
browser : this . notificationManager ? . preferences ? . eventTypes ? . session_error ? . browser ?? true ,
push : this . notificationManager ? . preferences ? . eventTypes ? . session_error ? . push ?? false ,
audio : false ,
},
respawn_cycle : {
enabled : document . getElementById ( 'eventRespawnEnabled' ). checked ,
browser : document . getElementById ( 'eventRespawnBrowser' ). checked ,
push : document . getElementById ( 'eventRespawnPush' ). checked ,
audio : document . getElementById ( 'eventRespawnAudio' ). checked ,
},
token_milestone : {
enabled : true ,
browser : false ,
push : false ,
audio : false ,
},
ralph_complete : {
enabled : document . getElementById ( 'eventRalphEnabled' ). checked ,
browser : document . getElementById ( 'eventRalphBrowser' ). checked ,
push : document . getElementById ( 'eventRalphPush' ). checked ,
audio : document . getElementById ( 'eventRalphAudio' ). checked ,
},
subagent_spawn : {
enabled : document . getElementById ( 'eventSubagentEnabled' ). checked ,
browser : document . getElementById ( 'eventSubagentBrowser' ). checked ,
push : document . getElementById ( 'eventSubagentPush' ). checked ,
audio : document . getElementById ( 'eventSubagentAudio' ). checked ,
},
subagent_complete : {
enabled : document . getElementById ( 'eventSubagentEnabled' ). checked ,
browser : document . getElementById ( 'eventSubagentBrowser' ). checked ,
push : document . getElementById ( 'eventSubagentPush' ). checked ,
audio : document . getElementById ( 'eventSubagentAudio' ). checked ,
},
},
2026-07-28 23:20:01 +03:00
_version : 5 ,
2026-03-14 21:24:22 +01:00
};
if ( this . notificationManager ) {
this . notificationManager . preferences = notifPrefsToSave ;
this . notificationManager . savePreferences ();
}
// Sync push preferences to server
this . _syncPushPreferences ();
// Apply header visibility immediately
this . applyHeaderVisibilitySettings ();
2026-06-13 19:46:50 +02:00
this . applySkin ();
2026-07-21 02:49:25 +08:00
this . applyLocalization ();
2026-08-04 12:38:36 +02:00
// Re-parents #sessionTabs between header host and sidebar if the layout
// changed, then calls applyTabWrapSettings() itself — do not call both.
this . applySessionListLayout ();
2026-08-23 12:21:02 -04:00
this . applyTabOrientation ({ settleRailWidth : true });
2026-08-12 02:30:08 +02:00
this . applyLineageLineSettings ? .();
2026-03-14 21:24:22 +01:00
this . _updateTokensImmediate (); // Re-render token display (picks up showCost change)
this . applyMonitorVisibility ();
2026-08-09 14:04:34 +02:00
this . renderApprovals ? .(); // Approvals Inbox toggle (hide/show bell + drawer)
2026-03-14 21:24:22 +01:00
this . renderProjectInsightsPanel (); // Re-render to apply visibility setting
this . updateSubagentWindowVisibility (); // Apply subagent window visibility setting
2026-03-24 09:22:06 +08:00
// Apply CJK input visibility immediately
this . _updateCjkInputState ();
2026-08-04 15:02:46 +02:00
// The phone home surface (overview vs welcome) may have just been toggled.
// Only re-decide while a home screen is actually up.
if ( ! this . activeSessionId ) this . showWelcome ();
2026-04-03 04:17:33 +02:00
// Apply keyboard bar mode
KeyboardAccessoryBar . setMode ( settings . extendedKeyboardBar ? 'extended' : 'simple' );
2026-06-14 20:15:39 +02:00
// Save to server (includes notification prefs for cross-browser persistence).
// Strip device-specific DISPLAY keys so they never sync across devices —
2026-09-07 20:37:29 -05:00
// localEcho/cjk/extendedKeyboard/skin are per-platform.
2026-07-12 12:42:36 +02:00
// webglRendererEnabled is per-device as well (renderer choice is GPU-specific,
// and syncing would leak mobile's hidden-checkbox false onto desktop); it's
// also absent from SettingsUpdateSchema, which is .strict() — sending it
// would 400 the whole settings PUT.
2026-09-14 15:53:25 +02:00
// showPlanUsageLimits is per-device for DISPLAY (loadAppSettingsFromServer
// only seeds it into localStorage when a device has no value yet, like every
// other display key) but ALSO doubles as the server-side plan-usage telemetry
2026-09-07 20:37:29 -05:00
// COLLECTION switch (readPlanUsageTelemetryEnabled in hooks-config.ts, read
2026-09-14 15:53:25 +02:00
// fresh at every claude session create/respawn). So it is stripped here like
// the others and re-added below ONLY when this save FLIPS it on this device
// (planUsageCollectionFlip): the chip defaults OFF on handhelds, so sending
// it on every save let a phone saving its font size persist `false` and
// switch collection off for every desktop, whose chip then went stale with
// no error anywhere. An explicit toggle on any device still writes it, in
// either direction.
const _chipFlip = this . planUsageCollectionFlip ( _prev , settings . showPlanUsageLimits );
2026-06-14 20:15:39 +02:00
const {
localEchoEnabled : _leo ,
cjkInputEnabled : _cjk ,
extendedKeyboardBar : _ekb ,
skin : _skin ,
2026-07-21 02:49:25 +08:00
language : _language ,
2026-09-14 15:53:25 +02:00
showPlanUsageLimits : _pul ,
2026-06-14 21:21:06 +02:00
showAttachmentsButton : _ahb ,
2026-07-20 01:28:53 +02:00
showFileViewerButton : _fvb ,
2026-09-19 14:01:54 -05:00
// Desktop-only header button, per-device, and absent from
// SettingsUpdateSchema (.strict()) — sending it 400s the whole PUT
// (moving it into displayKeys alone is not the strip; this is).
showSplitButton : _ssp ,
2026-07-12 12:42:36 +02:00
webglRendererEnabled : _wgl ,
2026-07-16 09:33:39 +02:00
terminalWheelLocalScrollback : _twls ,
2026-08-20 10:46:11 +02:00
// Copy-on-select. Per-device (clipboard access differs by device and by
// origin: the plain-HTTP LAN install has no navigator.clipboard at all)
// and absent from SettingsUpdateSchema (.strict()), so sending it would
// 400 the whole settings PUT.
autoCopySelection : _acs ,
2026-09-22 08:15:57 +02:00
// What the clipboard gets is a property of what this device is looking
// at, and the key is absent from SettingsUpdateSchema (.strict()), so
// sending it would 400 the whole settings PUT.
copyStripMargin : _csm ,
2026-08-19 00:45:19 +02:00
// Per-device by nature (the font must exist on the device) and absent
// from SettingsUpdateSchema (.strict()) — sending it would 400 the PUT.
terminalFontFamily : _tff ,
2026-09-14 14:09:13 +02:00
// Same two reasons: which weights a family can actually render is a
// property of the faces installed on THIS device, and neither key is
// declared in the .strict() schema.
terminalFontWeight : _tfw ,
terminalFontWeightBold : _tfwb ,
2026-07-19 17:59:23 +02:00
// Per-device header/toolbar button toggles — client-only, and absent from
// SettingsUpdateSchema (.strict()), so sending them would 400 the PUT.
showSessionButton : _ssb ,
showAwayDigestButton : _adb ,
showCronButton : _crb ,
2026-08-08 13:34:05 +02:00
showTabDetachButton : _tdb ,
2026-08-04 15:02:46 +02:00
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
mobileOverviewEnabled : _mov ,
2026-08-12 02:30:08 +02:00
// Desktop-only tab decoration, per-device, and likewise absent from the
// .strict() schema — syncing it would push a desktop-shaped choice onto
// devices that cannot render it at all.
sessionLineageLines : _sll ,
2026-06-14 20:15:39 +02:00
... serverSettings
} = settings ;
2026-10-04 23:42:47 +02:00
let webhookError = '' ;
2026-03-14 21:24:22 +01:00
try {
2026-06-10 12:25:26 -04:00
const res = await this . _apiPut ( '/api/settings' , {
... serverSettings ,
2026-09-14 15:53:25 +02:00
...( _chipFlip !== undefined ? { showPlanUsageLimits : _chipFlip } : {}),
2026-06-10 12:25:26 -04:00
notificationPreferences : notifPrefsToSave ,
voiceSettings ,
});
// COD-55: the server refuses an unauthenticated public tunnel with a 403 — which
// rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle
// (in the UI + localStorage) so it doesn't look enabled. Other settings persisted
// to localStorage above still apply locally.
if ( settings . tunnelEnabled && ( await this . _handleTunnelEnableRefusal ( res ))) {
settings . tunnelEnabled = false ;
this . saveAppSettingsToStorage ( settings );
const cb = document . getElementById ( 'appSettingsTunnelEnabled' );
if ( cb ) cb . checked = false ;
this . closeAppSettings ();
return ;
}
2026-03-14 21:24:22 +01:00
// Save model configuration separately
await this . saveModelConfigFromSettings ();
2026-10-04 23:42:47 +02:00
// The webhook is server state in its own 0600 file (its URL is a secret, kept out of
// settings.json), so like the model config above it is saved beside the settings PUT, not in
// it. Only when the group was edited: an untouched group must not re-PUT. A refusal (bad URL,
// enabled with no URL) keeps the modal open below, with the pasted URL still in the box.
webhookError = this . _webhookPending () ? await this . saveWebhook () : '' ;
if ( webhookError ) {
this . showToast ( `Settings saved, but not the webhook: ${ webhookError } ` , 'warning' );
} else {
this . showToast ( 'Settings saved' , 'success' );
}
2026-03-14 21:24:22 +01:00
// Show tunnel-specific feedback if toggled on
if ( settings . tunnelEnabled ) {
this . showToast ( 'Tunnel starting — QR code will appear when ready...' , 'info' );
}
} catch ( err ) {
// Server save failed but localStorage succeeded
this . showToast ( 'Settings saved locally' , 'warning' );
}
2026-10-04 23:42:47 +02:00
if ( webhookError ) {
document . getElementById ( 'webhookGroup' ) ? . scrollIntoView ({ block : 'center' });
} else {
this . closeAppSettings ();
}
2026-06-08 06:01:15 +02:00
2026-08-10 11:19:51 +02:00
// Voice availability is a server-side answer, so re-probe after a save:
// otherwise the mic keeps using the pre-save provider until the next reload.
VoiceInput . refreshClaudeStatus ();
2026-06-08 06:01:15 +02:00
// The gesture overlay is injected at page render (server reads
// gestureControlEnabled from settings.json), so a change only takes effect on
// reload. Reload when it actually changed — the server PUT above already
// persisted the new value.
if ( settings . gestureControlEnabled !== _prevGestureEnabled ) {
this . showToast (
settings . gestureControlEnabled ? 'Enabling gesture control — reloading…' : 'Disabling gesture control — reloading…' ,
'info'
);
setTimeout (() => location . reload (), 400 );
}
2026-03-14 21:24:22 +01:00
},
// Load model configuration from server for the settings modal
async loadModelConfigForSettings () {
try {
const res = await fetch ( '/api/execution/model-config' );
const data = await res . json ();
if ( data . success && data . data ) {
const config = data . data ;
// Default model
const defaultModelEl = document . getElementById ( 'appSettingsDefaultModel' );
if ( defaultModelEl ) {
2026-03-30 16:44:00 +08:00
defaultModelEl . value = config . defaultModel || '' ;
2026-03-14 21:24:22 +01:00
}
// Show recommendations
const showRecsEl = document . getElementById ( 'appSettingsShowModelRecommendations' );
if ( showRecsEl ) {
showRecsEl . checked = config . showRecommendations ?? true ;
}
// Agent type overrides
const overrides = config . agentTypeOverrides || {};
const exploreEl = document . getElementById ( 'appSettingsModelExplore' );
const implementEl = document . getElementById ( 'appSettingsModelImplement' );
const testEl = document . getElementById ( 'appSettingsModelTest' );
const reviewEl = document . getElementById ( 'appSettingsModelReview' );
if ( exploreEl ) exploreEl . value = overrides . explore || '' ;
if ( implementEl ) implementEl . value = overrides . implement || '' ;
if ( testEl ) testEl . value = overrides . test || '' ;
if ( reviewEl ) reviewEl . value = overrides . review || '' ;
}
} catch ( err ) {
console . warn ( 'Failed to load model config:' , err );
}
},
// Save model configuration from settings modal to server
async saveModelConfigFromSettings () {
const defaultModelEl = document . getElementById ( 'appSettingsDefaultModel' );
const showRecsEl = document . getElementById ( 'appSettingsShowModelRecommendations' );
const exploreEl = document . getElementById ( 'appSettingsModelExplore' );
const implementEl = document . getElementById ( 'appSettingsModelImplement' );
const testEl = document . getElementById ( 'appSettingsModelTest' );
const reviewEl = document . getElementById ( 'appSettingsModelReview' );
const agentTypeOverrides = {};
if ( exploreEl ? . value ) agentTypeOverrides . explore = exploreEl . value ;
if ( implementEl ? . value ) agentTypeOverrides . implement = implementEl . value ;
if ( testEl ? . value ) agentTypeOverrides . test = testEl . value ;
if ( reviewEl ? . value ) agentTypeOverrides . review = reviewEl . value ;
const config = {
2026-03-30 16:44:00 +08:00
defaultModel : defaultModelEl ? . value || '' ,
2026-03-14 21:24:22 +01:00
showRecommendations : showRecsEl ? . checked ?? true ,
agentTypeOverrides ,
};
try {
await fetch ( '/api/execution/model-config' , {
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ( config )
});
} catch ( err ) {
console . warn ( 'Failed to save model config:' , err );
}
},
2026-09-15 10:12:31 +08:00
// ═══════════════════════════════════════════════════════════════
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md)
//
// CRUD against /api/model-endpoints, rendered into the Models settings section.
// Deliberately its own load/save pair rather than folded into openAppSettings/
// saveAppSettings: these are server-side infra records (like remote/docker
// hosts), not a settings-payload field, so the app-settings-structure guard's
// by-id contract does not apply to them — only the `customModelEndpointsEnabled`
// toggle itself goes through that path.
// ═══════════════════════════════════════════════════════════════
2026-09-16 07:00:55 +08:00
/**
* Toggles the endpoint-management body's visibility to match the setting and,
* turning it on, lazily loads the endpoint list. Assigning `.checked` (as the
* settings load path does) fires no `change` event, so this must be called
* explicitly on open as well as wired to the checkbox's own onchange — a
* gate that only worked one of those two ways would show a stale "off"
* body right after opening, or a stale "on" one right after saving it off.
* With the feature off the body is a list of controls that do nothing, so it
* is hidden entirely rather than shown disabled.
*/
applyCustomModelEndpointsVisibility () {
const enabled = document . getElementById ( 'appSettingsCustomModelEndpoints' ). checked ;
const body = document . getElementById ( 'customModelEndpointsBody' );
if ( body ) body . style . display = enabled ? '' : 'none' ;
if ( enabled ) this . loadCustomModelEndpointsForSettings ();
else this . closeCustomModelHostEditor ();
this . _applyCustomModelAdminGate ();
},
/**
* Endpoint writes are admin-only in multi-user mode (custom-model-routes.ts),
* and GET already answers a non-admin with an empty list, which hides every
* per-row Edit/Discover/Delete button on its own. The "+ Add endpoint" button
* has no row to hide behind, so it needs its own gate — otherwise a non-admin
* can open the form, fill it in, and get a 403 toast on Save. Wired to the
* `codeman:me` event (admin-ui.js) as well as called from
* applyCustomModelEndpointsVisibility(), because `window.__codemanUser`'s
* real role can resolve AFTER settings have already been opened once.
*/
_applyCustomModelAdminGate () {
const addBtn = document . getElementById ( 'customModelHostAddBtn' );
if ( ! addBtn ) return ;
const me = window . __codemanUser || {};
const blocked = me . multiUser && me . role !== 'admin' ;
addBtn . style . display = blocked ? 'none' : '' ;
},
2026-09-15 10:12:31 +08:00
async loadCustomModelEndpointsForSettings () {
2026-09-16 07:00:55 +08:00
// GET /api/model-endpoints wraps its body in the { success, data } envelope
// like every other /api route (server.ts's preSerialization hook applies to
// arrays too) — _apiJson() unwraps it. A raw fetch().json() here would
// silently see the envelope object instead of the array and this panel
// would read as "No endpoints yet" forever, even with endpoints saved.
const hosts = await this . _apiJson ( '/api/model-endpoints' );
this . _customModelHosts = Array . isArray ( hosts ) ? hosts : [];
2026-09-15 10:12:31 +08:00
this . renderCustomModelHostsList ();
},
renderCustomModelHostsList () {
const list = document . getElementById ( 'customModelHostsList' );
if ( ! list ) return ;
const hosts = this . _customModelHosts || [];
if ( hosts . length === 0 ) {
list . innerHTML = '<p class="set-group-hint">No endpoints yet. Add one below to point a harness at a local or cloud OpenAI-compatible server.</p>' ;
return ;
}
list . innerHTML = hosts
. map (( h ) => {
const modelCount = ( h . models || []). length ;
const modelSummary = modelCount === 0
? 'No models discovered yet'
: ` ${ modelCount } model ${ modelCount === 1 ? '' : 's' }${ h . defaultModelId ? ` · default: ${ escapeHtml ( h . defaultModelId ) } ` : ' · no default set' } ` ;
2026-09-16 07:00:55 +08:00
// escapeHtml(JSON.stringify(h.id)) — not JSON.stringify(h.id) alone —
// because JSON.stringify's own double quotes would otherwise terminate
// this double-quoted attribute at the first one, and everything after
// parses as raw tag content rather than the rest of the quoted string.
// Same idiom as deleteCase's onclick in session-ui.js. h.id is
// regex-constrained server-side (safe either way) but the pattern must
// match everywhere it is used, including where the argument is not.
const idArg = escapeHtml ( JSON . stringify ( h . id ));
2026-09-15 10:12:31 +08:00
return `
<div class="set-row" data-endpoint-id=" ${ escapeHtml ( h . id ) } ">
<div class="set-row-text">
<span class="set-row-label"> ${ escapeHtml ( h . label ) } </span>
<span class="set-row-desc"> ${ escapeHtml ( h . baseUrl ) } — ${ modelSummary } </span>
</div>
<div class="set-row-actions">
2026-09-16 07:00:55 +08:00
<button type="button" class="btn-toolbar btn-sm" onclick="app.discoverCustomModelHostModels( ${ idArg } )">Discover</button>
<button type="button" class="btn-toolbar btn-sm" onclick="app.openCustomModelHostEditor( ${ idArg } )">Edit</button>
<button type="button" class="btn-toolbar btn-danger btn-sm" onclick="app.deleteCustomModelHost( ${ idArg } )">Delete</button>
2026-09-15 10:12:31 +08:00
</div>
</div>` ;
})
. join ( '' );
},
/** Opens the inline add/edit form. Pass no id to add a new endpoint. */
openCustomModelHostEditor ( hostId ) {
const host = hostId ? ( this . _customModelHosts || []). find (( h ) => h . id === hostId ) : null ;
this . _editingCustomModelHostId = host ? host . id : null ;
document . getElementById ( 'customModelHostEditorTitle' ). textContent = host ? `Edit ${ host . label } ` : 'Add endpoint' ;
document . getElementById ( 'customModelHostId' ). value = host ? . id || '' ;
document . getElementById ( 'customModelHostId' ). disabled = !! host ; // id is immutable once created
document . getElementById ( 'customModelHostLabel' ). value = host ? . label || '' ;
document . getElementById ( 'customModelHostBaseUrl' ). value = host ? . baseUrl || '' ;
2026-09-16 07:00:55 +08:00
document . getElementById ( 'customModelHostApiKey' ). value = '' ; // the server never returns the real value (apiKeySet is a bool)
document . getElementById ( 'customModelHostApiKey' ). placeholder = host ? . apiKeySet ? '•••••••• (unchanged if left blank)' : '' ;
2026-09-15 10:12:31 +08:00
document . getElementById ( 'customModelHostAuthStyle' ). value = host ? . authStyle || 'bearer' ;
this . _populateCustomModelDefaultSelect ( host );
document . getElementById ( 'customModelHostEditor' ). style . display = '' ;
},
closeCustomModelHostEditor () {
document . getElementById ( 'customModelHostEditor' ). style . display = 'none' ;
this . _editingCustomModelHostId = null ;
},
_populateCustomModelDefaultSelect ( host ) {
const select = document . getElementById ( 'customModelHostDefaultModel' );
const models = host ? . models || [];
select . innerHTML =
'<option value="">No default (picker uses the first discovered model)</option>' +
models . map (( m ) => `<option value=" ${ escapeHtml ( m ) } "> ${ escapeHtml ( m ) } </option>` ). join ( '' );
select . value = host ? . defaultModelId || '' ;
select . disabled = models . length === 0 ;
},
async saveCustomModelHostFromEditor () {
const id = document . getElementById ( 'customModelHostId' ). value . trim ();
const label = document . getElementById ( 'customModelHostLabel' ). value . trim ();
const baseUrl = document . getElementById ( 'customModelHostBaseUrl' ). value . trim ();
const apiKeyInput = document . getElementById ( 'customModelHostApiKey' ). value ;
const authStyle = document . getElementById ( 'customModelHostAuthStyle' ). value ;
const defaultModelId = document . getElementById ( 'customModelHostDefaultModel' ). value || undefined ;
if ( ! id || ! label || ! baseUrl ) {
this . showToast ( 'Id, label and base URL are all required' , 'warning' );
return ;
}
const editing = this . _editingCustomModelHostId ;
2026-09-16 07:00:55 +08:00
// PUT (server-side) treats an absent apiKey as "keep the stored one" — the
// browser never holds the real value to resend deliberately unchanged (see
// openCustomModelHostEditor and custom-model-routes.ts's applyStoredApiKey),
// so a blank field here means omitting the key entirely, not resending
// something we do not have. models/lastDiscoveredAt DO still need
// re-sending: PUT replaces the whole record, and this cached copy still
// carries both (only apiKey is redacted from what GET hands back).
2026-09-15 10:12:31 +08:00
const existing = editing ? ( this . _customModelHosts || []). find (( h ) => h . id === editing ) : null ;
const body = {
id ,
label ,
baseUrl ,
authStyle ,
defaultModelId ,
2026-09-16 07:00:55 +08:00
apiKey : apiKeyInput || undefined ,
2026-09-15 10:12:31 +08:00
models : existing ? . models ,
lastDiscoveredAt : existing ? . lastDiscoveredAt ,
};
try {
const res = await fetch ( editing ? `/api/model-endpoints/ ${ encodeURIComponent ( editing ) } ` : '/api/model-endpoints' , {
method : editing ? 'PUT' : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ( body ),
});
const data = await res . json ();
if ( ! data . success ) {
this . showToast ( data . error || 'Failed to save endpoint' , 'error' );
return ;
}
this . showToast ( editing ? 'Endpoint updated' : 'Endpoint added' , 'success' );
this . closeCustomModelHostEditor ();
await this . loadCustomModelEndpointsForSettings ();
} catch ( err ) {
this . showToast ( `Failed to save endpoint: ${ err . message } ` , 'error' );
}
},
async discoverCustomModelHostModels ( hostId ) {
this . showToast ( 'Discovering models…' , 'info' );
try {
const res = await fetch ( `/api/model-endpoints/ ${ encodeURIComponent ( hostId ) } /discover-models` , { method : 'POST' });
const data = await res . json ();
if ( ! data . success ) {
this . showToast ( data . error || 'Discovery failed' , 'error' );
return ;
}
this . showToast ( `Found ${ data . data . models . length } model ${ data . data . models . length === 1 ? '' : 's' } ` , 'success' );
await this . loadCustomModelEndpointsForSettings ();
} catch ( err ) {
this . showToast ( `Discovery failed: ${ err . message } ` , 'error' );
}
},
async deleteCustomModelHost ( hostId ) {
const host = ( this . _customModelHosts || []). find (( h ) => h . id === hostId );
if ( ! confirm ( `Delete endpoint " ${ host ? . label || hostId } "? Any session currently pointed at it keeps running until cleared.` )) return ;
try {
await fetch ( `/api/model-endpoints/ ${ encodeURIComponent ( hostId ) } ` , { method : 'DELETE' });
await this . loadCustomModelEndpointsForSettings ();
} catch ( err ) {
this . showToast ( `Failed to delete endpoint: ${ err . message } ` , 'error' );
}
},
2026-03-14 21:24:22 +01:00
2026-09-24 07:48:26 +08:00
// ═══════════════════════════════════════════════════════════════
// CLI management (docs/cli-enable-disable-plan.md)
//
// CRUD against /api/clis, rendered into the Agents & CLIs settings section.
// Same load/save-pair-outside-openAppSettings reasoning as the Custom Model
// Endpoints block above: these are server-side registry records, not a
// settings-payload field — only the `cliManagementEnabled` toggle itself
// goes through openAppSettings/saveAppSettings.
// ═══════════════════════════════════════════════════════════════
/**
* Same two-caller shape as applyCustomModelEndpointsVisibility (assigning
* .checked fires no change event, so this needs both an explicit call on
* open AND the checkbox's own onchange) and the same reasoning for hiding
* the whole list rather than showing it disabled: with the flag off the
* rows would be controls that only 403.
*/
applyCliManagementVisibility () {
const enabled = document . getElementById ( 'appSettingsCliManagement' ). checked ;
const group = document . getElementById ( 'cliListGroup' );
if ( group ) group . style . display = enabled ? '' : 'none' ;
if ( enabled ) this . loadCliListForSettings ();
else this . closeCliCustomForm ();
this . _applyCliManagementAdminGate ();
},
/**
* Decision 5 (docs/cli-enable-disable-plan.md): hidden entirely for a
* non-admin in multi-user mode, not shown-empty. GET /api/clis already
* answers a non-admin with [], which empties the row list on its own; the
* "Add a custom CLI" row has no list row to hide behind, so it needs its
* own gate the same way the Custom Model Endpoints "+ Add" button does.
*/
_applyCliManagementAdminGate () {
const group = document . getElementById ( 'cliListGroup' );
if ( ! group ) return ;
const me = window . __codemanUser || {};
const blocked = me . multiUser && me . role !== 'admin' ;
const featureOn = document . getElementById ( 'appSettingsCliManagement' ) ? . checked ?? false ;
group . style . display = blocked || ! featureOn ? 'none' : '' ;
const addRow = document . getElementById ( 'cliCustomAddToggle' );
if ( addRow ) addRow . style . display = blocked ? 'none' : '' ;
},
async loadCliListForSettings () {
// GET /api/clis wraps its body in the { success, data } envelope like every
// other /api route — _apiJson() unwraps it, same reasoning as the Custom
// Model Endpoints list load above.
const clis = await this . _apiJson ( '/api/clis' );
this . _cliList = Array . isArray ( clis ) ? clis : [];
this . _syncCliLaunchCatalog ();
this . renderCliList ();
},
/** Keep the launch surfaces in sync with Settings mutations without a reload. */
_syncCliLaunchCatalog () {
if ( ! Array . isArray ( this . _cliList ) || this . _cliList . length === 0 ) return ;
window . __codemanCliCatalog = this . _cliList . map (( cli ) => ({
id : cli . id ,
label : cli . label ,
shortBadge : cli . shortBadge ,
order : cli . order ,
kind : cli . kind ,
enabled : cli . enabled ,
available : cli . kind === 'shell' || ( cli . enabled && cli . installed ),
}));
window . __codemanCliAvailable = {
...( window . __codemanCliAvailable || {}),
... Object . fromEntries ( this . _cliList . map (( cli ) => [ cli . id , cli . kind === 'shell' || ( cli . enabled && cli . installed )])),
};
if ( ! window . __codemanCliCatalog . some (( cli ) => cli . id === this . runMode && cli . enabled )) {
this . setRunMode ? .( 'claude' );
}
this . applyWelcomeCliVisibility ? .();
this . renderRegistryRunOptions ? .();
this . renderMobileOverview ? .();
const menu = document . getElementById ( 'runModeMenu' );
if ( menu ) this . _refreshRunModeAvailability ? .( menu );
},
renderCliList () {
const list = document . getElementById ( 'cliListRows' );
if ( ! list ) return ;
const clis = [...( this . _cliList || [])]. sort (( a , b ) => {
// Installed CLIs first, alphabetically; then not-installed, alphabetically.
if ( a . installed !== b . installed ) return a . installed ? - 1 : 1 ;
return a . label . localeCompare ( b . label );
});
if ( clis . length === 0 ) {
list . innerHTML = '<p class="set-group-hint">No CLIs found.</p>' ;
return ;
}
// Mirrors cli-registry-routes.ts's own isUndisableable(): a kind 'shell' entry
// is the one the backend refuses to ever disable (keyed on kind, never an id).
// Revised 2026-09-23: rather
// than render a permanently-greyed switch for it (which read as "broken"
// next to every other row's working toggle), shell gets NO switch at all —
// a plain "Always available" label, so there is nothing to click that
// could look like it should work but doesn't.
list . innerHTML = clis
. map (( c ) => {
const idArg = escapeHtml ( JSON . stringify ( c . id ));
const untoggleable = c . kind === 'shell' ;
const installBtn =
c . stock && ! c . installed
? `<button type="button" class="btn-toolbar btn-sm" onclick="app.installCliEntry( ${ idArg } )" id="cliInstallBtn- ${ escapeHtml ( c . id ) } ">Install</button>`
: '' ;
const customActions = c . stock
? ''
: `<button type="button" class="btn-toolbar btn-sm" onclick="app.openCliCustomForm( ${ idArg } )">Edit</button>
<button type="button" class="btn-toolbar btn-danger btn-sm" onclick="app.deleteCliCustom( ${ idArg } )">Delete</button>` ;
const toggle = untoggleable
? '<span class="set-row-desc">Always available</span>'
: `<label class="switch switch-sm">
<input type="checkbox" ${ c . enabled ? 'checked' : '' } onchange="app.toggleCliEnabled( ${ idArg } , this)">
<span class="slider"></span>
</label>` ;
return `
<div class="set-row" data-cli-id=" ${ escapeHtml ( c . id ) } ">
<div class="set-row-text">
<span class="set-row-label"> ${ escapeHtml ( c . label ) } <span class="set-scope"> ${ escapeHtml ( c . shortBadge ) } </span></span>
<span class="set-row-desc"> ${ c . installed ? 'Installed' : 'Not installed' }${ c . stock ? '' : ' · custom' } </span>
</div>
<div class="set-row-actions">
${ installBtn }
${ customActions }
${ toggle }
</div>
</div>` ;
})
. join ( '' );
},
/**
* ⚠️ A successful toggle must patch `window.__codemanCliAvailable` and refresh
* every surface that reads it, or the change is invisible everywhere except
* this settings row until the next full page reload — `window.__codemanCliAvailable`
* is injected ONCE at initial page render (server.ts) and nothing else refetches
* it. Same pattern `installDeepSeekProfile()` already uses for the same reason.
*/
async toggleCliEnabled ( id , checkbox ) {
const next = checkbox . checked ;
const res = await this . _api ( `/api/clis/ ${ encodeURIComponent ( id ) } ` , { method : 'PUT' , body : { enabled : next } });
if ( ! res || ! res . ok ) {
checkbox . checked = ! next ; // revert on failure — the row must not lie about server state
let detail = '' ;
try {
detail = ( await res ? . json ()) ? . error || '' ;
} catch {
/* no body to read */
}
this . showToast ( `Failed to ${ next ? 'enable' : 'disable' } " ${ id } " ${ detail ? `: ${ detail } ` : '' } ` , 'error' );
return ;
}
await this . loadCliListForSettings ();
},
async installCliEntry ( id ) {
// Installing runs a command on the server, so it never happens on a single click:
// the confirm names the exact command POST /api/clis/:id/install would run (the
// #343 review's "auto-install may end up behind an explicit confirm").
const entry = ( this . _cliList || []). find (( c ) => c . id === id );
const label = entry ? . label || id ;
const command = entry ? . installCommand ;
const prompt = command
? `Install ${ label } ? This runs the following on the Codeman server:\n\n ${ command } `
: `Install ${ label } ? This runs its official install command on the Codeman server.` ;
if ( ! confirm ( prompt )) return ;
const btn = document . getElementById ( `cliInstallBtn- ${ id } ` );
if ( btn ) {
btn . disabled = true ;
btn . textContent = 'Installing…' ;
}
try {
const res = await this . _api ( `/api/clis/ ${ encodeURIComponent ( id ) } /install` , { method : 'POST' });
if ( ! res || ! res . ok ) {
let detail = '' ;
try {
detail = ( await res ? . json ()) ? . error || '' ;
} catch {
/* no body to read */
}
this . showToast ( `Installing " ${ id } " failed ${ detail ? `: ${ detail } ` : '' } ` , 'error' );
return ;
}
this . showToast ( `Installed " ${ id } "` , 'success' );
} finally {
await this . loadCliListForSettings ();
}
},
/**
* Pass no id to create a new entry; pass an existing CUSTOM id to edit one.
* ⚠️ GET /api/clis deliberately excludes discovery/launch (Phase 2's own
* scope), so an edit cannot be pre-filled with the entry's existing binary
* or argv — those two fields start blank and must be re-entered, since the
* update endpoint (PUT /api/clis/custom/:id) replaces the whole launch
* spec rather than patching it. id/label/badge DO come from the list row.
*/
openCliCustomForm ( editId ) {
const form = document . getElementById ( 'cliCustomForm' );
const errorEl = document . getElementById ( 'cliCustomFormError' );
if ( ! form ) return ;
const existing = editId ? ( this . _cliList || []). find (( c ) => c . id === editId ) : null ;
this . _editingCliCustomId = existing ? existing . id : null ;
document . getElementById ( 'cliCustomId' ). value = existing ? existing . id : '' ;
document . getElementById ( 'cliCustomId' ). disabled = !! existing ; // id is immutable once created
document . getElementById ( 'cliCustomLabel' ). value = existing ? existing . label : '' ;
document . getElementById ( 'cliCustomBadge' ). value = existing ? existing . shortBadge : '' ;
document . getElementById ( 'cliCustomBinary' ). value = '' ;
document . getElementById ( 'cliCustomArgv' ). value = '' ;
document . getElementById ( 'cliCustomSubmit' ). textContent = existing ? 'Save' : 'Create' ;
if ( errorEl ) errorEl . style . display = 'none' ;
form . style . display = '' ;
},
closeCliCustomForm () {
const form = document . getElementById ( 'cliCustomForm' );
if ( form ) form . style . display = 'none' ;
this . _editingCliCustomId = null ;
},
/** Wired to #cliCustomForm's onsubmit; `event` is the submit event. */
async submitCliCustomForm ( event ) {
event . preventDefault ();
const errorEl = document . getElementById ( 'cliCustomFormError' );
const showError = ( msg ) => {
if ( errorEl ) {
errorEl . textContent = msg ;
errorEl . style . display = '' ;
}
};
const id = document . getElementById ( 'cliCustomId' ). value . trim ();
const label = document . getElementById ( 'cliCustomLabel' ). value . trim ();
const shortBadge = document . getElementById ( 'cliCustomBadge' ). value . trim ();
const binaries = document . getElementById ( 'cliCustomBinary' ). value . trim (). split ( /\s+/ ). filter ( Boolean );
const argv = document . getElementById ( 'cliCustomArgv' ). value . trim (). split ( /\s+/ ). filter ( Boolean );
if ( ! id || ! label || ! shortBadge || binaries . length === 0 || argv . length === 0 ) {
showError ( 'All fields are required.' );
return ;
}
const editing = this . _editingCliCustomId ;
const path = editing ? `/api/clis/custom/ ${ encodeURIComponent ( editing ) } ` : '/api/clis' ;
const method = editing ? 'PUT' : 'POST' ;
const res = await this . _api ( path , { method , body : { id , label , shortBadge , binaries , argv } });
if ( ! res || ! res . ok ) {
let detail = 'Request failed' ;
try {
detail = ( await res ? . json ()) ? . error || detail ;
} catch {
/* no body to read */
}
showError ( detail );
return ;
}
this . closeCliCustomForm ();
await this . loadCliListForSettings ();
},
async deleteCliCustom ( id ) {
const entry = ( this . _cliList || []). find (( c ) => c . id === id );
if ( ! confirm ( `Delete custom CLI " ${ entry ? . label || id } "? This cannot be undone.` )) return ;
const res = await this . _api ( `/api/clis/ ${ encodeURIComponent ( id ) } ` , { method : 'DELETE' });
if ( ! res || ! res . ok ) {
let detail = '' ;
try {
detail = ( await res ? . json ()) ? . error || '' ;
} catch {
/* no body to read */
}
this . showToast ( `Failed to delete " ${ id } " ${ detail ? `: ${ detail } ` : '' } ` , 'error' );
return ;
}
await this . loadCliListForSettings ();
},
2026-03-14 21:24:22 +01:00
// ═══════════════════════════════════════════════════════════════
// Visibility Settings & Device-Specific Defaults
// ═══════════════════════════════════════════════════════════════
// Get the global Ralph tracker enabled setting
isRalphTrackerEnabledByDefault () {
const settings = this . loadAppSettingsFromStorage ();
return settings . ralphTrackerEnabled ?? false ;
},
2026-07-20 21:22:28 +08:00
// Keep the settings namespace stable across foldable posture changes. Layout
// still follows viewport width, but an unfolded phone remains the same
// handheld device and must not silently switch to desktop preferences.
2026-03-14 21:24:22 +01:00
getSettingsStorageKey () {
2026-07-20 21:22:28 +08:00
const isHandheld =
MobileDetection . isHandheldDevice ? .() ?? MobileDetection . getDeviceType () === 'mobile' ;
return isHandheld ? 'codeman-app-settings-mobile' : 'codeman-app-settings' ;
2026-03-14 21:24:22 +01:00
},
// Get default settings based on device type
// Note: Notification prefs are handled separately by NotificationManager
getDefaultSettings () {
2026-07-20 21:22:28 +08:00
const isHandheld =
MobileDetection . isHandheldDevice ? .() ?? MobileDetection . getDeviceType () === 'mobile' ;
if ( isHandheld ) {
2026-03-14 21:24:22 +01:00
// Mobile defaults: minimal UI for small screens
return {
// Header visibility - hide everything on mobile
showFontControls : false ,
showSystemStats : false ,
showTokenCount : false ,
showCost : false ,
// Panel visibility - hide panels on mobile (not enough space)
showMonitor : false ,
showProjectInsights : false ,
showFileBrowser : false ,
showSubagents : false ,
2026-06-15 08:40:05 +02:00
showUltracodeAgents : false ,
2026-06-15 16:33:54 +02:00
ultracodeFloatingWindows : false ,
2026-06-08 06:01:15 +02:00
showMultiMonitorButton : false ,
2026-09-15 22:13:00 -05:00
showSplitButton : false ,
2026-07-30 14:27:45 +02:00
// Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it
// OFF so the phone header stays minimal and the mobile-header-buttons
// policy guard keeps passing.
2026-06-14 05:00:28 +02:00
showPlanUsageLimits : false ,
2026-06-14 21:21:06 +02:00
showAttachmentsButton : false ,
2026-07-20 01:28:53 +02:00
showFileViewerButton : false ,
2026-06-17 23:19:54 +08:00
showRedrawButton : false ,
2026-07-19 17:59:23 +02:00
showSessionButton : false ,
showAwayDigestButton : false ,
2026-07-20 14:36:18 +02:00
showCronButton : false ,
2026-08-04 15:02:46 +02:00
// Phone home screen: the C logo opens the session overview instead of the
// welcome screen. ON by default here, and the escape hatch if it ever
// misbehaves on a device (the gate treats only an explicit false as off).
mobileOverviewEnabled : true ,
2026-06-13 14:26:06 -04:00
// Remote auto-reconnect (COD-108) — on by default
remoteAutoReconnect : true ,
2026-06-08 06:01:15 +02:00
// Input
gestureControlEnabled : false ,
2026-03-14 21:24:22 +01:00
// Feature toggles - keep tracking on even on mobile
subagentTrackingEnabled : true ,
subagentActiveTabOnly : true , // Only show subagents for active tab
imageWatcherEnabled : false ,
ralphTrackerEnabled : false ,
tabTwoRows : false ,
2026-08-23 12:21:02 -04:00
tabOrientation : 'horizontal' ,
tabRailWidth : 256 ,
2026-08-24 04:45:02 +02:00
tabRailDetail : 'rich' ,
2026-09-12 06:09:28 +02:00
tabRailSort : 'activity' ,
2026-08-04 12:38:36 +02:00
sessionListLayout : 'header' ,
2026-08-24 00:59:48 +02:00
sessionSidebarFontSize : 12 ,
2026-06-09 10:26:09 -04:00
cjkInputEnabled : false ,
2026-07-16 09:33:39 +02:00
terminalWheelLocalScrollback : false , // mobile scrolls via touch, not wheel
2026-07-01 20:41:38 -05:00
webglRendererEnabled : false , // mobile always uses the DOM renderer
2026-06-13 19:46:50 +02:00
skin : 'daylight-blue' ,
2026-03-14 21:24:22 +01:00
};
}
// Desktop defaults - rely on ?? operators in apply functions
// This allows desktop to have different defaults without duplication
return {};
},
loadAppSettingsFromStorage () {
// Return cached settings if available (avoids synchronous localStorage + JSON.parse
// on every SSE event — critical for input responsiveness)
if ( this . _cachedAppSettings ) return this . _cachedAppSettings ;
try {
const key = this . getSettingsStorageKey ();
const saved = localStorage . getItem ( key );
if ( saved ) {
this . _cachedAppSettings = JSON . parse ( saved );
return this . _cachedAppSettings ;
}
} catch ( err ) {
console . error ( 'Failed to load app settings:' , err );
}
// Return device-specific defaults
this . _cachedAppSettings = this . getDefaultSettings ();
return this . _cachedAppSettings ;
},
saveAppSettingsToStorage ( settings ) {
// Invalidate cache on save
this . _cachedAppSettings = settings ;
try {
const key = this . getSettingsStorageKey ();
localStorage . setItem ( key , JSON . stringify ( settings ));
} catch ( err ) {
console . error ( 'Failed to save app settings:' , err );
}
},
2026-06-13 19:46:50 +02:00
// Apply the chosen skin live: sets the html[data-skin] attribute, syncs BOTH
// localStorage locations (the standalone 'codeman:skin' key the pre-paint head
// script reads + the app-settings blob field written by saveAppSettingsToStorage),
// updates window.__codemanSkin, and re-themes any live terminals.
applySkin () {
const settings = this . loadAppSettingsFromStorage ();
const defaults = this . getDefaultSettings ();
const skin = settings . skin ?? defaults . skin ?? 'daylight-blue' ;
document . documentElement . setAttribute ( 'data-skin' , skin );
window . __codemanSkin = skin ;
2026-07-21 00:39:10 +08:00
const themeColor = getComputedStyle ( document . documentElement ). getPropertyValue ( '--bg-dark' ). trim ();
if ( themeColor ) document . querySelector ( 'meta[name="theme-color"]' ) ? . setAttribute ( 'content' , themeColor );
2026-06-13 19:46:50 +02:00
try {
localStorage . setItem ( 'codeman:skin' , skin );
} catch ( _e ) {
/* private mode */
}
if ( typeof this . applyTerminalSkin === 'function' ) this . applyTerminalSkin ( skin );
},
2026-07-21 02:49:25 +08:00
// Apply the per-device language and the synced user-facing product name.
// The i18n layer updates both existing static nodes and future dynamic DOM.
applyLocalization () {
const settings = this . loadAppSettingsFromStorage ();
const result = window . CodemanI18n ? . configure ({
language : settings . language ,
displayName : settings . displayName ,
});
if ( result && this . notificationManager ) {
this . notificationManager . originalTitle = document . title ;
}
},
2026-07-30 14:27:45 +02:00
// Resolved per-device state of the plan-usage chip. Desktop defaults ON,
// handhelds default OFF (the mobile block in getDefaultSettings() sets false,
// and the mobile-header-buttons-policy guard depends on that staying false).
2026-09-07 20:37:29 -05:00
// Single source of truth for the two call sites that must never disagree:
// the App Settings checkbox and the chip's visibility. Telemetry COLLECTION
// no longer has a THIRD client-side call site here at all — the server reads
// this same persisted setting directly (readPlanUsageTelemetryEnabled in
// hooks-config.ts), fresh, at every claude session create/respawn.
2026-07-30 14:27:45 +02:00
planUsageChipEnabled ( settings = null ) {
const s = settings ?? this . loadAppSettingsFromStorage ();
return s . showPlanUsageLimits ?? this . getDefaultSettings (). showPlanUsageLimits ?? true ;
},
2026-09-14 15:53:25 +02:00
// What a settings save tells the server about plan-usage COLLECTION: the new
// chip value when this save FLIPS it relative to what this device resolved
// before (stored value, else the per-device default), otherwise undefined,
// meaning "say nothing". The server reads an absent key as ON, so a device
// that never touched the chip leaves collection alone, and a handheld (chip
// default OFF) cannot switch it off for every desktop by saving its font
// size. Pure so test/plan-usage-collection-flip.test.ts can drive it.
planUsageCollectionFlip ( prevSettings , now ) {
const before = this . planUsageChipEnabled ( prevSettings ?? {});
return now === before ? undefined : now ;
},
2026-03-14 21:24:22 +01:00
applyHeaderVisibilitySettings () {
const settings = this . loadAppSettingsFromStorage ();
const defaults = this . getDefaultSettings ();
2026-08-08 13:34:05 +02:00
2026-08-08 13:54:43 +02:00
// Tab pop-out (open-in-new-window) button: opt-in (App Settings → Tab Bar,
2026-08-08 13:34:05 +02:00
// default OFF, per-device). Mirrored as a class on <html>: styles.css hides
// .tab-detach without it (a tab that is already detached keeps its icon as
// the re-focus affordance for the popped-out window).
const showTabDetach = settings . showTabDetachButton ?? defaults . showTabDetachButton ?? false ;
document . documentElement . classList . toggle ( 'tabs-show-detach' , showTabDetach );
2026-06-09 10:26:09 -04:00
const compactHeader = MobileDetection . getDeviceType () !== 'desktop' ;
const showFontControls = compactHeader ? false : ( settings . showFontControls ?? defaults . showFontControls ?? false );
const showSystemStats = compactHeader ? false : ( settings . showSystemStats ?? defaults . showSystemStats ?? true );
2026-07-24 14:50:43 +02:00
// Default OFF: the header stays gear + usage chips + files button unless a
// stored preference explicitly re-enables the token chip (no UI toggle exists).
const showTokenCount = compactHeader ? false : ( settings . showTokenCount ?? defaults . showTokenCount ?? false );
2026-03-14 21:24:22 +01:00
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' ;
}
// Hide lifecycle log button when setting is disabled
2026-07-24 14:50:43 +02:00
// Default OFF: the lifecycle-log document icon is opt-in; the default header
// keeps only WS/CPU/MEM, the file-viewer folder, usage chips, and the gear.
const showLifecycleLog = settings . showLifecycleLog ?? defaults . showLifecycleLog ?? false ;
2026-03-14 21:24:22 +01:00
const lifecycleBtn = document . querySelector ( '.btn-lifecycle-log' );
if ( lifecycleBtn ) {
lifecycleBtn . style . display = showLifecycleLog ? '' : 'none' ;
}
2026-06-10 12:10:31 +02:00
// Hide the response viewer (eye) button when setting is disabled.
// Marker class, not inline style — the base rule is display:inline-flex !important.
2026-06-10 12:16:47 +02:00
const showResponseViewer = settings . showResponseViewer ?? defaults . showResponseViewer ?? false ;
2026-06-10 12:10:31 +02:00
const responseViewerBtn = document . querySelector ( '.btn-response-viewer-header' );
if ( responseViewerBtn ) {
responseViewerBtn . classList . toggle ( 'btn-response-viewer-header--hidden' , ! showResponseViewer );
}
2026-06-14 21:21:06 +02:00
// Hide the attachments (history) button when disabled. Opt-in, default OFF —
// marker class, base is display:inline-flex !important.
const showAttachmentsButton = settings . showAttachmentsButton ?? defaults . showAttachmentsButton ?? false ;
const attachmentsBtn = document . getElementById ( 'attachmentsHistoryBtn' );
if ( attachmentsBtn ) {
attachmentsBtn . classList . toggle ( 'btn-attachments-history--hidden' , ! showAttachmentsButton );
}
2026-07-20 01:28:53 +02:00
// File Viewer header button — opt-in, default OFF. Marker class (base is
// display:inline-flex !important); clicking it toggles the file browser panel.
2026-07-24 14:50:43 +02:00
// Default ON (desktop): the folder button is part of the standard header now;
// phones still hide it via mobile.css (btn-file-viewer in the phone-hidden set).
const showFileViewerButton = settings . showFileViewerButton ?? defaults . showFileViewerButton ?? true ;
2026-07-20 01:28:53 +02:00
const fileViewerBtn = document . querySelector ( '.btn-file-viewer' );
if ( fileViewerBtn ) {
fileViewerBtn . classList . toggle ( 'btn-file-viewer--hidden' , ! showFileViewerButton );
}
2026-06-08 06:01:15 +02:00
// Multi-monitor button — hidden by default (App Settings → Display → "Header
// Displays"). The server renders the correct initial state on every reload;
2026-06-08 15:46:10 +02:00
// this handles a live toggle from a settings save (no reload). Toggle the
// marker class (matches the server-side reveal) rather than an inline style.
2026-06-08 06:01:15 +02:00
const showMultiMonitorButton = settings . showMultiMonitorButton ?? defaults . showMultiMonitorButton ?? false ;
const multiMonitorBtn = document . querySelector ( '.btn-multimonitor' );
if ( multiMonitorBtn ) {
2026-06-08 15:46:10 +02:00
multiMonitorBtn . classList . toggle ( 'btn-multimonitor--hidden' , ! showMultiMonitorButton );
2026-06-08 06:01:15 +02:00
}
2026-09-19 14:01:54 -05:00
// Split button — hidden by default, and hard-gated to desktop widths
// regardless of the setting (window.CodemanSplitPane.SPLIT_PANE_MIN_WIDTH,
// matching HOME_SESSIONS_MIN_WIDTH's JS-check + media-query-backstop
// pattern — the CSS in styles.css is the backstop, this is the check).
2026-09-15 22:13:00 -05:00
const showSplitButton = settings . showSplitButton ?? defaults . showSplitButton ?? false ;
2026-09-19 14:01:54 -05:00
this . _applySplitButtonVisibility ? .( showSplitButton );
2026-09-15 22:13:00 -05:00
2026-06-15 08:40:05 +02:00
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
// from the mobile-header-buttons-policy guard.
const showUltracodeAgents = settings . showUltracodeAgents ?? defaults . showUltracodeAgents ?? false ;
const ultracodeBtn = document . querySelector ( '.btn-ultracode-agents' );
if ( ultracodeBtn ) {
ultracodeBtn . classList . toggle ( 'btn-ultracode-agents--hidden' , ! showUltracodeAgents );
}
2026-08-09 23:05:11 +02:00
// Read My Mind 🧠 — hidden unless the synced opt-in `readMyMindEnabled` is
// ON (only an explicit true enables, mirroring the Approvals bell). Marker
// class (base is display:inline-flex !important); phones hide it in
2026-08-10 03:57:06 +02:00
// mobile.css regardless (their surface is the keyboard-accessory 🧠 key,
// re-synced right below).
2026-08-09 23:05:11 +02:00
const readMyMindBtn = document . querySelector ( '.btn-readmymind' );
if ( readMyMindBtn ) {
readMyMindBtn . classList . toggle ( 'btn-readmymind--hidden' , settings . readMyMindEnabled !== true );
}
2026-08-10 03:57:06 +02:00
// The accessory-bar 🧠 key shares the setting; its marker class lives on
// the bar element (keyboard-accessory.js), so a live toggle from a
// settings save reveals/hides it without a reload.
if ( typeof KeyboardAccessoryBar !== 'undefined' ) KeyboardAccessoryBar . syncReadMyMind ? .();
2026-08-09 23:05:11 +02:00
2026-07-30 14:27:45 +02:00
// Plan-usage chip — shown by default on desktop, OFF on handhelds (App
// Settings → Display → "Plan Usage Limits"). The template always ships it
// hidden because display is per-device and the server cannot know a
// localStorage value, so THIS is what reveals it on every load as well as
// on a live toggle. Marker class (base is display:inline-flex !important),
// matching the response-viewer/multimonitor pattern.
const showPlanUsageLimits = this . planUsageChipEnabled ( settings );
2026-06-14 05:00:28 +02:00
const planUsageChip = document . getElementById ( 'planUsageChip' );
if ( planUsageChip ) {
planUsageChip . classList . toggle ( 'header-plan-usage--hidden' , ! showPlanUsageLimits );
}
2026-06-17 23:19:54 +08:00
const showRedrawButton = settings . showRedrawButton ?? defaults . showRedrawButton ?? false ;
const redrawBtn = document . querySelector ( '.btn-redraw-terminal' );
if ( redrawBtn ) {
redrawBtn . classList . toggle ( 'btn-redraw-terminal--hidden' , ! showRedrawButton );
}
2026-07-19 17:59:23 +02:00
// Session Manager button — opt-in, hidden by default (App Settings → Display).
// Marker class (base is display:inline-flex !important); phones keep it hidden
// via mobile.css regardless. Sessions stay reachable via the Ctrl+K palette.
const showSessionButton = settings . showSessionButton ?? defaults . showSessionButton ?? false ;
const sessionBtn = document . querySelector ( '.btn-session-manager' );
if ( sessionBtn ) {
sessionBtn . classList . toggle ( 'btn-session-manager--hidden' , ! showSessionButton );
}
// Away Digest button — opt-in, hidden by default. Same marker pattern.
const showAwayDigestButton = settings . showAwayDigestButton ?? defaults . showAwayDigestButton ?? false ;
const awayDigestBtn = document . querySelector ( '.btn-away-digest' );
if ( awayDigestBtn ) {
awayDigestBtn . classList . toggle ( 'btn-away-digest--hidden' , ! showAwayDigestButton );
}
2026-07-20 14:36:18 +02:00
// Cron button (footer toolbar) — opt-in, hidden by default. Same marker pattern.
const showCronButton = settings . showCronButton ?? defaults . showCronButton ?? false ;
2026-07-19 17:59:23 +02:00
const cronBtn = document . querySelector ( '.btn-cron' );
if ( cronBtn ) {
cronBtn . classList . toggle ( 'btn-cron--hidden' , ! showCronButton );
}
2026-06-08 06:01:15 +02:00
// Notification bell is retired (notifications live in Settings → Notifications
// + the drawer); keep it hidden regardless of the notification-enabled state.
2026-03-14 21:24:22 +01:00
const notifBtn = document . querySelector ( '.btn-notifications' );
if ( notifBtn ) {
2026-06-08 06:01:15 +02:00
notifBtn . style . display = 'none' ;
2026-03-14 21:24:22 +01:00
}
// Close the drawer if notifications got disabled while it's open
2026-06-08 06:01:15 +02:00
const notifEnabled = this . notificationManager ? . preferences ? . enabled ?? true ;
2026-03-14 21:24:22 +01:00
if ( ! notifEnabled ) {
const drawer = document . getElementById ( 'notifDrawer' );
if ( drawer ) drawer . classList . remove ( 'open' );
}
},
2026-08-23 12:21:02 -04:00
applyTabOrientation ( options = {}) {
const settings = this . loadAppSettingsFromStorage ();
const defaults = this . getDefaultSettings ();
const sidebarOwnsTabs = this . isSessionSidebarActive ? .() === true ;
const orientation =
! this . isSoloWindow && ! sidebarOwnsTabs && window . CodemanTabOverflow ? . resolveTabOrientation
? window . CodemanTabOverflow . resolveTabOrientation ({
deviceType : MobileDetection . getDeviceType (),
setting : settings . tabOrientation ?? defaults . tabOrientation ?? 'horizontal' ,
})
: 'horizontal' ;
const root = document . documentElement ;
const previous = root . getAttribute ( 'data-tab-orientation' ) || 'horizontal' ;
root . setAttribute ( 'data-tab-orientation' , orientation );
2026-08-24 04:45:02 +02:00
// Row detail rides on its OWN attribute, exactly like the sidebar's
// data-sidebar-detail: every html[data-tab-orientation='vertical'] rule in
// styles.css keeps matching both variants untouched, and the gate in app.js
// reads one attribute instead of re-parsing localStorage per tab.
const previousDetail = root . dataset . tabRailDetail || 'rich' ;
const detail = ( settings . tabRailDetail ?? defaults . tabRailDetail ?? 'rich' ) === 'simple' ? 'simple' : 'rich' ;
root . dataset . tabRailDetail = detail ;
2026-09-12 06:09:28 +02:00
// Row ORDER rides on a third attribute, for the same reason detail rides on
// its own: a sort flip leaves orientation on 'vertical' both times, and the
// order is applied as an inline `order` the render paths emit, not by CSS
// that could just re-match. `isTabRailSorted()` (app.js) reads this.
const previousSort = root . dataset . tabRailSort || 'activity' ;
const sort = ( settings . tabRailSort ?? defaults . tabRailSort ?? 'activity' ) === 'manual' ? 'manual' : 'activity' ;
root . dataset . tabRailSort = sort ;
2026-08-23 12:21:02 -04:00
const tabsEl = document . getElementById ( 'sessionTabs' );
const rail = document . getElementById ( 'tabRail' );
const headerHost = document . getElementById ( 'sessionTabsHost' );
if ( ! sidebarOwnsTabs && tabsEl && rail && headerHost ) {
if ( orientation === 'vertical' ) {
if ( tabsEl . parentElement !== rail ) rail . appendChild ( tabsEl );
} else if ( tabsEl . parentElement !== headerHost ) {
headerHost . appendChild ( tabsEl );
}
}
if ( tabsEl ) {
tabsEl . setAttribute ( 'aria-orientation' , sidebarOwnsTabs || orientation === 'vertical' ? 'vertical' : 'horizontal' );
}
const settleRailWidth =
options . settleRailWidth === true && ( orientation === 'vertical' || previous !== orientation );
this . applyTabRailWidth ? .({ settle : settleRailWidth });
2026-08-24 04:45:02 +02:00
const orientationChanged = previous !== orientation ;
// A detail flip counts as a change on its own: simple ⟷ detailed leaves the
// orientation on 'vertical' both times, and the stamps line is emitted by
// the row template, not toggled by CSS — same reasoning as the sidebar's
// detail half in applySessionListLayout(). Taller rows also move every
// connector anchored to a tab rect.
2026-09-12 06:09:28 +02:00
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort ;
2026-08-24 04:45:02 +02:00
if ( orientationChanged ) {
2026-08-23 12:21:02 -04:00
this . updateTabOverflowMode ? .();
2026-09-22 13:07:33 +05:30
if ( ! settleRailWidth ) this . syncTerminalGeometry ? .();
2026-08-24 04:45:02 +02:00
}
// applyTabWrapSettings() is the ONE owner of tabs-show-folder and is
// rail-aware, so it has to run AFTER the two attributes above — the
// applySessionListLayout() call that precedes this one on the settings-save
// path ran while data-tab-rail-detail still held the old value. It
// re-renders by itself when the folder row appears or disappears, which is
// why the render below is skipped in that case rather than doubled.
const prevTall = this . _tallTabsEnabled ;
if ( changed ) this . applyTabWrapSettings ? .();
if ( changed ) {
2026-08-24 22:58:28 +02:00
// Mirror of applyTabWrapSettings()'s OWN render condition, which is
// `prevTallTabs !== undefined && prevTallTabs !== showFolder`: its first
// call ever only establishes the baseline and deliberately renders
// nothing. Reading an undefined previous value as "it rendered" skips
// BOTH renders and leaves the rows stale — reachable whenever this is the
// first call, i.e. when the pre-paint script threw and left the
// attributes on their fallbacks for applyTabOrientation() to correct.
const wrapRendered = prevTall !== undefined && prevTall !== this . _tallTabsEnabled ;
if ( ! wrapRendered ) this . _fullRenderSessionTabs ? .();
2026-08-23 12:21:02 -04:00
this . _updateConnectionLinesImmediate ? .();
this . _refreshHomeSessionsIfVisible ? .();
}
2026-08-24 04:45:02 +02:00
// Only detailed rows carry stamps that go stale with no event behind them.
// _fullRenderSessionTabs() settles this too, but applyTabOrientation() runs
// on paths where nothing re-rendered (boot with the layout already applied).
if ( this . isRichTabRows ? .()) this . _startSidebarRichClock ? .();
else this . _stopSidebarRichClock ? .();
2026-08-23 12:21:02 -04:00
},
2026-03-14 21:24:22 +01:00
applyTabWrapSettings () {
const settings = this . loadAppSettingsFromStorage ();
const defaults = this . getDefaultSettings ();
const deviceType = MobileDetection . getDeviceType ();
2026-08-04 12:38:36 +02:00
// The left sidebar is one vertical column with its own scroller: there is no
// row to wrap into, and its rows are always tall (name + folder) because that
// is the cheapest way to tell 25 sessions apart. Header strip keeps the old
// rules unchanged. Kept here rather than only in applySessionListLayout() so
// that a stray applyTabWrapSettings() call (this one is invoked from
// saveAppSettings and from the resize path) cannot leave the sidebar wrapped.
2026-08-18 16:26:34 +02:00
// Matches BOTH sidebar variants: isSessionSidebarActive() reads
// data-session-list, which applySessionListLayout() sets to 'sidebar' for
// 'sidebar' and 'sidebar-rich' alike. Row detail rides on a separate
// attribute and has no bearing on wrapping.
2026-08-04 12:38:36 +02:00
const sidebar = this . isSessionSidebarActive ? .() === true ;
2026-03-14 21:24:22 +01:00
// Two-row tabs disabled on mobile/tablet — not enough screen space
2026-08-04 12:38:36 +02:00
const twoRows = ! sidebar && deviceType === 'desktop'
2026-03-14 21:24:22 +01:00
? ( settings . tabTwoRows ?? defaults . tabTwoRows ?? false )
: false ;
2026-08-24 04:45:02 +02:00
// The DETAILED vertical rail is the third tall-row surface, for the same
// reason as the sidebar: it is a docked column with a row per session, and
// the stamps line below the name says nothing about WHICH project the
// session is in. Read from the applied attribute, which applyTabOrientation()
// has already written (app.js calls it before this).
const railRich = this . isTabRailRich ? .() === true ;
const showFolder = sidebar || twoRows || railRich ;
2026-03-14 21:24:22 +01:00
const prevTallTabs = this . _tallTabsEnabled ;
2026-08-04 12:38:36 +02:00
this . _tallTabsEnabled = showFolder ;
2026-03-14 21:24:22 +01:00
const tabsEl = document . getElementById ( 'sessionTabs' );
if ( tabsEl ) {
tabsEl . classList . toggle ( 'tabs-two-rows' , twoRows );
2026-08-04 12:38:36 +02:00
tabsEl . classList . toggle ( 'tabs-show-folder' , showFolder );
2026-03-14 21:24:22 +01:00
}
// Re-render tabs if folder visibility changed (folder spans are generated in JS)
2026-08-04 12:38:36 +02:00
if ( prevTallTabs !== undefined && prevTallTabs !== showFolder ) {
2026-03-14 21:24:22 +01:00
this . _fullRenderSessionTabs ();
}
},
applyMonitorVisibility () {
const settings = this . loadAppSettingsFromStorage ();
const defaults = this . getDefaultSettings ();
2026-06-10 11:12:19 +02:00
const showMonitor = settings . showMonitor ?? defaults . showMonitor ?? false ;
2026-03-14 21:24:22 +01:00
const showSubagents = settings . showSubagents ?? defaults . showSubagents ?? false ;
const showFileBrowser = settings . showFileBrowser ?? defaults . showFileBrowser ?? false ;
const monitorPanel = document . getElementById ( 'monitorPanel' );
if ( monitorPanel ) {
monitorPanel . style . display = showMonitor ? '' : 'none' ;
if ( showMonitor ) {
monitorPanel . classList . add ( 'open' );
} else {
monitorPanel . classList . remove ( 'open' );
}
}
const subagentsPanel = document . getElementById ( 'subagentsPanel' );
if ( subagentsPanel ) {
if ( showSubagents ) {
subagentsPanel . classList . remove ( 'hidden' );
} else {
subagentsPanel . classList . add ( 'hidden' );
}
}
2026-06-15 08:40:05 +02:00
// Ultracode agents panel visibility (SYNCED setting — not in displayKeys)
const showUltracodeAgents = settings . showUltracodeAgents ?? defaults . showUltracodeAgents ?? false ;
const ultracodePanel = document . getElementById ( 'ultracodeAgentsPanel' );
if ( ultracodePanel ) {
if ( showUltracodeAgents ) {
ultracodePanel . classList . remove ( 'hidden' );
} else {
ultracodePanel . classList . remove ( 'open' );
ultracodePanel . classList . add ( 'hidden' );
}
}
2026-06-15 16:33:54 +02:00
// Floating ultracode run windows have their OWN opt-in (default OFF), independent of the
// docked panel above: pop active runs when enabled, tear them all down when disabled
// (additional layer — ultracode-windows.js).
const ultracodeFloatingWindows = settings . ultracodeFloatingWindows ?? defaults . ultracodeFloatingWindows ?? false ;
if ( ultracodeFloatingWindows ) {
if ( typeof this . syncAllUltracodeFloatingWindows === 'function' ) this . syncAllUltracodeFloatingWindows ();
} else if ( typeof this . removeAllUltracodeWindows === 'function' ) {
this . removeAllUltracodeWindows ();
}
2026-06-15 08:40:05 +02:00
2026-03-14 21:24:22 +01:00
// File browser panel visibility
const fileBrowserPanel = document . getElementById ( 'fileBrowserPanel' );
if ( fileBrowserPanel ) {
if ( showFileBrowser && this . activeSessionId ) {
fileBrowserPanel . classList . add ( 'visible' );
this . loadFileBrowser ( this . activeSessionId );
// Attach drag listeners if not already attached
if ( ! this . fileBrowserDragListeners ) {
const header = fileBrowserPanel . querySelector ( '.file-browser-header' );
if ( header ) {
// Convert right-positioned to left/top before drag so makeWindowDraggable works
const onFirstDrag = () => {
if ( ! fileBrowserPanel . style . left ) {
const rect = fileBrowserPanel . getBoundingClientRect ();
fileBrowserPanel . style . left = ` ${ rect . left } px` ;
fileBrowserPanel . style . top = ` ${ rect . top } px` ;
fileBrowserPanel . style . right = 'auto' ;
}
};
header . addEventListener ( 'mousedown' , onFirstDrag );
header . addEventListener ( 'touchstart' , onFirstDrag , { passive : true });
this . fileBrowserDragListeners = this . makeWindowDraggable ( fileBrowserPanel , header );
this . fileBrowserDragListeners . _onFirstDrag = onFirstDrag ;
}
}
2026-08-23 21:58:45 -04:00
} else if ( fileBrowserPanel . classList . contains ( 'visible' )) {
this . _resetFileBrowserForHide ? .();
2026-03-14 21:24:22 +01:00
fileBrowserPanel . classList . remove ( 'visible' );
}
}
},
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 ;
this . saveAppSettingsToStorage ( settings );
},
closeSubagentsPanel () {
// Hide the subagents panel
const subagentsPanel = document . getElementById ( 'subagentsPanel' );
if ( subagentsPanel ) {
subagentsPanel . classList . remove ( 'open' );
subagentsPanel . classList . add ( 'hidden' );
}
this . subagentPanelVisible = false ;
// Save the setting
const settings = this . loadAppSettingsFromStorage ();
settings . showSubagents = false ;
this . saveAppSettingsToStorage ( settings );
},
async clearAllSubagents () {
const count = this . subagents . size ;
if ( count === 0 ) {
this . showToast ( 'No subagents to clear' , 'info' );
return ;
}
if ( ! confirm ( `Clear all ${ count } tracked subagent(s)? This removes them from the UI but does not affect running processes.` )) {
return ;
}
try {
const res = await fetch ( '/api/subagents' , { method : 'DELETE' });
const data = await res . json ();
if ( data . success ) {
// Clear local state
this . subagents . clear ();
this . subagentActivity . clear ();
this . subagentToolResults . clear ();
// Close any open subagent windows
this . cleanupAllFloatingWindows ();
// Update UI
this . renderSubagentPanel ();
this . renderMonitorSubagents ();
this . updateSubagentBadge ();
this . showToast ( `Cleared ${ data . data . cleared } subagent(s)` , 'success' );
} else {
this . showToast ( 'Failed to clear subagents: ' + data . error , 'error' );
}
} catch ( err ) {
this . showToast ( 'Failed to clear subagents' , 'error' );
}
},
toggleSubagentsPanel () {
const panel = document . getElementById ( 'subagentsPanel' );
const toggleBtn = document . getElementById ( 'subagentsToggleBtn' );
if ( ! panel ) return ;
// If hidden, show it first
if ( panel . classList . contains ( 'hidden' )) {
panel . classList . remove ( 'hidden' );
// Save setting
const settings = this . loadAppSettingsFromStorage ();
settings . showSubagents = true ;
this . saveAppSettingsToStorage ( settings );
}
// Toggle open/collapsed state
panel . classList . toggle ( 'open' );
this . subagentPanelVisible = panel . classList . contains ( 'open' );
// Update toggle button icon
if ( toggleBtn ) {
toggleBtn . innerHTML = this . subagentPanelVisible ? '▼' : '▲' ; // Down when open, up when collapsed
}
if ( this . subagentPanelVisible ) {
this . renderSubagentPanel ();
}
},
async loadAppSettingsFromServer ( settingsPromise = null ) {
2026-06-14 20:15:39 +02:00
// One-time migration: showPlanUsageLimits became a per-device display setting.
// Before this, it synced from the server, so the (separate) mobile settings blob
// may carry a stale `true` the user never enabled on this device. Clear it once
// so mobile defaults to OFF; the desktop blob is untouched and keeps its value.
try {
if (
2026-07-20 21:22:28 +08:00
( MobileDetection . isHandheldDevice ? .() ?? MobileDetection . getDeviceType () === 'mobile' ) &&
2026-06-14 20:15:39 +02:00
! localStorage . getItem ( 'codeman:planUsagePerDeviceMigrated' )
) {
const s = this . loadAppSettingsFromStorage ();
if ( s && s . showPlanUsageLimits ) {
s . showPlanUsageLimits = false ;
this . saveAppSettingsToStorage ( s );
}
localStorage . setItem ( 'codeman:planUsagePerDeviceMigrated' , '1' );
}
} catch {
/* best-effort migration */
}
2026-03-14 21:24:22 +01:00
try {
2026-06-10 01:30:43 +02:00
const settings = settingsPromise ? await settingsPromise : await fetch ( '/api/settings' ). then ( r => r . ok ? r . json () : null ). then ( env => env ? . success === true ? env . data : env );
2026-03-14 21:24:22 +01:00
if ( settings ) {
// Extract notification prefs before merging app settings
const { notificationPreferences , voiceSettings , respawnPresets , runMode , ... appSettings } = settings ;
// Filter out display settings — these are device-specific (mobile vs desktop)
// and should not be synced from the server to avoid overriding mobile defaults.
// NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
// are NOT display keys — they control server-side behavior and must sync from server.
const displayKeys = new Set ([
'showFontControls' , 'showSystemStats' , 'showTokenCount' , 'showCost' ,
2026-06-17 23:19:54 +08:00
'showLifecycleLog' , 'showResponseViewer' , 'showRedrawButton' ,
2026-03-14 21:24:22 +01:00
'showMonitor' , 'showProjectInsights' , 'showFileBrowser' , 'showSubagents' ,
2026-09-12 06:09:28 +02:00
'subagentActiveTabOnly' , 'tabTwoRows' , 'tabOrientation' , 'tabRailWidth' , 'tabRailDetail' , 'tabRailSort' , 'sessionListLayout' , 'sessionSidebarFontSize' , 'localEchoEnabled' , 'cjkInputEnabled' , 'extendedKeyboardBar' ,
2026-07-20 01:28:53 +02:00
'skin' , 'showPlanUsageLimits' , 'showAttachmentsButton' , 'showFileViewerButton' , 'webglRendererEnabled' ,
2026-09-14 14:09:13 +02:00
'terminalFontFamily' , 'terminalFontWeight' , 'terminalFontWeightBold' ,
2026-07-21 02:49:25 +08:00
'language' ,
2026-07-16 09:33:39 +02:00
'terminalWheelLocalScrollback' ,
2026-09-22 08:15:57 +02:00
'autoCopySelection' , 'copyStripMargin' ,
2026-07-19 17:59:23 +02:00
'showSessionButton' , 'showAwayDigestButton' , 'showCronButton' ,
2026-08-08 13:34:05 +02:00
'showTabDetachButton' ,
2026-08-04 15:02:46 +02:00
'mobileOverviewEnabled' ,
2026-08-12 02:30:08 +02:00
'sessionLineageLines' ,
2026-09-19 11:47:31 -05:00
'showSplitButton' ,
2026-03-14 21:24:22 +01:00
]);
2026-07-30 14:27:45 +02:00
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
2026-09-07 20:37:29 -05:00
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
// the server's stored value here so it is NEVER seeded into a device that
// didn't explicitly enable it — even though this SAME setting also drives
// server-side telemetry collection now (readPlanUsageTelemetryEnabled in
// hooks-config.ts), that's a read the server does directly from settings.json
// at spawn time; it has nothing to do with what gets merged into THIS
// device's local display preference.
2026-06-14 20:15:39 +02:00
delete appSettings . showPlanUsageLimits ;
2026-03-14 21:24:22 +01:00
// Merge settings: non-display keys always sync from server,
// display keys only seed from server when localStorage has no value
// (prevents cross-device overwrite while fixing settings re-enabling on fresh loads)
const localSettings = this . loadAppSettingsFromStorage ();
const merged = { ... localSettings };
for ( const [ key , value ] of Object . entries ( appSettings )) {
if ( displayKeys . has ( key )) {
// Display keys: only use server value as initial seed
if ( ! ( key in localSettings )) {
merged [ key ] = value ;
}
} else {
// Non-display keys: server always wins
merged [ key ] = value ;
}
}
this . saveAppSettingsToStorage ( merged );
// Apply notification prefs from server if present (only if localStorage has none)
if ( notificationPreferences && this . notificationManager ) {
const localNotifPrefs = localStorage . getItem ( this . notificationManager . getStorageKey ());
if ( ! localNotifPrefs ) {
2026-07-28 23:20:01 +03:00
this . notificationManager . preferences =
this . notificationManager . normalizePreferences ( notificationPreferences );
2026-03-14 21:24:22 +01:00
this . notificationManager . savePreferences ();
}
}
// Sync voice settings from server (seed localStorage if no local API key)
if ( voiceSettings ) {
const localVoice = localStorage . getItem ( 'codeman-voice-settings' );
if ( ! localVoice || ! JSON . parse ( localVoice ). apiKey ) {
VoiceInput . _saveDeepgramConfig ( voiceSettings );
}
}
// Sync respawn presets from server (server is source of truth)
if ( respawnPresets && Array . isArray ( respawnPresets )) {
this . _serverRespawnPresets = respawnPresets ;
// Also update localStorage for offline access
localStorage . setItem ( 'codeman-respawn-presets' , JSON . stringify ( respawnPresets ));
} else {
// Migration: push existing localStorage presets to server
const localPresets = localStorage . getItem ( 'codeman-respawn-presets' );
if ( localPresets ) {
const parsed = JSON . parse ( localPresets );
if ( parsed . length > 0 ) {
this . _serverRespawnPresets = parsed ;
this . _apiPut ( '/api/settings' , { respawnPresets : parsed }). catch (() => {});
}
}
}
// Sync run mode from server
if ( runMode ) {
this . runMode = runMode ;
try { localStorage . setItem ( 'codeman_runMode' , runMode ); } catch {}
this . _applyRunMode ();
}
return merged ;
}
} catch ( err ) {
console . error ( 'Failed to load settings from server:' , err );
}
return this . loadAppSettingsFromStorage ();
},
/**
* Load subagent window states from server (or localStorage fallback).
* Called on page load to restore minimized/open window states.
*/
async loadSubagentWindowStates () {
let states = null ;
// Try server first for cross-browser sync
try {
const res = await fetch ( '/api/subagent-window-states' );
if ( res . ok ) {
2026-06-10 01:30:43 +02:00
const env = await res . json ();
states = env ? . success === true ? env . data : env ;
2026-03-14 21:24:22 +01:00
// Also update localStorage
localStorage . setItem ( 'codeman-subagent-window-states' , JSON . stringify ( states ));
}
} catch ( err ) {
console . error ( 'Failed to load subagent window states from server:' , err );
}
// Fallback to localStorage
if ( ! states ) {
try {
const saved = localStorage . getItem ( 'codeman-subagent-window-states' );
if ( saved ) {
states = JSON . parse ( saved );
}
} catch ( err ) {
console . error ( 'Failed to load subagent window states from localStorage:' , err );
}
}
return states || { minimized : {}, open : [] };
},
// ═══════════════════════════════════════════════════════════════
// Persistent Parent Associations
// ═══════════════════════════════════════════════════════════════
// This is the ROCK-SOLID system for tracking which tab an agent belongs to.
// Once an agent's parent is discovered, it's saved here PERMANENTLY.
/**
* Save the subagent parent map to localStorage and server.
* Called whenever a new parent association is discovered.
*/
async saveSubagentParentMap () {
const mapData = Object . fromEntries ( this . subagentParentMap );
// Save to localStorage for instant recovery
try {
localStorage . setItem ( 'codeman-subagent-parents' , JSON . stringify ( mapData ));
} catch ( err ) {
console . error ( 'Failed to save subagent parents to localStorage:' , err );
}
// Save to server for cross-browser/session persistence
try {
await fetch ( '/api/subagent-parents' , {
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ( mapData )
});
} catch ( err ) {
console . error ( 'Failed to save subagent parents to server:' , err );
}
},
/**
* Load the subagent parent map from server (or localStorage fallback).
* Called once on page load, before any agents are discovered.
*/
async loadSubagentParentMap () {
let mapData = null ;
// Try server first (most authoritative)
try {
const res = await fetch ( '/api/subagent-parents' );
if ( res . ok ) {
2026-06-10 01:30:43 +02:00
const env = await res . json ();
mapData = env ? . success === true ? env . data : env ;
2026-03-14 21:24:22 +01:00
// Update localStorage as cache
localStorage . setItem ( 'codeman-subagent-parents' , JSON . stringify ( mapData ));
}
} catch ( err ) {
console . error ( 'Failed to load subagent parents from server:' , err );
}
// Fallback to localStorage
if ( ! mapData ) {
try {
const saved = localStorage . getItem ( 'codeman-subagent-parents' );
if ( saved ) {
mapData = JSON . parse ( saved );
}
} catch ( err ) {
console . error ( 'Failed to load subagent parents from localStorage:' , err );
}
}
// Populate the map (prune stale entries: require both session and agent to exist)
if ( mapData && typeof mapData === 'object' ) {
for ( const [ agentId , sessionId ] of Object . entries ( mapData )) {
if ( this . sessions . has ( sessionId ) && this . subagents . has ( agentId )) {
this . subagentParentMap . set ( agentId , sessionId );
}
}
}
},
/**
* Get the parent session ID for an agent from the persistent map.
* This is the ONLY source of truth for connection lines.
*/
getAgentParentSessionId ( agentId ) {
return this . subagentParentMap . get ( agentId ) || null ;
},
/**
* Set and persist the parent session ID for an agent.
* Once set, this association is PERMANENT and never recalculated.
*/
setAgentParentSessionId ( agentId , sessionId ) {
if ( ! agentId || ! sessionId ) return ;
// Only set if not already set (first association wins)
if ( this . subagentParentMap . has ( agentId )) {
return ; // Already has a parent, don't override
}
this . subagentParentMap . set ( agentId , sessionId );
this . saveSubagentParentMap (); // Persist immediately
// Also update the agent object for consistency
const agent = this . subagents . get ( agentId );
if ( agent ) {
agent . parentSessionId = sessionId ;
const session = this . sessions . get ( sessionId );
if ( session ) {
agent . parentSessionName = this . getSessionName ( session );
}
this . subagents . set ( agentId , agent );
}
},
// ═══════════════════════════════════════════════════════════════
// Help Modal
// ═══════════════════════════════════════════════════════════════
showHelp () {
const modal = document . getElementById ( 'helpModal' );
modal . classList . add ( 'active' );
// Activate focus trap
this . activeFocusTrap = new FocusTrap ( modal );
this . activeFocusTrap . activate ();
},
closeHelp () {
document . getElementById ( 'helpModal' ). classList . remove ( 'active' );
// Deactivate focus trap and restore focus
if ( this . activeFocusTrap ) {
this . activeFocusTrap . deactivate ();
this . activeFocusTrap = null ;
}
},
2026-07-09 11:26:24 -04:00
// ─── Shortcut Settings (App Settings → Shortcuts tab) ────────────────────────
// Renders the list of shortcuts with capture buttons for key rebinding,
2026-07-12 19:06:12 +02:00
// and persists overrides under settings.shortcutOverrides (saved through
// saveAppSettingsToStorage so the device key + settings cache stay coherent).
2026-07-09 11:26:24 -04:00
renderShortcutSettingsList () {
const list = document . getElementById ( 'appSettingsShortcutsList' );
if ( ! list ) return ;
2026-07-12 19:06:12 +02:00
const registry = this . getShortcutRegistry
? this . getShortcutRegistry ()
: typeof DEFAULT_SHORTCUTS !== 'undefined'
? DEFAULT_SHORTCUTS
: [];
const overrides = this . readShortcutOverridesFromSettings ();
list . innerHTML = registry
. map (( shortcut ) => {
const bindingLabel = shortcut . displayBindings
? shortcut . displayBindings . join ( ' / ' )
: ( shortcut . bindings || []). map (( b ) => [...( b . modifiers || []), b . key || b . code || '' ]. join ( '+' )). join ( ' / ' );
// Only registry entries dispatched through matchesShortcutEvent() are
// configurable; fixed keys (Escape, tab arrows, …) render read-only.
const configurable = !! shortcut . action && Array . isArray ( shortcut . bindings );
const overridden = !! overrides [ shortcut . id ];
const controls = configurable
? `<button type="button" class="shortcut-capture-btn" data-shortcut-action="capture" title="Capture new binding">Edit</button>
<button type="button" class="shortcut-reset-btn" data-shortcut-action="reset" title="Reset to default" ${ overridden ? '' : ' disabled' } >Reset</button>
<input class="shortcut-enabled-checkbox" type="checkbox" ${ shortcut . disabled ? '' : 'checked' } data-shortcut-action="toggle" title="Enable/disable">`
: '' ;
return `<div class="shortcut-setting-row ${ configurable ? '' : ' shortcut-setting-row--fixed' } " data-shortcut-id=" ${ escapeHtml ( shortcut . id ) } ">
2026-07-09 11:26:24 -04:00
<label class="shortcut-setting-label"> ${ escapeHtml ( shortcut . label ) } </label>
<input class="shortcut-binding-input" type="text" readonly value=" ${ escapeHtml ( bindingLabel ) } " placeholder="(none)" data-id=" ${ escapeHtml ( shortcut . id ) } ">
2026-07-12 19:06:12 +02:00
${ controls }
2026-07-09 11:26:24 -04:00
</div>` ;
2026-07-12 19:06:12 +02:00
})
. join ( '' );
this . _wireShortcutSettingsList ( list );
},
// Delegated handlers (no inline onclick — registry ids never land inside a
// JS string context, and the listeners survive re-renders).
_wireShortcutSettingsList ( list ) {
if ( list . dataset . shortcutListenersAdded ) return ;
list . dataset . shortcutListenersAdded = 'true' ;
list . addEventListener ( 'click' , ( e ) => {
const btn = e . target ? . closest ? .( '[data-shortcut-action]' );
if ( ! btn ) return ;
const id = btn . closest ? .( '[data-shortcut-id]' ) ? . dataset ? . shortcutId ;
if ( ! id ) return ;
if ( btn . dataset . shortcutAction === 'capture' ) this . startShortcutCapture ( id );
else if ( btn . dataset . shortcutAction === 'reset' ) this . resetShortcutOverride ( id );
});
list . addEventListener ( 'change' , ( e ) => {
const box = e . target ;
if ( ! box ? . matches ? .( '[data-shortcut-action="toggle"]' )) return ;
const id = box . closest ? .( '[data-shortcut-id]' ) ? . dataset ? . shortcutId ;
if ( id ) this . toggleShortcutEnabled ( id , box . checked );
});
2026-07-09 11:26:24 -04:00
},
readShortcutOverridesFromSettings () {
const settings = this . loadAppSettingsFromStorage ();
return settings . shortcutOverrides || {};
},
startShortcutCapture ( shortcutId ) {
const input = document . querySelector ( `.shortcut-binding-input[data-id=" ${ shortcutId } "]` );
if ( ! input ) return ;
input . value = 'Press keys…' ;
input . focus ();
this . _capturingShortcutId = shortcutId ;
2026-07-12 19:06:12 +02:00
// Persistent listener (NOT {once}) — the first keydown of a combo like
// Ctrl+Shift+P is the modifier itself ('Control'), which must not end the
// capture. The first non-modifier key completes it.
const onCaptureKeydown = ( e ) => {
e . preventDefault ();
e . stopPropagation ();
if ( e . key === 'Control' || e . key === 'Shift' || e . key === 'Alt' || e . key === 'Meta' ) return ;
input . removeEventListener ( 'keydown' , onCaptureKeydown );
this . onShortcutCaptureKeydown ( e , shortcutId );
};
input . addEventListener ( 'keydown' , onCaptureKeydown );
2026-07-09 11:26:24 -04:00
},
onShortcutCaptureKeydown ( e , shortcutId ) {
e . preventDefault ();
e . stopPropagation ();
2026-07-12 19:06:12 +02:00
this . _capturingShortcutId = null ;
2026-07-09 11:26:24 -04:00
if ( e . key === 'Escape' ) {
this . renderShortcutSettingsList ();
return ;
}
2026-07-12 19:06:12 +02:00
// Require a real chord: the dispatcher has no focus-target guard, so a
// bare-key binding would fire while typing in any input.
if ( ! e . ctrlKey && ! e . metaKey && ! e . altKey ) {
this . renderShortcutSettingsList ();
this . showToast ? .( 'Shortcut must include Ctrl, Cmd, or Alt' , 'error' );
return ;
}
2026-07-09 11:26:24 -04:00
const modifiers = [];
if ( e . ctrlKey ) modifiers . push ( 'ctrl' );
if ( e . metaKey ) modifiers . push ( 'meta' );
if ( e . shiftKey ) modifiers . push ( 'shift' );
if ( e . altKey ) modifiers . push ( 'alt' );
2026-07-12 19:06:12 +02:00
const settings = this . loadAppSettingsFromStorage ();
const shortcutOverrides = { ...( settings . shortcutOverrides || {}) };
shortcutOverrides [ shortcutId ] = {
...( shortcutOverrides [ shortcutId ] || {}),
bindings : [{ modifiers , key : e . key , code : e . code }],
};
2026-07-09 11:26:24 -04:00
settings . shortcutOverrides = shortcutOverrides ;
2026-07-12 19:06:12 +02:00
this . saveAppSettingsToStorage ( settings );
2026-07-09 11:26:24 -04:00
this . renderShortcutSettingsList ();
},
resetShortcutOverride ( shortcutId ) {
const settings = this . loadAppSettingsFromStorage ();
2026-07-12 19:06:12 +02:00
const shortcutOverrides = { ...( settings . shortcutOverrides || {}) };
2026-07-09 11:26:24 -04:00
delete shortcutOverrides [ shortcutId ];
settings . shortcutOverrides = shortcutOverrides ;
2026-07-12 19:06:12 +02:00
this . saveAppSettingsToStorage ( settings );
2026-07-09 11:26:24 -04:00
this . renderShortcutSettingsList ();
},
toggleShortcutEnabled ( shortcutId , enabled ) {
const settings = this . loadAppSettingsFromStorage ();
2026-07-12 19:06:12 +02:00
const shortcutOverrides = { ...( settings . shortcutOverrides || {}) };
2026-07-09 11:26:24 -04:00
shortcutOverrides [ shortcutId ] = { ...( shortcutOverrides [ shortcutId ] || {}), disabled : ! enabled };
settings . shortcutOverrides = shortcutOverrides ;
2026-07-12 19:06:12 +02:00
this . saveAppSettingsToStorage ( settings );
this . renderShortcutSettingsList ();
2026-07-09 11:26:24 -04:00
},
2026-03-14 21:24:22 +01:00
closeAllPanels () {
this . closeSessionOptions ();
this . closeAppSettings ();
this . cancelCloseSession ();
this . closeTokenStats ();
document . getElementById ( 'monitorPanel' ). classList . remove ( 'open' );
// Collapse subagents panel (don't hide it permanently)
const subagentsPanel = document . getElementById ( 'subagentsPanel' );
if ( subagentsPanel ) {
subagentsPanel . classList . remove ( 'open' );
}
this . subagentPanelVisible = false ;
},
});
2026-09-16 07:00:55 +08:00
// window.__codemanUser's real role can resolve after settings have already been
// opened once (admin-ui.js fetches /api/me asynchronously and dispatches this on
// arrival), so the Custom Model Endpoints admin gate needs to be re-applied when
// it does, not just when the modal opens. Optional chaining on addEventListener
// itself: several frontend tests (run-mode-ui.test.ts) load this file into a vm
// context with a minimal fake `document` that has no event-target methods at
// all, and a module-level statement that throws there fails the whole file's
// evaluation, not just this feature.
document . addEventListener ? .( 'codeman:me' , () => {
window . app ? . _applyCustomModelAdminGate ? .();
2026-09-24 07:48:26 +08:00
window . app ? . _applyCliManagementAdminGate ? .();
2026-10-04 23:43:36 +02:00
window . app ? . _applyMcpSyncAdminGate ? .();
2026-09-16 07:00:55 +08:00
});