/** * @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) { // 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; // Always track pending hook - alert will show when switching away from session if (data.sessionId) { this.setPendingHook(data.sessionId, 'idle_prompt'); } this._notifySession(data.sessionId, 'warning', 'hook-idle', 'Waiting for Input', data.message || 'Claude is idle and waiting for a prompt'); }, _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 : ''}` : ''; // A dialog read off a hook-less CLI's screen (codex; `source: 'screen'`, see // src/web/screen-blocked.ts) carries a server-built message naming that CLI, so the // notification does not claim it is Claude asking. const screenMessage = data.source === 'screen' && data.message ? data.message : ''; this._notifySession(data.sessionId, 'critical', 'hook-permission', 'Permission Required', toolInfo || screenMessage || 'Claude needs tool approval to continue'); }, _onHookElicitationDialog(data) { // Always track pending hook - action alerts need user interaction to clear if (data.sessionId) { this.setPendingHook(data.sessionId, 'elicitation_dialog'); } this._notifySession(data.sessionId, 'critical', 'hook-elicitation', 'Question Asked', data.question || 'Claude is asking a question and waiting for your answer'); }, _onHookElicitationComplete(data) { // Question answered in the terminal: clear the action alert without // waiting for `stop` (the turn may keep running for a long time). // ⚠️ 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). if (data.sessionId) { this.clearPendingHooks(data.sessionId, 'elicitation_dialog'); this.clearPendingHooks(data.sessionId, 'permission_prompt'); } }, _onHookElicitationResponse(data) { this._onHookElicitationComplete(data); }, _onHookStop(data) { // The end of Codeman's own keep-alive turn (prompt-cache keep-warm): nothing finished // that a person asked for, and an idle alert still pending is theirs to spend, so this // neither clears it nor raises "Response Complete". if (data.acknowledgedReason) return; // Clear all pending hooks when Claude finishes responding if (data.sessionId) { this.clearPendingHooks(data.sessionId); } this._notifySession(data.sessionId, 'info', 'hook-stop', 'Response Complete', data.reason || 'Claude has finished responding'); }, _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'); } }, _onHookTeammateIdle(data) { const session = this.sessions.get(data.sessionId); this._notifySession(data.sessionId, 'warning', 'hook-teammate-idle', 'Teammate Idle', `A teammate is idle in ${session?.name || data.sessionId}`); }, _onHookTaskCompleted(data) { const session = this.sessions.get(data.sessionId); this._notifySession(data.sessionId, 'info', 'hook-task-completed', 'Task Completed', `A team task completed in ${session?.name || data.sessionId}`); }, // 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 = ` ${data.message}`; } // Also update button text if on welcome screen const btn = document.getElementById('welcomeTunnelBtn'); if (btn?.classList.contains('connecting')) { btn.innerHTML = ` ${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; // Behind a sub-path mount the worker is served at /sw.js and controls // / (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) => { this._swRegistration = reg; // Listen for messages from service worker (notification clicks) navigator.serviceWorker.addEventListener('message', (event) => { if (event.data?.type === 'notification-click') { const { sessionId, action, approvalId } = event.data; if (action) { // Approve/Deny action buttons on a push: answer via the // Approvals Inbox instead of just focusing the session. this.handleNotificationAction?.(action, approvalId, sessionId); } else if (sessionId && this.sessions.has(sessionId)) { 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'); if (!keyData) throw new Error('Failed to get VAPID key'); const applicationServerKey = urlBase64ToUint8Array(keyData.publicKey); 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(), }, }); if (!data) throw new Error('Failed to register subscription'); this._pushSubscription = subscription; this._pushSubscriptionId = data.id; localStorage.setItem('codeman-push-subscription-id', data.id); 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 // ═══════════════════════════════════════════════════════════════ /** * 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() || ''; }, /** * Announce a new release once per browser: one toast naming its headline changes. * The notes come from /whats-new.json, which `npm run version-packages` regenerates from * CHANGELOG.md for each release. A browser that has never seen any version just records the * current one, so a fresh install is not greeted with "what's new". * * The same pass places the security notices (/security-notices.json, see * _loadSecurityNotices), so a load shows ONE notification per notice: * CodemanSecurityNotices.plan() (constants.js) puts every selected notice INSIDE the update * toast when that toast shows (showUpdateNotice), and gives each its own card otherwise, a * fresh install or a reload on the same version (showSecurityNotice). With no notice * selected the update toast is the plain one it always was. Storage blocked shows neither: * nothing could remember a close. * * ⚠️ A detached session window (/session/:id) is rendered without __codemanCliAvailable, so * it can never select a notice naming a CLI. Were it to take the update toast anyway, it * would record the version and the dashboard would later show that notice as a separate * card, not inside the update toast. So while any notice is pending (_loadSecurityNotices * counts every CLI the file names as installed there), a solo window shows and records * nothing and leaves both to the dashboard; with none pending it shows the plain update * toast as it always did. */ async checkWhatsNew(runningVersion) { if (this._whatsNewChecked) return; this._whatsNewChecked = true; const KEY = 'codeman-whats-new-seen'; let seen = null; try { seen = localStorage.getItem(KEY); } catch { /* storage blocked: skip the announcement and the notices */ return; } const remember = () => { try { localStorage.setItem(KEY, runningVersion); } catch { /* ignore */ } }; const [notes, notices] = await Promise.all([ seen && seen !== runningVersion ? this._fetchWhatsNewNotes() : null, this._loadSecurityNotices(), ]); if (this.isSoloWindow && notices.length) return; const plan = window.CodemanSecurityNotices.plan({ seenVersion: seen, runningVersion, notes, notices }); if (plan.recordVersion) remember(); if (plan.update) { const shown = notes.highlights.slice(0, 3).join(' · '); const more = notes.highlights.length > 3 ? ` (+${notes.highlights.length - 3} more)` : ''; const text = `Codeman updated to v${runningVersion}: ${shown}${more}`; const releaseUrl = `https://github.com/Ark0N/Codeman/releases/tag/codeman%40${runningVersion}`; if (plan.updateNotices.length) { this.showUpdateNotice({ text, releaseUrl, notices: plan.updateNotices, onNoticeSeen: (id) => this._rememberSecurityNotice(id), }); } else { this.showToast(text, 'info', { duration: 20000, action: { label: "See what's new", onClick: () => window.open(releaseUrl, '_blank', 'noopener'), }, }); } } for (const notice of plan.cards) { this.showSecurityNotice(notice, { onClose: () => this._rememberSecurityNotice(notice.id) }); } }, /** This release's notes, or null (offline or malformed: try again next load). */ async _fetchWhatsNewNotes() { try { const res = await fetch('/whats-new.json', { cache: 'no-cache' }); return res.ok ? await res.json() : null; } catch { return null; } }, /** * The security notices this browser should see now, from the HAND-maintained * /security-notices.json (docs/releasing.md says how to add and retire one), selected by * CodemanSecurityNotices.select() in constants.js: dates, the seen list, and for a notice * naming a CLI, `window.__codemanCliAvailable[cli] === true` only (unknown means not * installed). [] on any failure, and when storage is blocked (a close could not be * remembered, so the card would return on every load). In a solo window every CLI the file * names counts as installed: there the answer only decides whether to leave the release to * the dashboard (see checkWhatsNew), and is never shown. */ async _loadSecurityNotices() { let seen; try { seen = this._readSecurityNoticesSeen(); } catch { /* storage blocked: show nothing */ return []; } try { const res = await fetch(CodemanBase.url('/security-notices.json'), { cache: 'no-cache' }); if (!res.ok) return []; const data = await res.json(); const list = Array.isArray(data?.notices) ? data.notices : []; const available = this.isSoloWindow ? Object.fromEntries(list.filter((entry) => typeof entry?.cli === 'string').map((entry) => [entry.cli, true])) : window.__codemanCliAvailable; return window.CodemanSecurityNotices.select(data?.notices, { available, seen, now: Date.now(), lang: window.CodemanI18n?.language || window.__codemanLanguage, }); } catch { /* offline or malformed: try again next load */ return []; } }, /** The ids the user closed or followed (`codeman-security-notices-seen`). Throws when storage is blocked. */ _readSecurityNoticesSeen() { const raw = localStorage.getItem('codeman-security-notices-seen'); let ids = []; try { ids = raw ? JSON.parse(raw) : []; } catch { /* a corrupt value counts as nothing seen */ } return Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : []; }, /** * Record a notice as seen. Only a user action calls this (a close, Dismiss, its link), never * the card appearing, so one shown while nobody was looking comes back on the next load. * Re-reads first: another tab may have recorded a notice since this one loaded. */ _rememberSecurityNotice(id) { try { const ids = this._readSecurityNoticesSeen(); if (!ids.includes(id)) ids.push(id); localStorage.setItem('codeman-security-notices-seen', JSON.stringify(ids)); } catch { /* storage blocked since load: it shows again next load */ } }, openAppSettings() { // Load current settings const settings = this.loadAppSettingsFromStorage(); document.getElementById('appSettingsDisplayName').value = settings.displayName || 'Codeman'; document.getElementById('appSettingsLanguage').value = settings.language === 'zh-CN' ? 'zh-CN' : 'en'; 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; // Opt-in host readings after CPU and MEM: all default OFF. document.getElementById('appSettingsShowGpuStats').checked = settings.showGpuStats ?? defaults.showGpuStats ?? false; document.getElementById('appSettingsShowCpuMhz').checked = settings.showCpuMhz ?? defaults.showCpuMhz ?? false; document.getElementById('appSettingsHeaderStatsStyle').value = this.resolveHeaderStatsStyle(settings); document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false; document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false; document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true; document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false; document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue'; // 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?.(); // 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'; document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? false; 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; document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false; // Approvals Inbox: synced, default OFF (opt-in; only an explicit true enables). document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true; // Agent inbox snapshot on disk: synced, default OFF. document.getElementById('appSettingsAgentInboxPersist').checked = settings.agentInboxPersistEnabled === true; // Experimental agent coordination (inspect/stage/submit routes): synced, default OFF. document.getElementById('appSettingsAgentCoordination').checked = settings.agentCoordinationEnabled === true; // Custom Model Endpoint Profiles: synced, default OFF. The toggle governs both // the Run-menu picker's generated entries and this settings panel's visibility; // the endpoint list itself is server state, loaded on demand below. document.getElementById('appSettingsCustomModelEndpoints').checked = settings.customModelEndpointsEnabled === true; // 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(); // 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(); // MCP server sync: synced, default OFF; same explicit-sync reasoning as above. // 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; this.applyMcpSyncVisibility(); // Config backups: absent means ON, 20 copies, 30 days. document.getElementById('appSettingsConfigBackup').checked = settings.configBackupEnabled !== false; document.getElementById('appSettingsConfigBackupDir').value = settings.configBackupDir || ''; document.getElementById('appSettingsConfigBackupKeepCount').value = settings.configBackupKeepCount ?? 20; document.getElementById('appSettingsConfigBackupKeepDays').value = settings.configBackupKeepDays ?? 30; this.loadConfigBackups(); this._applyDoctorAdminGate(); this.loadWebhook(); this.loadDiscord(); // Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens). document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true; document.getElementById('appSettingsUltracodeFloatingWindows').checked = settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false; document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false; document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false; document.getElementById('appSettingsShowTileGridButton').checked = settings.showTileGridButton ?? defaults.showTileGridButton ?? true; document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings); document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false; // Phone overview home screen: only meaningful under 600px, so the row is // hidden elsewhere rather than offering a toggle that changes nothing. // 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; // Auto-name sessions: synced, default OFF (opt-in; only an explicit true enables). document.getElementById('appSettingsAutoNameSessions').checked = settings.autoNameSessions === true; // Spawned tabs follow their parent: synced (the server places the tab), default OFF. document.getElementById('appSettingsSpawnedTabsFollowParent').checked = settings.spawnedTabsFollowParent === true; const lineageItem = document.getElementById('appSettingsLineageLinesItem'); if (lineageItem) lineageItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none'; document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false; const mobileOverviewItem = document.getElementById('appSettingsMobileOverviewItem'); if (mobileOverviewItem) mobileOverviewItem.style.display = MobileDetection.getDeviceType() === 'mobile' ? '' : 'none'; // 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). document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false; document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false; document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false; document.getElementById('appSettingsShowGitStatus').checked = settings.showGitStatus ?? defaults.showGitStatus ?? false; document.getElementById('appSettingsGitStatusTree').checked = settings.gitStatusTree ?? defaults.gitStatusTree ?? true; document.getElementById('appSettingsGitStatusMaxRepos').value = settings.gitStatusMaxRepos ?? defaults.gitStatusMaxRepos ?? 12; document.getElementById('appSettingsGitStatusTimeout').value = settings.gitStatusTimeoutSeconds ?? defaults.gitStatusTimeoutSeconds ?? 30; // Git fetch on an interval: synced, default OFF, interval default 300 s (DEFAULT_GIT_FETCH_INTERVAL_SECONDS). // Synced means the SERVER decides whether to fetch, so these keys stay out of the per-device display list // below and are not stripped from a save: a stripped key would never reach the scheduler. document.getElementById('appSettingsGitFetchEnabled').checked = settings.gitFetchEnabled === true; document.getElementById('appSettingsGitFetchIntervalSeconds').value = settings.gitFetchIntervalSeconds ?? 300; document.getElementById('appSettingsShowEditHistory').checked = settings.showEditHistory ?? defaults.showEditHistory ?? false; // 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'; document.getElementById('appSettingsGestureControl').checked = settings.gestureControlEnabled ?? defaults.gestureControlEnabled ?? false; 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; // Prompt-cache keep-warm: synced, default OFF, cap default 2 h (DEFAULT_KEEP_WARM_MAX_HOURS). document.getElementById('appSettingsPromptCacheKeepWarm').checked = settings.promptCacheKeepWarm === true; document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value = settings.promptCacheKeepWarmMaxHours ?? 2; document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false; this.loadTunnelStatus(); document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); // Default ON, so an absent key reads as enabled rather than as off. document.getElementById('appSettingsKeyboardHoldsRows').checked = settings.keyboardHoldsRows !== false; // 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; // Default ON, so an absent key reads as enabled rather than as off. document.getElementById('appSettingsCopyStripMargin').checked = settings.copyStripMargin !== false; document.getElementById('appSettingsTerminalFont').value = settings.terminalFontFamily || ''; this.populateTerminalFontWeight(document.getElementById('appSettingsTerminalFontWeight'), settings.terminalFontWeight); this.populateTerminalFontWeight( document.getElementById('appSettingsTerminalFontWeightBold'), settings.terminalFontWeightBold ); document.getElementById('appSettingsTerminalWheelLocal').checked = settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false; document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false; document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false; document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false; document.getElementById('appSettingsShowTabCliLogos').checked = this.tabCliLogosEnabled(settings); document.getElementById('appSettingsTabOrientation').value = settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal'; const tabRailWidth = window.CodemanTabRail?.resolveWidth({ // Same default resolution as applyTabRailWidth(): a rail that has never // been sized shows the width it is actually rendering at, which for // 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, }) ?? 256; this.syncTabRailWidthSetting?.(tabRailWidth); document.getElementById('appSettingsTabRailDetail').value = settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich'; document.getElementById('appSettingsTabRailSort').value = settings.tabRailSort ?? defaults.tabRailSort ?? 'activity'; document.getElementById('appSettingsTabArrangement').value = this.resolveTabArrangement(settings); document.getElementById('appSettingsTabStateOrder').value = this.resolveTabStateOrder(settings); document.getElementById('appSettingsShowTabDetachButton').checked = this.tabDetachButtonEnabled?.(settings, defaults) ?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false); // The tab's "+" (applyHeaderVisibilitySettings): per-device, OFF unless switched on. document.getElementById('appSettingsShowTabNewButton').checked = settings.showTabNewButton === true; // Detach Tiles (tile-grid.js): per-device, OFF unless switched on. The tile // grid is desktop-only, so the row is hidden elsewhere, like the lineage lines. document.getElementById('appSettingsTileDetach').checked = settings.tileDetachEnabled === true; const tileDetachItem = document.getElementById('appSettingsTileDetachItem'); if (tileDetachItem) tileDetachItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none'; document.getElementById('appSettingsSessionListLayout').value = settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header'; const sessionSidebarFontSize = this.resolveSessionSidebarFontSize( settings.sessionSidebarFontSize ?? defaults.sessionSidebarFontSize ); document.getElementById('appSettingsSessionSidebarFontSize').value = String(sessionSidebarFontSize); document.getElementById('appSettingsSessionSidebarFontSizeValue').textContent = `${sessionSidebarFontSize} px`; // 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'; }; // 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. document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked = settings.codexDangerouslyBypassApprovals ?? false; document.getElementById('appSettingsCodexNetworkAccess').checked = settings.codexNetworkAccess ?? false; document.getElementById('appSettingsCodexAnimations').checked = settings.codexAnimationsEnabled ?? false; document.getElementById('appSettingsCodexModel').value = settings.codexModel ?? ''; document.getElementById('appSettingsCodexReasoningEffort').value = settings.codexReasoningEffort ?? ''; this._applyCodexSettingsVisibility(); // Claude Permissions settings document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false; // Default ON (since 1.50.0), like the workspace hooks below: the server reads an // absent key as on, so the switch must too, or the next Save would write false. document.getElementById('appSettingsAgentSkill').checked = settings.agentSkillEnabled !== false; // 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; document.getElementById('appSettingsPinCliSessionName').checked = settings.pinCliSessionName === true; document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? ''; document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false; document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true; document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? ''; document.getElementById('appSettingsClaudeAdvisor').value = settings.claudeAdvisorModel ?? ''; // 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(); // 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. // 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('appSettingsNotifToastSecs').value = Math.round( (this.notificationManager?.getToastDurationMs?.() ?? DEFAULT_TOAST_DURATION_MS) / 1000 ); document.getElementById('appSettingsNotifBrowserSecs').value = Math.round( (notifPrefs.browserAutoCloseMs ?? AUTO_CLOSE_NOTIFICATION_MS) / 1000 ); 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 || {}; document.getElementById('eventStopEnabled').checked = stopPref.enabled ?? false; 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'; document.getElementById('voiceKeyterms').value = voiceCfg.keyterms || DEFAULT_VOICE_KEYTERMS; document.getElementById('voiceInsertMode').value = voiceCfg.insertMode || 'direct'; document.getElementById('voiceProvider').value = voiceCfg.provider || 'auto'; document.getElementById('appSettingsClaudeVoice').checked = settings.claudeVoiceEnabled ?? false; // Reset key visibility to hidden const keyInput = document.getElementById('voiceDeepgramKey'); keyInput.type = 'password'; document.getElementById('voiceKeyToggleBtn').textContent = 'Show'; // 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()); // Updates section — show current version, reset transient result/progress UI. this._initUpdatesSection(); // Model cards + effort segment are views over the hidden '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 ? 'Available for Fable 5.1, Fable 5, Opus 5.5, Opus and Opus 4.6.' : 'With no model pinned, this starts new sessions on Opus with a 1M window.'; } }, _buildEffortSegment() { 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 ${options(data.backups[0].id)} with ` : ''; out.innerHTML = `
${data.backups.length} backup${data.backups.length === 1 ? '' : 's'} in ${escapeHtml(data.dir)}
${picker}
`; }, /** What differs between two states of the config files: a backup id or `current` (the running config). */ async showConfigDiff(from, to) { const out = this.$('configBackupDiff'); if (!out) return; out.textContent = 'Comparing…'; const res = await this._api(`/api/config-backups/diff?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`); let body = null; try { body = res ? await res.json() : null; } catch { /* fall through */ } if (!res || !res.ok || !body || body.success === false) { out.textContent = body?.error || 'Could not compare these'; return; } const label = (id) => { if (id === 'current') return 'the current config'; const b = (this._configBackups || []).find((x) => x.id === id); return b ? new Date(b.createdAt).toLocaleString() : id; }; const d = body.data; const verb = { added: 'added', removed: 'removed', changed: 'changed' }; const files = d.files.filter((f) => f.status !== 'unchanged').map((f) => { let detail; if (f.detail === 'bytes') { detail = '
Differs (not a key/value file, so no detail).
'; } else { const rows = f.changes.map((c) => { const val = (v) => `${escapeHtml(v ?? '')}`; const text = c.redacted ? `${verb[c.kind]} (value hidden: credential)` : c.kind === 'added' ? `added ${val(c.after)}` : c.kind === 'removed' ? `removed (was ${val(c.before)})` : `${val(c.before)} → ${val(c.after)}`; return `
  • ${escapeHtml(c.path)}: ${text}
  • `; }); const more = f.truncated ? '
  • More changes not shown.
  • ' : ''; detail = f.changes.length ? `` : ''; } const whole = f.status === 'added' ? ' (new file)' : f.status === 'removed' ? ' (file gone)' : ''; return `
    ${escapeHtml(f.name)}${whole}${detail}
    `; }); const same = d.files.filter((f) => f.status === 'unchanged').map((f) => escapeHtml(f.name)); out.innerHTML = `
    ${escapeHtml(label(d.from))} → ${escapeHtml(label(d.to))}: ${ d.changed ? `${d.changed} file${d.changed === 1 ? '' : 's'} differ` : 'no differences'}
    ${files.join('')}${ same.length ? `
    Unchanged: ${same.join(', ')}
    ` : ''}`; }, async backupConfigNow() { const btn = this.$('configBackupNowBtn'); if (btn) btn.disabled = true; try { const res = await this._apiPost('/api/config-backups', {}); let body = null; try { body = res ? await res.json() : null; } catch { /* fall through */ } if (!res || !res.ok || !body || body.success === false) { this.showToast(body?.error || 'Backup failed', 'error'); } else if (body.data.status === 'empty') { this.showToast('Nothing to back up yet', 'warning'); } else { this.showToast(`Backed up ${body.data.files} file${body.data.files === 1 ? '' : 's'}`, 'success'); } } finally { if (btn) btn.disabled = false; this.loadConfigBackups(); } }, async restoreConfigBackup(id) { const names = ((this._configBackups || []).find((b) => b.id === id)?.files || []).map((f) => f.name); const secret = names.filter((n) => ['.env', 'users.json', 'push-keys.json', 'custom-model-hosts.json', 'webhook.json'].includes(n)); const list = names.length ? `\n\nFiles replaced: ${names.join(', ')}` : ''; const warn = secret.length ? `\n\nThis rolls back credentials and accounts (${secret.join(', ')}): passwords, disabled accounts and keys return to how they were then.` : ''; if (!confirm(`Restore this backup? Its files replace the current ones (a copy of the current files is made first). Some files only take effect after a restart.${list}${warn}`)) return; const res = await this._apiPost(`/api/config-backups/${encodeURIComponent(id)}/restore`, {}); let body = null; try { body = res ? await res.json() : null; } catch { /* fall through */ } const ok = res && res.ok && body && body.success !== false; const d = body?.data; // A restore that failed partway still answers with what it wrote (`restored`, `failed`, `safetySnapshot`): // those files are the backup's now and need the same refill as a full restore. if (!ok && !Array.isArray(d?.restored)) { this.showToast(body?.error || 'Restore failed', 'error'); return; } const restart = d.restartRequired?.length ? ` Restart Codeman to apply: ${d.restartRequired.join(', ')}.` : ''; // The form still holds the pre-restore values, and Save would PUT them over the restored file. if (d.restored.includes('settings.json')) { await this.loadAppSettingsFromServer(); this.openAppSettings(); } if (ok) { this.showToast(`Restored ${d.restored.length} file${d.restored.length === 1 ? '' : 's'}.${restart}`, 'success'); } else { this.showToast(`${body.error || 'Restore failed partway'}.${restart}`, 'error'); } this.loadConfigBackups(); }, /** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */ async mcpSync(apply) { const out = this.$('mcpSyncResult'); const show = (html, hint = '') => { if (out) { out.style.display = 'block'; out.innerHTML = html; out.dataset.hint = hint; } }; // Switched on in this modal but not saved yet: the routes would only answer "disabled". if (!this._mcpSyncSavedOn) { show('Apply or Save settings to turn MCP sync on first, then preview or sync.', 'save-first'); return; } if (apply && !confirm('Add missing MCP servers to every installed, enabled CLI\'s config file (and GitHub Copilot CLI\'s, when it is installed)? Env values and headers on those servers are copied too.')) return; 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) => { if (t.status === 'absent') return `
  • ${escapeHtml(t.label)}: not installed, skipped
  • `; if (t.status === 'skipped') return `
  • ${escapeHtml(t.label)}: not touched (${escapeHtml(t.error || 'config location unknown')})
  • `; if (t.status === 'unreadable') return `
  • ${escapeHtml(t.label)}: not touched, file can't be read safely (${escapeHtml(t.error || 'unreadable')})
  • `; if (t.status === 'failed') return `
  • ${escapeHtml(t.label)}: failed (${escapeHtml(t.error || 'error')}); the file may be unchanged
  • `; 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(', ')}`); const count = `${t.servers.length} server${t.servers.length === 1 ? '' : 's'}`; return `
  • ${escapeHtml(t.label)} (${count}): ${parts.join('; ')}
  • `; }); const conflicts = data.conflicts.length ? `

    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(', ')}

    ` : ''; const disabled = data.disabled?.length ? `

    Switched off in their own CLI, so not copied: ${data.disabled.map(escapeHtml).join(', ')}

    ` : ''; const unsupported = data.unsupported?.length ? `

    No MCP config support for: ${data.unsupported.map(escapeHtml).join(', ')}

    ` : ''; show(`${conflicts}${disabled}${unsupported}`); }, /** * 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. * * 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. */ _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) { this._webhookLoaded = null; 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 */ } if (!body || body.success === false) { this._webhookLoaded = null; group.style.display = 'none'; return; } 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.'; const clearBtn = document.getElementById('webhookClearBtn'); if (clearBtn) clearBtn.style.display = d.hasUrl ? '' : 'none'; // Read back from the controls, so a value the `; return `
    ${escapeHtml(c.label)} ${escapeHtml(c.shortBadge)} ${c.installed ? 'Installed' : 'Not installed'}${c.stock ? '' : ' · custom'}
    ${installBtn} ${customActions} ${toggle}
    `; }) .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(); }, // ═══════════════════════════════════════════════════════════════ // Visibility Settings & Device-Specific Defaults // ═══════════════════════════════════════════════════════════════ // Get the global Ralph tracker enabled setting isRalphTrackerEnabledByDefault() { const settings = this.loadAppSettingsFromStorage(); return settings.ralphTrackerEnabled ?? false; }, // 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. getSettingsStorageKey() { const isHandheld = MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile'; return isHandheld ? 'codeman-app-settings-mobile' : 'codeman-app-settings'; }, // Get default settings based on device type // Note: Notification prefs are handled separately by NotificationManager getDefaultSettings() { const isHandheld = MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile'; if (isHandheld) { // Mobile defaults: minimal UI for small screens return { // Header visibility - hide everything on mobile showFontControls: false, showSystemStats: false, showGpuStats: false, showCpuMhz: false, showTokenCount: false, showCost: false, // Panel visibility - hide panels on mobile (not enough space) showMonitor: false, showProjectInsights: false, showFileBrowser: false, showSubagents: false, showUltracodeAgents: false, ultracodeFloatingWindows: false, showMultiMonitorButton: false, showSplitButton: false, showTileGridButton: false, // 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. showPlanUsageLimits: false, showAttachmentsButton: false, showFileViewerButton: false, showRedrawButton: false, showSessionButton: false, showAwayDigestButton: false, showCronButton: false, // 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, // Remote auto-reconnect (COD-108) — on by default remoteAutoReconnect: true, // Input gestureControlEnabled: false, // Feature toggles - keep tracking on even on mobile subagentTrackingEnabled: true, subagentActiveTabOnly: true, // Only show subagents for active tab imageWatcherEnabled: false, ralphTrackerEnabled: false, tabTwoRows: false, showTabCliLogos: true, tabOrientation: 'horizontal', tabRailWidth: 256, tabRailDetail: 'rich', tabRailSort: 'activity', tabArrangement: 'classic', tabStateOrder: 'urgent-first', sessionListLayout: 'header', sessionSidebarFontSize: 12, cjkInputEnabled: false, terminalWheelLocalScrollback: false, // mobile scrolls via touch, not wheel webglRendererEnabled: false, // mobile always uses the DOM renderer skin: 'daylight-blue', }; } // Desktop defaults - rely on ?? operators in apply functions // This allows desktop to have different defaults without duplication // A touch-primary tablet (iPad, an Android tablet: not a handheld, so it // lands here) keeps the Tiles button opt-in, as Split is: a tile has none of // the main terminal's touch, IME and soft-keyboard handling. The PRIMARY // pointer decides, never MobileDetection.isTouchDevice(), which is true on a // touchscreen laptop too (fine primary pointer: the desktop default stays). const coarsePrimaryPointer = typeof window !== 'undefined' && window.matchMedia?.('(pointer: coarse)')?.matches === true; return coarsePrimaryPointer ? { showTileGridButton: false } : {}; }, 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, without showTileGridButton: its default // on a non-handheld follows the LIVE primary pointer (getDefaultSettings), // and this object is what a fresh device caches and the server-settings // merge then persists, which would freeze a 2-in-1's first-load posture // into a stored value. Every reader resolves the absent key through a // fresh getDefaultSettings() (?? defaults.showTileGridButton ?? true). const defaults = { ...this.getDefaultSettings() }; delete defaults.showTileGridButton; this._cachedAppSettings = defaults; 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); } }, // 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; const themeColor = getComputedStyle(document.documentElement).getPropertyValue('--bg-dark').trim(); if (themeColor) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', themeColor); try { localStorage.setItem('codeman:skin', skin); } catch (_e) { /* private mode */ } if (typeof this.applyTerminalSkin === 'function') this.applyTerminalSkin(skin); }, // 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; } // The connection tile's value word (Header Stats Style Tiles) is written // already translated into a data-i18n-skip span, so the translator above // cannot revert it; its own language compare makes this one call repaint it. this._updateConnectionIndicator?.(); }, // 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). // 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. planUsageChipEnabled(settings = null) { const s = settings ?? this.loadAppSettingsFromStorage(); return s.showPlanUsageLimits ?? this.getDefaultSettings().showPlanUsageLimits ?? true; }, // 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; }, /** * The stored tab layout, or the default. Anything but the four known values * (an absent key, a value from a newer build) reads as 'classic', the default: * the single strip as before, the owner's pick on the 1.36.0 beta. 'state' * (Discussion #426, option C), 'case' and 'ledger' are opt-in. */ resolveTabArrangement(settings) { const value = settings?.tabArrangement ?? this.getDefaultSettings().tabArrangement; return value === 'state' || value === 'case' || value === 'ledger' ? value : 'classic'; }, /** * CLI Logos on Tabs (`showTabCliLogos`, per-device, default ON on every * device). Anything but an explicit false reads as on, the same test the * pre-paint script in index.html applies, so a reload and a Save never * disagree about an odd stored value. */ tabCliLogosEnabled(settings) { return (settings?.showTabCliLogos ?? this.getDefaultSettings().showTabCliLogos) !== false; }, /** The stored state-group order: 'urgent-last' only when chosen, else 'urgent-first'. */ resolveTabStateOrder(settings) { const value = settings?.tabStateOrder ?? this.getDefaultSettings().tabStateOrder; return value === 'urgent-last' ? 'urgent-last' : 'urgent-first'; }, /** * The stored header-stats style, or the default. Anything but the three * known values (an absent key, a value from a newer build) reads as * 'compact', the default (the two-pill ring variant of Discussion #426's * option G, picked by the owner on the 1.36.0 beta over 'tiles'). */ resolveHeaderStatsStyle(settings) { const value = settings?.headerStatsStyle ?? this.getDefaultSettings().headerStatsStyle; return value === 'classic' || value === 'tiles' ? value : 'compact'; }, /** * Apply a header-stats style: the `data-header-stats` attribute every rule in * the "Header stats styles" block of styles.css keys on, plus the two DOM * moves the clustered styles need. * * The template keeps the classic order, where the connection indicator sits * before the font controls and the plan-usage chip near the end of the header. * Compact and Tiles draw them as ONE cluster (WS · CPU · MEM, then the plan * windows), so the indicator moves into #headerSystemStats as its first child * and the chip moves right after it. Comment anchors left at the template * positions are what 'classic' moves them back to, so switching back restores * the header exactly. * * ⚠️ The indicator only joins the pill while System Stats is shown: the pill * is hidden with `display: none`, and the WS readout must not disappear with * it. Both elements keep their ids, so every writer (setConnectionStatus, * updatePlanUsageChip) finds them wherever they sit. * * @param {{style: 'classic'|'compact'|'tiles', showSystemStats: boolean}} opts */ applyHeaderStatsStyle({ style, showSystemStats }) { document.documentElement.dataset.headerStats = style; const stats = document.getElementById('headerSystemStats'); const conn = document.getElementById('connectionIndicator'); const plan = document.getElementById('planUsageChip'); if (!stats || !conn || !plan) return; if (!this._headerStatsAnchors) { const connAnchor = document.createComment(' connection indicator (classic position) '); const planAnchor = document.createComment(' plan usage chip (classic position) '); conn.before(connAnchor); plan.before(planAnchor); this._headerStatsAnchors = { conn: connAnchor, plan: planAnchor }; } const anchors = this._headerStatsAnchors; const clustered = style !== 'classic'; if (clustered && showSystemStats) { if (stats.firstElementChild !== conn) stats.prepend(conn); } else if (anchors.conn.nextSibling !== conn) { anchors.conn.after(conn); } if (clustered) { if (stats.nextElementSibling !== plan) stats.after(plan); } else if (anchors.plan.nextSibling !== plan) { anchors.plan.after(plan); } }, applyHeaderVisibilitySettings() { const settings = this.loadAppSettingsFromStorage(); const defaults = this.getDefaultSettings(); // Tab pop-out (open-in-new-window) button: opt-in (App Settings → Tab Bar, // default OFF, per-device). Mirrored as a class on : 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). // Under a host that opens windows (see hasHostWindows) popping out is the // way to get two panes side by side, so the button defaults on there. const showTabDetach = this.tabDetachButtonEnabled?.(settings, defaults) ?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false); document.documentElement.classList.toggle('tabs-show-detach', showTabDetach); // The tab's "+" (new session beside it): per-device and opt-in everywhere, // like the pop-out button. On a phone it also widens the active tab (mobile.css). document.documentElement.classList.toggle('tabs-show-new', settings.showTabNewButton === true); const compactHeader = MobileDetection.getDeviceType() !== 'desktop'; const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false); const showSystemStats = compactHeader ? false : (settings.showSystemStats ?? defaults.showSystemStats ?? true); // 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); 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'; } // After the System Stats visibility above: whether WS joins the stats pill // depends on the pill being shown. this.applyHeaderStatsStyle({ style: compactHeader || this.isSoloWindow ? 'classic' : this.resolveHeaderStatsStyle(settings), showSystemStats, }); // Hide lifecycle log button when setting is disabled // 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; const lifecycleBtn = document.querySelector('.btn-lifecycle-log'); if (lifecycleBtn) { lifecycleBtn.style.display = showLifecycleLog ? '' : 'none'; } // Hide the response viewer (eye) button when setting is disabled. // Marker class, not inline style — the base rule is display:inline-flex !important. const showResponseViewer = settings.showResponseViewer ?? defaults.showResponseViewer ?? false; const responseViewerBtn = document.querySelector('.btn-response-viewer-header'); if (responseViewerBtn) { responseViewerBtn.classList.toggle('btn-response-viewer-header--hidden', !showResponseViewer); } // 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); } // File Viewer header button — opt-in, default OFF. Marker class (base is // display:inline-flex !important); clicking it toggles the file browser panel. // 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; const fileViewerBtn = document.querySelector('.btn-file-viewer'); if (fileViewerBtn) { fileViewerBtn.classList.toggle('btn-file-viewer--hidden', !showFileViewerButton); } // Multi-monitor button — hidden by default (App Settings → Display → "Header // Displays"). The server renders the correct initial state on every reload; // 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. const showMultiMonitorButton = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false; const multiMonitorBtn = document.querySelector('.btn-multimonitor'); if (multiMonitorBtn) { multiMonitorBtn.classList.toggle('btn-multimonitor--hidden', !showMultiMonitorButton); } // 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). const showSplitButton = settings.showSplitButton ?? defaults.showSplitButton ?? false; this._applySplitButtonVisibility?.(showSplitButton); // Tiles button: same gate and backstop as Split (tile-grid.js). const showTileGridButton = settings.showTileGridButton ?? defaults.showTileGridButton ?? true; this._applyTileGridButtonVisibility?.(showTileGridButton); // Detach Tiles changes what a tile's header does (and says): repaint the handles. this._renderTileChrome?.(); // 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); } // 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 // mobile.css regardless (their surface is the keyboard-accessory 🧠 key, // re-synced right below). const readMyMindBtn = document.querySelector('.btn-readmymind'); if (readMyMindBtn) { readMyMindBtn.classList.toggle('btn-readmymind--hidden', settings.readMyMindEnabled !== true); } // 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?.(); // 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); const planUsageChip = document.getElementById('planUsageChip'); if (planUsageChip) { planUsageChip.classList.toggle('header-plan-usage--hidden', !showPlanUsageLimits); } const showRedrawButton = settings.showRedrawButton ?? defaults.showRedrawButton ?? false; const redrawBtn = document.querySelector('.btn-redraw-terminal'); if (redrawBtn) { redrawBtn.classList.toggle('btn-redraw-terminal--hidden', !showRedrawButton); } // 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); } // Cron button (footer toolbar) — opt-in, hidden by default. Same marker pattern. const showCronButton = settings.showCronButton ?? defaults.showCronButton ?? false; const cronBtn = document.querySelector('.btn-cron'); if (cronBtn) { cronBtn.classList.toggle('btn-cron--hidden', !showCronButton); } // Bottom-bar Git indicator (git-status-ui.js): opt-in, per-device. Starts or stops its poll to // match the setting, so a live toggle needs no reload. this.applyGitStatusVisibility?.(); // Header Edit history button (edit-history-ui.js): opt-in, per-device, marker-class hidden. this.applyEditHistoryVisibility?.(); // Notification bell is retired (notifications live in Settings → Notifications // + the drawer); keep it hidden regardless of the notification-enabled state. const notifBtn = document.querySelector('.btn-notifications'); if (notifBtn) { notifBtn.style.display = 'none'; } // Close the drawer if notifications got disabled while it's open const notifEnabled = this.notificationManager?.preferences?.enabled ?? true; if (!notifEnabled) { const drawer = document.getElementById('notifDrawer'); if (drawer) drawer.classList.remove('open'); } }, 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'; // The search box lives in the rail: a search left applied after the list // moves out would hide tabs with no box to clear it from. if (orientation !== 'vertical' && this._tabRailSearch) this._resetTabRailSearch?.(); const root = document.documentElement; const previous = root.getAttribute('data-tab-orientation') || 'horizontal'; root.setAttribute('data-tab-orientation', orientation); // 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; // 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; // The tab layout rides on a fourth attribute, for the same reason: it is // applied by the render paths (inline `order` plus headings, or cluster // boxes), so a flip has to re-render, and the gates in app.js // (`isTabTriage()`, `isTabClusters()`, `isTabLedger()`) read one attribute // per pass instead of re-parsing localStorage. const previousArrangement = root.dataset.tabArrangement || 'state'; const arrangement = this.resolveTabArrangement(settings); root.dataset.tabArrangement = arrangement; // Which end the state groups start from; read by _tabTriageLayout(). const previousStateOrder = root.dataset.tabStateOrder || 'urgent-first'; const stateOrder = this.resolveTabStateOrder(settings); root.dataset.tabStateOrder = stateOrder; // CLI Logos on Tabs. Unlike the attributes above this one is pure CSS // (styles.css hides `.tab-harness` and `.home-sessions-harness` under // html[data-tab-logos='off']), so a flip re-renders nothing and stays out // of `changed` below: the logo spans are always in the markup. It still // resizes every agent tab, which the tail of this function settles. const previousLogos = root.dataset.tabLogos; const logos = this.tabCliLogosEnabled(settings) ? 'on' : 'off'; root.dataset.tabLogos = logos; 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 }); 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. const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousArrangement !== arrangement || previousStateOrder !== stateOrder; if (orientationChanged) { this.updateTabOverflowMode?.(); if (!settleRailWidth) this.syncTerminalGeometry?.(); } // 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) { // 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?.(); this._updateConnectionLinesImmediate?.(); this._refreshHomeSessionsIfVisible?.(); } else if (previousLogos !== logos) { // A logo flip narrows or widens every agent tab with no render behind // it, so re-take what a render would have: the strip's one-row wrap // decision and the lines anchored to tab rects (lineage, subagent // connectors). A header that gains or loses a row resizes the terminal // container, whose ResizeObserver (terminal-ui.js) owns the PTY geometry. this.updateTabOverflowMode?.(); this._updateConnectionLinesImmediate?.(); } // 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?.(); }, applyTabWrapSettings() { const settings = this.loadAppSettingsFromStorage(); const defaults = this.getDefaultSettings(); const deviceType = MobileDetection.getDeviceType(); // 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. // 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. const sidebar = this.isSessionSidebarActive?.() === true; // Two-row tabs disabled on mobile/tablet — not enough screen space const twoRows = !sidebar && deviceType === 'desktop' ? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false) : false; // 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; const prevTallTabs = this._tallTabsEnabled; this._tallTabsEnabled = showFolder; const tabsEl = document.getElementById('sessionTabs'); if (tabsEl) { tabsEl.classList.toggle('tabs-two-rows', twoRows); tabsEl.classList.toggle('tabs-show-folder', showFolder); } // Re-render tabs if folder visibility changed (folder spans are generated in JS) if (prevTallTabs !== undefined && prevTallTabs !== showFolder) { this._fullRenderSessionTabs(); } }, applyMonitorVisibility() { const settings = this.loadAppSettingsFromStorage(); const defaults = this.getDefaultSettings(); const showMonitor = settings.showMonitor ?? defaults.showMonitor ?? false; 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'); } } // 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'); } } // 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(); } // 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; } } } else if (fileBrowserPanel.classList.contains('visible')) { this._resetFileBrowserForHide?.(); 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(); } }, /** * The per-device DISPLAY keys. They are device-specific (mobile vs desktop) and must not be * synced from the server, to avoid overriding mobile defaults: loadAppSettingsFromServer() * seeds one from the server only when this device has no value for it. A non-admin's save in * multi-user mode reads the same split (_keepServerSyncedSettings()), so the list lives here, * once, and is there even when GET /api/settings failed at load. * NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled) * are NOT display keys: they control server-side behavior and must sync from server. */ _displaySettingKeySet() { const displayKeys = new Set([ 'showFontControls', 'showSystemStats', 'showGpuStats', 'showCpuMhz', 'headerStatsStyle', 'showTokenCount', 'showCost', 'showLifecycleLog', 'showResponseViewer', 'showRedrawButton', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', 'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'keyboardHoldsRows', 'cjkInputEnabled', 'extendedKeyboardBar', 'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled', 'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold', 'language', 'terminalWheelLocalScrollback', 'autoCopySelection', 'copyStripMargin', 'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds', 'showEditHistory', 'showTabDetachButton', 'showTabNewButton', 'mobileOverviewEnabled', 'sessionLineageLines', 'showSplitButton', 'showTileGridButton', 'tileDetachEnabled', ]); return displayKeys; }, async loadAppSettingsFromServer(settingsPromise = null) { // 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 ( (MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile') && !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 */ } try { const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.success === true ? env.data : env); if (settings) { // Extract notification prefs before merging app settings const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings; // Display settings are device-specific (mobile vs desktop) and must not be synced // from the server: see _displaySettingKeySet(). const displayKeys = this._displaySettingKeySet(); // The plan-usage chip is a PER-DEVICE display setting (desktop default ON, // 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. delete appSettings.showPlanUsageLimits; // 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; } } // The agent skill is a synced key that is ON when absent (since 1.50.0). A server // without the key means that default, so this device's own copy goes rather than // standing in for it: up to 1.49.0 the switch loaded an absent key as off, so a // device whose save never reached the server still holds `false`, and its next // Save would write it back. The server's one-time boot migration stores `true` for // installs that had saved `false` (agent-skill-default-migration.ts). if (!('agentSkillEnabled' in appSettings)) delete merged.agentSkillEnabled; 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) { this.notificationManager.preferences = this.notificationManager.normalizePreferences(notificationPreferences); 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 if (!this._settingsReadOnly()) { // Migration: push existing localStorage presets to server. Not for a non-admin in // multi-user mode: the presets are a server-wide setting there (admin-only PUT). 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 }) .then((res) => this._noteSettingsWriteRefusal(res)) .catch(() => {}); } } } // Sync run mode from server if (runMode) { this.runMode = runMode; try { localStorage.setItem('codeman_runMode', runMode); } catch {} this._applyRunMode(); } // Multi-user non-admin: their own run mode and presets, kept on this device, win over // the server's just applied (no-op otherwise; `codeman:me` below covers a late role). this._applyOwnSettings(); 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) { const env = await res.json(); states = env?.success === true ? env.data : env; // 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) { const env = await res.json(); mapData = env?.success === true ? env.data : env; // 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; } }, // ─── Shortcut Settings (App Settings → Shortcuts tab) ──────────────────────── // Renders the list of shortcuts with capture buttons for key rebinding, // and persists overrides under settings.shortcutOverrides (saved through // saveAppSettingsToStorage so the device key + settings cache stay coherent). renderShortcutSettingsList() { const list = document.getElementById('appSettingsShortcutsList'); if (!list) return; 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 ? ` ` : ''; return `
    ${controls}
    `; }) .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); }); }, 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; // 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); }, onShortcutCaptureKeydown(e, shortcutId) { e.preventDefault(); e.stopPropagation(); this._capturingShortcutId = null; if (e.key === 'Escape') { this.renderShortcutSettingsList(); return; } // 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; } // Refuse a chord something else already owns: the registry dispatches the first match, // so a duplicate would silently take over (or lose to) the other action. const taken = this.findShortcutConflict(e, shortcutId); if (taken) { this.renderShortcutSettingsList(); this.showToast?.(`${this.describeShortcutEvent(e)} is already used by ${taken}`, 'error'); return; } 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'); const settings = this.loadAppSettingsFromStorage(); const shortcutOverrides = { ...(settings.shortcutOverrides || {}) }; shortcutOverrides[shortcutId] = { ...(shortcutOverrides[shortcutId] || {}), bindings: [{ modifiers, key: e.key, code: e.code }], }; settings.shortcutOverrides = shortcutOverrides; this.saveAppSettingsToStorage(settings); this.renderShortcutSettingsList(); }, /** Label of the action that already owns the chord in `e`, or '' when it is free. */ findShortcutConflict(e, shortcutId) { // Chords the keydown handlers claim before (or outside) the registry: they are fixed, so a // rebind onto one of them would be dead or would hijack it. Alt+1-9 switch tabs by index // (the document handler does not exclude Cmd); Ctrl/Shift+Enter insert a newline and // Ctrl/Cmd+V pastes, and the user-bound gate sits above those branches in the xterm handler. if (e.altKey && !e.ctrlKey && !e.shiftKey && /^Digit[1-9]$/.test(e.code || '')) { return 'Switch to Tab N'; } // (Alt+Shift+Enter is the tile grid's Zoom chord, a registry entry, so Alt stays free.) if (e.key === 'Enter' && !e.altKey && (e.ctrlKey || e.shiftKey)) return 'Insert Newline'; if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && String(e.key).toLowerCase() === 'v') return 'Paste'; if (typeof this.getShortcutRegistry !== 'function' || typeof this.matchesShortcutEvent !== 'function') return ''; const other = this.getShortcutRegistry().find( (shortcut) => shortcut.id !== shortcutId && !shortcut.disabled && Array.isArray(shortcut.bindings) && this.matchesShortcutEvent(e, shortcut) ); return other ? other.label : ''; }, /** * Label of the action that already owns a chord in `shortcut`'s bindings, or ''. Used when a * binding comes back without a capture (re-enable, Reset), where the chord may have been * given to another action in the meantime. */ findShortcutBindingConflict(shortcut) { for (const binding of shortcut?.bindings || []) { const mods = binding.modifiers || []; const taken = this.findShortcutConflict( { ctrlKey: mods.includes('ctrl'), metaKey: mods.includes('meta'), shiftKey: mods.includes('shift'), altKey: mods.includes('alt'), key: binding.key || '', code: binding.code || '', }, shortcut.id ); if (taken) return taken; } return ''; }, describeShortcutEvent(e) { const parts = []; if (e.ctrlKey) parts.push('Ctrl'); if (e.metaKey) parts.push('Meta'); if (e.shiftKey) parts.push('Shift'); if (e.altKey) parts.push('Alt'); parts.push(e.key && e.key.length === 1 ? e.key.toUpperCase() : e.key || e.code); return parts.join('+'); }, resetShortcutOverride(shortcutId) { // Reset puts the default chord back (and re-enables the row): refuse when another action // has taken that chord since, instead of leaving two actions on one key. const fallback = typeof DEFAULT_SHORTCUTS !== 'undefined' ? DEFAULT_SHORTCUTS.find((d) => d.id === shortcutId) : null; const taken = fallback ? this.findShortcutBindingConflict(fallback) : ''; if (taken) { this.renderShortcutSettingsList(); this.showToast?.(`The default chord is already used by ${taken}`, 'error'); return; } const settings = this.loadAppSettingsFromStorage(); const shortcutOverrides = { ...(settings.shortcutOverrides || {}) }; delete shortcutOverrides[shortcutId]; settings.shortcutOverrides = shortcutOverrides; this.saveAppSettingsToStorage(settings); this.renderShortcutSettingsList(); }, toggleShortcutEnabled(shortcutId, enabled) { if (enabled) { // Re-enabling brings the row's chord back into play: leave it disabled when another action // has been bound to that chord while it was off. const shortcut = this.getShortcutRegistry?.().find((s) => s.id === shortcutId); const taken = this.findShortcutBindingConflict(shortcut); if (taken) { this.renderShortcutSettingsList(); this.showToast?.(`Its chord is already used by ${taken}`, 'error'); return; } } const settings = this.loadAppSettingsFromStorage(); const shortcutOverrides = { ...(settings.shortcutOverrides || {}) }; shortcutOverrides[shortcutId] = { ...(shortcutOverrides[shortcutId] || {}), disabled: !enabled }; settings.shortcutOverrides = shortcutOverrides; this.saveAppSettingsToStorage(settings); this.renderShortcutSettingsList(); }, 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; }, }); // 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', () => { // A non-admin's own run mode and respawn presets (multi-user mode) win over the server's, // which the settings load may already have applied before the role was known. window.app?._applyOwnSettings?.(); window.app?._applyCustomModelAdminGate?.(); window.app?._applyCliManagementAdminGate?.(); window.app?._applyMcpSyncAdminGate?.(); window.app?._applyDoctorAdminGate?.(); });