const messagesEl = document.getElementById('messages'); const inputEl = document.getElementById('input'); const sendBtn = document.getElementById('send'); const sessionEl = document.getElementById('session-id'); const headerEmoji = document.querySelector('.header-emoji'); const backendToggle = document.getElementById('backend-toggle'); const sessionsBtn = document.getElementById('sessions-btn'); const sessionsPanel = document.getElementById('sessions-panel'); const enterToggle = document.getElementById('enter-toggle'); const stopBtn = document.getElementById('stop'); const mode_select_btn_el = document.getElementById('mode-select-btn'); const mode_dropdown_el = document.getElementById('mode-dropdown'); const mode_icon_el = document.getElementById('mode-icon'); const mode_label_el = document.getElementById('mode-label'); const note_vis_btn_el = document.getElementById('note-vis-btn'); const tools_toggle_el = document.getElementById('tools-toggle'); const settings_btn_el = document.getElementById('settings-btn'); const settings_dd_el = document.getElementById('settings-dropdown'); const sessionsBackdrop = document.getElementById('sessions-backdrop'); // ── Close all panels/dropdowns (mutual exclusion) ───────────── function closeAllPanels() { if (mode_dropdown_el) mode_dropdown_el.classList.remove('open'); if (settings_dd_el) settings_dd_el.classList.remove('open'); if (sessionsPanel) { sessionsPanel.classList.remove('open'); sessionsBackdrop.classList.remove('open'); } const pd = document.getElementById('persona-dropdown'); if (pd) pd.classList.remove('open'); const cp = document.getElementById('ctx-panel'); if (cp) cp.classList.remove('open'); } // ── Toasts ──────────────────────────────────────────────────── const toastContainer = document.getElementById('toast-container'); function showToast(message, type = 'info', duration = 2500) { const el = document.createElement('div'); el.className = 'toast' + (type !== 'info' ? ' ' + type : ''); el.textContent = message; toastContainer.appendChild(el); requestAnimationFrame(() => { requestAnimationFrame(() => el.classList.add('show')); }); setTimeout(() => { el.classList.remove('show'); el.addEventListener('transitionend', () => el.remove(), { once: true }); }, duration); } // ── Syntax highlighting ─────────────────────────────────────── function highlight_code(container) { if (typeof hljs === 'undefined') return; container.querySelectorAll('pre code').forEach(el => hljs.highlightElement(el)); } // ── Utility helpers ─────────────────────────────────────────── function _esc(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } // ── Lucide icon helpers ─────────────────────────────────────── function icon_html(name, size = 16) { return ``; } function render_icons() { if (window.lucide) lucide.createIcons(); } // User/persona injected by the server at /{user}/{persona} const CORTEX_USER = (window.CORTEX_CONFIG || {}).user || 'scott'; const CORTEX_PERSONA = (window.CORTEX_CONFIG || {}).persona || 'inara'; const CORTEX_EMOJI = (window.CORTEX_CONFIG || {}).emoji || '✨'; const personaLabel = CORTEX_PERSONA.charAt(0).toUpperCase() + CORTEX_PERSONA.slice(1); const _fileParams = `user=${encodeURIComponent(CORTEX_USER)}&persona=${encodeURIComponent(CORTEX_PERSONA)}`; if (headerEmoji) headerEmoji.textContent = CORTEX_EMOJI; // Set favicon to persona emoji { const favicon = document.querySelector("link[rel='icon']"); if (favicon && CORTEX_EMOJI) { const svg = ``; favicon.href = `data:image/svg+xml,${encodeURIComponent(svg)}`; } } // Wire help link to preserve current persona on return const helpLink = document.getElementById('help-link'); if (helpLink) helpLink.href = `/help?persona=${encodeURIComponent(CORTEX_PERSONA)}`; let sessionId = null; let primaryBackend = null; // null = auto / role-based routing let activeController = null; let currentHistory = []; // mirrors backend session [{role, content}, ...] let talkThinkingDiv = null; // pending "thinking…" bubble for live Talk updates // ── Session persistence ─────────────────────────────────────── // Survives page navigation (help, settings, etc.) within the same browser. // Expires after SESSION_TTL_MS of inactivity. const SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes const _sid_key = `cx_sid_${CORTEX_USER}_${CORTEX_PERSONA}`; const _sid_ts_key = `cx_sid_ts_${CORTEX_USER}_${CORTEX_PERSONA}`; function persist_session() { if (!sessionId) return; localStorage.setItem(_sid_key, sessionId); localStorage.setItem(_sid_ts_key, String(Date.now())); } function clear_stored_session() { localStorage.removeItem(_sid_key); localStorage.removeItem(_sid_ts_key); } function get_stored_session() { const id = localStorage.getItem(_sid_key); const ts = parseInt(localStorage.getItem(_sid_ts_key) || '0', 10); if (!id || Date.now() - ts > SESSION_TTL_MS) return null; return id; } // ── Enter toggle ───────────────────────────────────────────── // Default: Ctrl+Enter sends. Stored in localStorage. let ctrlEnterMode = localStorage.getItem('ctrlEnterSend') !== 'false'; function updateEnterToggleUI() { enterToggle.textContent = ctrlEnterMode ? '⌃↵' : '↵'; enterToggle.title = ctrlEnterMode ? 'Ctrl+Enter sends — click for Enter mode' : 'Enter sends — click for Ctrl+Enter mode'; updateInputPlaceholder(); } enterToggle.addEventListener('click', () => { ctrlEnterMode = !ctrlEnterMode; localStorage.setItem('ctrlEnterSend', ctrlEnterMode); updateEnterToggleUI(); }); // ── Textarea height ────────────────────────────────────────── const HEIGHT_SIZES = [120, 240, 480]; const HEIGHT_LABELS = ['S', 'M', 'L']; const HEIGHT_TITLES = [ 'Input size: Compact — click to cycle', 'Input size: Medium — click to cycle', 'Input size: Large — click to cycle', ]; let maxHeight = parseInt(localStorage.getItem('maxHeight') || '120'); const heightCycleBtn = document.getElementById('height-cycle-btn'); function syncHeight() { inputEl.style.transition = ''; inputEl.style.height = 'auto'; inputEl.style.maxHeight = maxHeight + 'px'; const sh = inputEl.scrollHeight; // Minimum height is 1/3 of maxHeight so each setting is visually distinct const minH = Math.round(maxHeight / 3); inputEl.style.height = Math.max(Math.min(sh, maxHeight), minH) + 'px'; } function updateHeightUI() { if (!heightCycleBtn) return; const idx = HEIGHT_SIZES.indexOf(maxHeight); const i = idx >= 0 ? idx : 0; heightCycleBtn.textContent = HEIGHT_LABELS[i]; heightCycleBtn.title = HEIGHT_TITLES[i]; } if (heightCycleBtn) { heightCycleBtn.addEventListener('click', () => { const idx = HEIGHT_SIZES.indexOf(maxHeight); const nextIdx = (idx + 1) % HEIGHT_SIZES.length; maxHeight = HEIGHT_SIZES[nextIdx]; localStorage.setItem('maxHeight', maxHeight); updateHeightUI(); syncHeight(); }); } // ── Input mode — dropdown select with MRU ordering ────────── const MODES = { chat: { icon: 'message-circle', label: 'Chat' }, note: { icon: 'pencil', label: 'Note' }, otr: { icon: 'lock', label: 'OTR' }, }; const send_defs = { chat: { icon: 'arrow-up', label: 'Send' }, note: { icon: 'pencil', label: 'Note' }, otr: { icon: 'arrow-up', label: 'Send' }, }; let current_mode = localStorage.getItem('current_mode') || 'chat'; if (!(current_mode in MODES)) current_mode = 'chat'; // migrate stored 'agent' let note_public = false; // MRU list — most recent first; used to sort dropdown options let mode_mru = JSON.parse(localStorage.getItem('mode_mru') || '["chat","note","otr"]'); mode_mru = mode_mru.filter(m => m in MODES); // strip stale 'agent' entries function push_mru(mode) { mode_mru = [mode, ...mode_mru.filter(m => m !== mode)]; localStorage.setItem('mode_mru', JSON.stringify(mode_mru)); } function set_mode(mode) { push_mru(mode); current_mode = mode; localStorage.setItem('current_mode', current_mode); close_mode_dropdown(); update_mode_ui(); inputEl.focus(); } function open_mode_dropdown() { closeAllPanels(); // Build options in MRU order (least recent at top, most recent at bottom) // — bottom is visually closest to the button since dropdown opens upward const ordered = [...mode_mru].reverse(); mode_dropdown_el.innerHTML = ''; ordered.forEach(mode => { const m = MODES[mode]; const btn = document.createElement('button'); btn.className = 'mode-option' + (mode === current_mode ? ' current' : ''); btn.innerHTML = `${m.label}` + (mode === current_mode ? '✓' : ''); btn.addEventListener('click', () => set_mode(mode)); mode_dropdown_el.appendChild(btn); }); mode_dropdown_el.classList.add('open'); render_icons(); } function close_mode_dropdown() { mode_dropdown_el.classList.remove('open'); } mode_select_btn_el.addEventListener('click', (e) => { e.stopPropagation(); if (mode_dropdown_el.classList.contains('open')) close_mode_dropdown(); else open_mode_dropdown(); }); document.addEventListener('click', (e) => { if (!mode_dropdown_el.contains(e.target) && e.target !== mode_select_btn_el) { close_mode_dropdown(); } }); function update_mode_ui() { const m = MODES[current_mode] || MODES.chat; const sd = send_defs[current_mode] || send_defs.chat; // Update trigger button mode_icon_el.innerHTML = icon_html(m.icon, 15); mode_label_el.textContent = m.label; mode_select_btn_el.className = current_mode === 'chat' ? '' : `mode-${current_mode}`; // Note visibility sub-button const is_note = current_mode === 'note'; note_vis_btn_el.style.display = is_note ? '' : 'none'; note_vis_btn_el.textContent = note_public ? 'pub' : 'prv'; note_vis_btn_el.classList.toggle('pub', note_public); // Textarea mode classes inputEl.classList.toggle('mode-note', current_mode === 'note'); inputEl.classList.toggle('public', current_mode === 'note' && note_public); inputEl.classList.toggle('mode-otr', current_mode === 'otr'); // Send button label + icon (tools active → "Run", otherwise per-mode) const effectiveSd = toolsEnabled && current_mode !== 'note' ? { icon: 'zap', label: 'Run' } : sd; sendBtn.innerHTML = icon_html(effectiveSd.icon) + ' ' + effectiveSd.label; render_icons(); updateInputPlaceholder(); } function updateInputPlaceholder() { if (current_mode === 'note') { inputEl.placeholder = note_public ? 'Public note — LLM sees this next turn…' : 'Private note — only you see this…'; } else if (current_mode === 'otr') { inputEl.placeholder = toolsEnabled ? `Task for ${personaLabel}… ⚡ tools + off the record` : 'Off the record — not logged or distilled…'; } else if (toolsEnabled) { inputEl.placeholder = ctrlEnterMode ? `Task for ${personaLabel}… ⚡ tools (Ctrl+Enter to run)` : `Task for ${personaLabel}… ⚡ tools`; } else { inputEl.placeholder = ctrlEnterMode ? `Message ${personaLabel}… (Ctrl+Enter to send)` : `Message ${personaLabel}…`; } } // Note private/public sub-toggle note_vis_btn_el.addEventListener('click', (e) => { e.stopPropagation(); note_public = !note_public; update_mode_ui(); }); // ── Tools toggle ───────────────────────────────────────────── // When on: submit goes to POST /orchestrate (Gemini tool loop → active role responds). // When off: submit goes to POST /chat (direct to active role, no tools). let toolsEnabled = localStorage.getItem('tools-enabled') === 'true'; function updateToolsToggleUI() { tools_toggle_el.classList.toggle('local-on', toolsEnabled); tools_toggle_el.title = toolsEnabled ? '⚡ Tools enabled — click to disable' : 'Tools disabled — click to enable'; update_mode_ui(); } tools_toggle_el.addEventListener('click', (e) => { e.stopPropagation(); toolsEnabled = !toolsEnabled; localStorage.setItem('tools-enabled', toolsEnabled); updateToolsToggleUI(); }); // ── Settings dropdown ───────────────────────────────────────── settings_btn_el.addEventListener('click', (e) => { e.stopPropagation(); const isOpen = settings_dd_el.classList.contains('open'); closeAllPanels(); if (!isOpen) settings_dd_el.classList.add('open'); }); document.addEventListener('click', (e) => { if (!settings_dd_el.contains(e.target) && e.target !== settings_btn_el) { settings_dd_el.classList.remove('open'); } }); // ── Persona name + switcher ────────────────────────────────── const personaNameEl = document.getElementById('persona-name'); const personaDropEl = document.getElementById('persona-dropdown'); const personaSwitcher = document.getElementById('persona-switcher'); if (personaNameEl && CORTEX_PERSONA) { personaNameEl.textContent = CORTEX_PERSONA.charAt(0).toUpperCase() + CORTEX_PERSONA.slice(1); } // Load persona list and build dropdown async function loadPersonaSwitcher() { try { const res = await fetch('/api/personas'); if (!res.ok) return; const data = await res.json(); const personas = data.personas || []; if (personas.length === 0) return; personaDropEl.innerHTML = ''; personas.forEach(p => { const a = document.createElement('a'); a.href = `/${CORTEX_USER}/${p}`; a.textContent = p.charAt(0).toUpperCase() + p.slice(1); if (p === CORTEX_PERSONA) a.classList.add('active'); personaDropEl.appendChild(a); }); const divider = document.createElement('div'); divider.className = 'pd-divider'; personaDropEl.appendChild(divider); const addLink = document.createElement('a'); addLink.href = '/setup/persona'; addLink.className = 'pd-add'; addLink.textContent = '+ New persona'; personaDropEl.appendChild(addLink); } catch (_) {} } loadPersonaSwitcher(); // Toggle dropdown on click if (personaSwitcher) { personaSwitcher.addEventListener('click', (e) => { if (personaDropEl.children.length === 0) return; const isOpen = personaDropEl.classList.contains('open'); closeAllPanels(); if (!isOpen) personaDropEl.classList.add('open'); e.stopPropagation(); }); document.addEventListener('click', () => personaDropEl.classList.remove('open')); } // ── Role toggle ────────────────────────────────────────────── // Cycles through roles that have a primary model assigned (excluding orchestrator). // Sends chat_role ("chat"|"coder"|"research"|...) in chat requests. // Falls back to "chat" when no roles are configured in the registry. const TYPE_CLASS = { claude_cli: '', gemini_api: 'mem-on', gemini_cli: 'mem-on', local_openai: 'local-on' }; const backendModelHint = document.getElementById('backend-model-hint'); let availableRoles = []; // [{role, label, model_label, type}] from /backend let roleIdx = 0; function activeRole() { return availableRoles.length > 0 ? availableRoles[roleIdx] : null; } function setRoleToggleUI(entry) { if (!entry) { backendToggle.textContent = 'chat'; backendToggle.className = 'ctx-btn'; } else { backendToggle.textContent = entry.label; backendToggle.className = 'ctx-btn ' + (TYPE_CLASS[entry.type] || ''); } if (backendModelHint) { const hint = entry?.model_label || ''; backendModelHint.textContent = hint; backendModelHint.style.display = hint ? '' : 'none'; } } fetch('/backend').then(r => r.json()).then(d => { availableRoles = d.available_roles || []; roleIdx = 0; setRoleToggleUI(availableRoles[0] || null); }); backendToggle.addEventListener('click', () => { if (availableRoles.length <= 1) return; roleIdx = (roleIdx + 1) % availableRoles.length; const entry = availableRoles[roleIdx]; setRoleToggleUI(entry); addMessage('system', `Role: ${entry.label} · ${entry.model_label}`); }); // ── Sessions panel ─────────────────────────────────────────── sessionsBtn.addEventListener('click', async (e) => { e.stopPropagation(); if (sessionsPanel.classList.contains('open')) { sessionsPanel.classList.remove('open'); sessionsBackdrop.classList.remove('open'); return; } closeAllPanels(); const res = await fetch(`/sessions?${_fileParams}`); const data = await res.json(); renderPanel(data.sessions); sessionsPanel.classList.add('open'); sessionsBackdrop.classList.add('open'); }); sessionsBackdrop.addEventListener('click', () => { sessionsPanel.classList.remove('open'); sessionsBackdrop.classList.remove('open'); }); document.addEventListener('click', (e) => { if (!sessionsPanel.contains(e.target) && e.target !== sessionsBtn) { sessionsPanel.classList.remove('open'); sessionsBackdrop.classList.remove('open'); } }); // session_id → friendly name (populated on each panel render) const sessionNames = new Map(); function renderPanel(sessions) { sessionsPanel.innerHTML = ''; sessionNames.clear(); const newItem = makeItem('new', '+ New session', ''); newItem.addEventListener('click', () => { sessionId = null; clear_stored_session(); currentHistory = []; messagesEl.innerHTML = ''; sessionEl.textContent = ''; addMessage('system', 'New session'); sessionsPanel.classList.remove('open'); sessionsBackdrop.classList.remove('open'); inputEl.focus(); }); sessionsPanel.appendChild(newItem); if (!sessions.length) { const empty = makeItem('', 'No sessions yet', ''); empty.style.cursor = 'default'; empty.style.color = 'var(--muted)'; sessionsPanel.appendChild(empty); return; } for (const s of sessions) { const displayName = s.name || s.session_id; sessionNames.set(s.session_id, displayName); const item = makeItem( s.session_id === sessionId ? 'active' : '', displayName, `${s.message_count} msgs · ${timeAgo(s.updated)}` ); item.addEventListener('click', () => resumeSession(s.session_id)); // Rename button (✎) const renameBtn = document.createElement('button'); renameBtn.className = 'session-rename-btn'; renameBtn.textContent = '✎'; renameBtn.title = 'Rename session'; renameBtn.addEventListener('click', async (e) => { e.stopPropagation(); const labelEl = item.querySelector('.session-id'); const current = s.name || ''; const input = document.createElement('input'); input.className = 'session-rename-input'; input.value = current; input.placeholder = s.session_id; labelEl.replaceWith(input); input.focus(); input.select(); async function commitRename() { const newName = input.value.trim(); await fetch(`/sessions/${s.session_id}?${_fileParams}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName }), }); const res = await fetch(`/sessions?${_fileParams}`); const data = await res.json(); renderPanel(data.sessions); // Update status bar if this is the active session if (sessionId === s.session_id) { sessionEl.textContent = `session: ${newName || s.session_id}`; } if (newName) showToast('Session renamed', 'success'); } input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commitRename(); } if (e.key === 'Escape') { renderPanel(sessions); } }); input.addEventListener('blur', commitRename); }); item.appendChild(renameBtn); const delBtn = document.createElement('button'); delBtn.className = 'session-delete-btn'; delBtn.textContent = '×'; delBtn.title = 'Delete session'; delBtn.addEventListener('click', async (e) => { e.stopPropagation(); await fetch(`/sessions/${s.session_id}?${_fileParams}`, { method: 'DELETE' }); if (sessionId === s.session_id) { sessionId = null; clear_stored_session(); currentHistory = []; messagesEl.innerHTML = ''; sessionEl.textContent = ''; showToast('Session deleted'); } const res = await fetch(`/sessions?${_fileParams}`); const data = await res.json(); renderPanel(data.sessions); }); item.appendChild(delBtn); sessionsPanel.appendChild(item); } } function makeItem(cls, label, meta) { const item = document.createElement('div'); item.className = 'session-item' + (cls ? ' ' + cls : ''); const idEl = document.createElement('span'); idEl.className = cls === 'new' ? '' : 'session-id'; idEl.textContent = label; item.appendChild(idEl); if (meta) { const metaEl = document.createElement('span'); metaEl.className = 'session-meta'; metaEl.textContent = meta; item.appendChild(metaEl); } return item; } async function resumeSession(id, silent = false) { talkThinkingDiv = null; if (id && id.startsWith('nct_')) sessionsBtn.classList.remove('talk-badge'); const res = await fetch(`/history/${id}?${_fileParams}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); messagesEl.innerHTML = ''; sessionId = id; sessionEl.textContent = `session: ${sessionNames.get(id) || id}`; currentHistory = []; for (let i = 0; i < data.messages.length; i++) { const msg = data.messages[i]; const role = msg.role === 'user' ? 'user' : 'assistant'; currentHistory.push({ role, content: msg.content }); const msgDiv = addMessage(role, msg.content); attachHistoryControls(msgDiv, i); if (role === 'assistant' && (msg.backend_label || msg.backend)) { const modelTag = document.createElement('div'); modelTag.className = 'model-tag'; const label = msg.backend_label || msg.backend; modelTag.textContent = msg.host ? `${label} · ${msg.host}` : label; msgDiv.appendChild(modelTag); } } if (!silent) addMessage('system', `Resumed session ${id}`); scrollToBottom(); sessionsPanel.classList.remove('open'); sessionsBackdrop.classList.remove('open'); inputEl.focus(); persist_session(); } function timeAgo(iso) { if (!iso) return '?'; const mins = Math.floor((Date.now() - new Date(iso)) / 60000); if (mins < 1) return 'just now'; if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; return `${Math.floor(hrs / 24)}d ago`; } function fallbackCopy(text) { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } // ── Scroll helpers ──────────────────────────────────────────── // Only auto-scroll when the user is already near the bottom (within 80px). // Explicit user actions (send, resume) call scrollToBottom() directly. function isNearBottom() { return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 80; } function scrollToBottom() { messagesEl.scrollTop = messagesEl.scrollHeight; } // ── Chat ───────────────────────────────────────────────────── // Returns the inner .message div. For user/assistant, wraps in .msg-wrapper. function addMessage(role, text) { const div = document.createElement('div'); div.className = `message ${role}`; if (role === 'assistant' && typeof marked !== 'undefined') { div.dataset.raw = text; div.innerHTML = marked.parse(text); highlight_code(div); div.querySelectorAll('a').forEach(a => { a.target = '_blank'; a.rel = 'noopener noreferrer'; }); div.appendChild(makeCopyBtn(div)); } else if (role === 'note-private' || role === 'note-public') { const label = document.createElement('span'); label.className = 'note-label'; label.textContent = role === 'note-private' ? '◦ private note' : '◦ context note'; const content = document.createElement('span'); content.className = 'note-content'; content.textContent = text; div.appendChild(label); div.appendChild(content); } else { div.dataset.raw = text; div.textContent = text; div.appendChild(makeCopyBtn(div)); } // Wrap user/assistant messages so action buttons can be attached const baseRole = role.split(' ')[0]; // 'user' or 'assistant' (strips 'thinking' etc) if (baseRole === 'user' || baseRole === 'assistant') { const wrapper = document.createElement('div'); wrapper.className = `msg-wrapper ${baseRole}`; wrapper.appendChild(div); const actions = document.createElement('div'); actions.className = 'msg-actions'; wrapper.appendChild(actions); messagesEl.appendChild(wrapper); } else { messagesEl.appendChild(div); } if (isNearBottom()) scrollToBottom(); return div; } // Wire edit/delete controls onto a message div (must already be in a .msg-wrapper). // histIdx is the index into currentHistory. Reads wrapper.dataset.histIdx at click time // so re-indexing after deletions is automatically picked up. function attachHistoryControls(msgDiv, histIdx) { const wrapper = msgDiv.parentElement; if (!wrapper || !wrapper.classList.contains('msg-wrapper')) return; wrapper.dataset.histIdx = histIdx; const actionsDiv = wrapper.querySelector('.msg-actions'); if (!actionsDiv) return; actionsDiv.innerHTML = ''; const editBtn = document.createElement('button'); editBtn.className = 'msg-act-btn'; editBtn.innerHTML = icon_html('pencil', 12) + ' edit'; editBtn.addEventListener('click', () => { startEdit(msgDiv); }); const delBtn = document.createElement('button'); delBtn.className = 'msg-act-btn del'; delBtn.innerHTML = icon_html('trash-2', 12) + ' del'; delBtn.addEventListener('click', () => { deleteMsg(wrapper); }); actionsDiv.appendChild(editBtn); actionsDiv.appendChild(delBtn); render_icons(); } // After any currentHistory splice, renumber all wrapper data-hist-idx attributes. function reIndexWrappers() { messagesEl.querySelectorAll('.msg-wrapper').forEach((w, i) => { w.dataset.histIdx = i; }); } function startEdit(msgDiv) { const wrapper = msgDiv.parentElement; const idx = parseInt(wrapper.dataset.histIdx); const role = msgDiv.classList.contains('user') ? 'user' : 'assistant'; const originalText = currentHistory[idx]?.content || msgDiv.dataset.raw || msgDiv.textContent; // Lock the current rendered size so the bubble doesn't collapse when we clear it const lockedW = msgDiv.offsetWidth; const lockedH = msgDiv.offsetHeight; msgDiv.style.minWidth = lockedW + 'px'; msgDiv.style.minHeight = lockedH + 'px'; const actionsDiv = wrapper.querySelector('.msg-actions'); if (actionsDiv) actionsDiv.style.display = 'none'; const ta = document.createElement('textarea'); ta.className = 'edit-textarea'; ta.value = originalText; ta.rows = Math.min(originalText.split('\n').length + 1, 12); const saveBtn = document.createElement('button'); saveBtn.innerHTML = icon_html('check', 13) + ' Save'; saveBtn.className = 'edit-save-btn'; const cancelBtn = document.createElement('button'); cancelBtn.innerHTML = icon_html('x', 13) + ' Cancel'; cancelBtn.className = 'edit-cancel-btn'; const btnRow = document.createElement('div'); btnRow.className = 'edit-btns'; btnRow.appendChild(saveBtn); btnRow.appendChild(cancelBtn); render_icons(); msgDiv.innerHTML = ''; msgDiv.appendChild(ta); msgDiv.appendChild(btnRow); ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); function unlock() { msgDiv.style.minWidth = ''; msgDiv.style.minHeight = ''; if (actionsDiv) actionsDiv.style.display = ''; } function restore() { setMessageText(msgDiv, role, originalText); unlock(); } function save() { const newText = ta.value.trim(); if (!newText) return; const currentIdx = parseInt(wrapper.dataset.histIdx); currentHistory[currentIdx].content = newText; setMessageText(msgDiv, role, newText); unlock(); syncHistory(); } saveBtn.addEventListener('click', save); cancelBtn.addEventListener('click', restore); ta.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); save(); } if (e.key === 'Escape') { e.preventDefault(); restore(); } }); } function deleteMsg(wrapper) { const idx = parseInt(wrapper.dataset.histIdx); currentHistory.splice(idx, 1); wrapper.remove(); reIndexWrappers(); syncHistory(); } async function syncHistory() { if (!sessionId) return; try { await fetch(`/history/${sessionId}?${_fileParams}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: currentHistory }), }); } catch (err) { console.error('syncHistory failed:', err); } } function setMessageText(div, role, text) { if (role === 'assistant' && typeof marked !== 'undefined') { div.dataset.raw = text; div.innerHTML = marked.parse(text); highlight_code(div); div.querySelectorAll('a').forEach(a => { a.target = '_blank'; a.rel = 'noopener noreferrer'; }); div.appendChild(makeCopyBtn(div)); } else { div.textContent = text; } } // ── Agent tool-call step cards ──────────────────────────────── function renderToolCalls(toolCalls, beforeEl) { if (!toolCalls || toolCalls.length === 0) return; const container = document.createElement('div'); container.className = 'tool-calls-container'; for (const tc of toolCalls) { const details = document.createElement('details'); details.className = 'tool-call'; // Summary: name + first arg value snippet const args = tc.args || {}; const argKeys = Object.keys(args); let argSnippet = ''; if (argKeys.length > 0) { const firstVal = String(args[argKeys[0]]); argSnippet = firstVal.length > 60 ? firstVal.slice(0, 60) + '…' : firstVal; } const summary = document.createElement('summary'); const nameSpan = document.createElement('span'); nameSpan.className = 'tc-name'; nameSpan.textContent = tc.tool; summary.appendChild(nameSpan); if (argSnippet) { const snippetSpan = document.createElement('span'); snippetSpan.className = 'tc-snippet'; snippetSpan.textContent = argSnippet; summary.appendChild(snippetSpan); } details.appendChild(summary); // Expanded body const body = document.createElement('div'); body.className = 'tc-body'; if (argKeys.length > 0) { const sec = document.createElement('div'); sec.className = 'tc-section'; const lbl = document.createElement('span'); lbl.className = 'tc-label'; lbl.textContent = 'args'; const pre = document.createElement('pre'); pre.textContent = JSON.stringify(args, null, 2); sec.appendChild(lbl); sec.appendChild(pre); body.appendChild(sec); } const resultStr = tc.result || ''; const truncated = resultStr.length > 400; const sec2 = document.createElement('div'); sec2.className = 'tc-section'; const lbl2 = document.createElement('span'); lbl2.className = 'tc-label'; lbl2.textContent = 'result'; const pre2 = document.createElement('pre'); pre2.textContent = truncated ? resultStr.slice(0, 400) + '\n…[truncated]' : resultStr; sec2.appendChild(lbl2); sec2.appendChild(pre2); body.appendChild(sec2); details.appendChild(body); container.appendChild(details); } beforeEl.parentElement.insertBefore(container, beforeEl); } function makeCopyBtn(div) { const btn = document.createElement('button'); btn.className = 'copy-btn'; btn.innerHTML = icon_html('copy', 12) + ' copy'; render_icons(); btn.addEventListener('click', (e) => { e.stopPropagation(); const text = div.dataset.raw || ''; if (navigator.clipboard) { navigator.clipboard.writeText(text).catch(() => fallbackCopy(text)); } else { fallbackCopy(text); } showToast('Copied to clipboard', 'success', 1800); btn.innerHTML = icon_html('check', 12) + ' copied'; render_icons(); btn.classList.add('copied'); setTimeout(() => { btn.innerHTML = icon_html('copy', 12) + ' copy'; btn.classList.remove('copied'); render_icons(); }, 1500); }); return btn; } async function addNote() { const text = inputEl.value.trim(); if (!text) return; inputEl.value = ''; syncHeight(); if (!note_public) { // Private: UI only, never sent to backend addMessage('note-private', text); return; } // Public: show in UI and persist to session so LLM sees it next turn if (!sessionId) { addMessage('system', 'Start a conversation first before adding a public note.'); return; } addMessage('note-public', text); try { const res = await fetch('/note', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, note: text }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (err) { showToast(`Note save failed: ${err.message}`, 'error'); } } stopBtn.addEventListener('click', () => { if (activeController) activeController.abort(); }); // ── Chat fetch + SSE handler ───────────────────────────────── // Extracted so the retry button can call it without re-adding the // user message to the DOM or currentHistory. async function _doSend(payload, thinkingDiv) { try { const res = await fetch('/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: activeController.signal, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); for (const line of lines) { if (!line.startsWith('data: ')) continue; const data = JSON.parse(line.slice(6)); if (data.type === 'keepalive') continue; if (data.type === 'response') { sessionId = data.session_id; sessionEl.textContent = `session: ${sessionId}`; persist_session(); thinkingDiv.className = 'message assistant'; setMessageText(thinkingDiv, 'assistant', data.response); const assistHistIdx = currentHistory.length; currentHistory.push({ role: 'assistant', content: data.response }); attachHistoryControls(thinkingDiv, assistHistIdx); // Model tag — always shown, amber if fallback was used const modelTag = document.createElement('div'); modelTag.className = 'model-tag' + (data.fallback_used ? ' fallback' : ''); const label = data.backend_label || data.backend || ''; const hostSuffix = data.host ? ` · ${data.host}` : ''; modelTag.textContent = data.fallback_used ? `⚡ fallback → ${label}${hostSuffix}` : `${label}${hostSuffix}`; thinkingDiv.appendChild(modelTag); } else if (data.type === 'error') { throw new Error(data.message); } } } } catch (err) { if (err.name === 'AbortError') { thinkingDiv.className = 'message system'; thinkingDiv.textContent = 'Stopped.'; } else { // Show error + retry button thinkingDiv.className = 'message error'; thinkingDiv.innerHTML = ''; const errSpan = document.createElement('span'); errSpan.textContent = `Error: ${err.message}`; thinkingDiv.appendChild(errSpan); const retryBtn = document.createElement('button'); retryBtn.className = 'retry-btn'; retryBtn.textContent = '↺ Retry'; retryBtn.addEventListener('click', async () => { // Roll back the failed user push, re-push, and try again if (currentHistory.at(-1)?.role === 'user') currentHistory.pop(); currentHistory.push({ role: 'user', content: payload.message }); thinkingDiv.className = 'message assistant thinking'; thinkingDiv.textContent = '✨ thinking…'; activeController = new AbortController(); sendBtn.style.display = 'none'; stopBtn.style.display = 'flex'; headerEmoji.classList.add('processing'); await _doSend(payload, thinkingDiv); activeController = null; headerEmoji.classList.remove('processing'); sendBtn.style.display = 'block'; stopBtn.style.display = 'none'; inputEl.focus(); }); thinkingDiv.appendChild(retryBtn); } } } async function sendMessage() { const text = inputEl.value.trim(); if (!text || activeController) return; inputEl.value = ''; syncHeight(); sendBtn.style.display = 'none'; stopBtn.style.display = 'flex'; headerEmoji.classList.add('processing'); activeController = new AbortController(); const userHistIdx = currentHistory.length; currentHistory.push({ role: 'user', content: text }); const userMsgDiv = addMessage('user', text); attachHistoryControls(userMsgDiv, userHistIdx); scrollToBottom(); const thinkingDiv = addMessage('assistant thinking', '✨ thinking…'); const payload = { message: text, session_id: sessionId, tier: currentTier, include_long: memLong, include_mid: memMid, include_short: memShort, off_record: current_mode === 'otr', chat_role: activeRole()?.role || 'chat', user: CORTEX_USER, persona: CORTEX_PERSONA, }; await _doSend(payload, thinkingDiv); activeController = null; headerEmoji.classList.remove('processing'); sendBtn.style.display = 'block'; stopBtn.style.display = 'none'; inputEl.focus(); } async function sendOrchestrate() { const text = inputEl.value.trim(); if (!text || activeController) return; inputEl.value = ''; syncHeight(); sendBtn.style.display = 'none'; stopBtn.style.display = 'flex'; headerEmoji.classList.add('processing'); activeController = new AbortController(); currentHistory.push({ role: 'user', content: text }); const userMsgDiv = addMessage('user', text); scrollToBottom(); const thinkingDiv = addMessage('assistant thinking', '⚡ working…'); try { const res = await fetch('/orchestrate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task: text, session_id: sessionId, tier: currentTier, include_long: memLong, include_mid: memMid, include_short: memShort, chat_role: activeRole()?.role || 'chat', user: CORTEX_USER, persona: CORTEX_PERSONA, }), signal: activeController.signal, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const { job_id } = await res.json(); // Poll until complete or stopped let job; while (true) { if (activeController.signal.aborted) throw new DOMException('Aborted', 'AbortError'); await new Promise(r => setTimeout(r, 2000)); if (activeController.signal.aborted) throw new DOMException('Aborted', 'AbortError'); const pollRes = await fetch(`/orchestrate/${job_id}`, { signal: activeController.signal, }); if (!pollRes.ok) throw new Error(`Poll failed: HTTP ${pollRes.status}`); job = await pollRes.json(); const n = job.tool_calls?.length || 0; if (job.status === 'queued' || job.status === 'running') { thinkingDiv.textContent = n ? `⚡ working… (${n} tool${n !== 1 ? 's' : ''} used)` : '⚡ working…'; continue; } break; } if (job.status === 'error') throw new Error(job.error || 'Orchestrator failed'); // Update session so this turn is part of the resumable history if (job.session_id) { sessionId = job.session_id; sessionEl.textContent = `session: ${sessionId}`; persist_session(); } const userHistIdx = currentHistory.length - 1; // pushed before fetch attachHistoryControls(userMsgDiv, userHistIdx); thinkingDiv.className = 'message assistant'; setMessageText(thinkingDiv, 'assistant', job.response || '(no response)'); const assistHistIdx = currentHistory.length; currentHistory.push({ role: 'assistant', content: job.response || '' }); attachHistoryControls(thinkingDiv, assistHistIdx); renderToolCalls(job.tool_calls, thinkingDiv.parentElement); } catch (err) { if (err.name === 'AbortError') { thinkingDiv.className = 'message system'; thinkingDiv.textContent = 'Stopped.'; } else { thinkingDiv.className = 'message error'; thinkingDiv.textContent = `Error: ${err.message}`; } } activeController = null; headerEmoji.classList.remove('processing'); sendBtn.style.display = 'block'; stopBtn.style.display = 'none'; inputEl.focus(); } function dispatchSend() { if (current_mode === 'note') addNote(); else if (toolsEnabled) sendOrchestrate(); else sendMessage(); } sendBtn.addEventListener('click', dispatchSend); inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { const shouldSend = ctrlEnterMode ? (e.ctrlKey || e.metaKey) : !e.shiftKey; if (shouldSend) { e.preventDefault(); dispatchSend(); } } }); inputEl.addEventListener('input', syncHeight); // ── File editor ────────────────────────────────────────────── const fileModal = document.getElementById('file-modal'); const fileSidebar = document.getElementById('file-sidebar'); const fileEditor = document.getElementById('file-editor'); const filePreview = document.getElementById('file-preview'); const fileRawBtn = document.getElementById('file-raw-btn'); const filePreviewBtn = document.getElementById('file-preview-btn'); const fileSaveBtn = document.getElementById('file-save-btn'); const fileCloseBtn = document.getElementById('file-close-btn'); const filesBtn = document.getElementById('files-btn'); let fileMode = 'preview'; // 'edit' or 'preview' let activeFileName = null; // File groups — controls sidebar order and section labels const FILE_GROUPS = [ { label: 'Identity', files: ['IDENTITY.md', 'SOUL.md', 'PROTOCOLS.md', 'CONTEXT_TIERS.md'] }, { label: 'Memory', files: ['MEMORY_LONG.md', 'MEMORY_MID.md', 'MEMORY_SHORT.md'] }, { label: 'Profile', files: ['USER.md', 'HELP.md'] }, ]; function fmtSize(bytes) { if (!bytes) return 'empty'; if (bytes < 1024) return bytes + ' B'; return (bytes / 1024).toFixed(1) + ' KB'; } function fmtModified(ts) { if (!ts) return ''; const d = new Date(ts * 1000); const now = new Date(); if (d.toDateString() === now.toDateString()) return 'today'; const diff = (now - d) / 86400000; if (diff < 2) return 'yesterday'; return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } function renderFileSidebar(files) { const byName = Object.fromEntries(files.map(f => [f.name, f])); fileSidebar.innerHTML = ''; for (const group of FILE_GROUPS) { const groupEl = document.createElement('div'); groupEl.className = 'file-group'; const header = document.createElement('div'); header.className = 'fg-header'; header.textContent = group.label; header.addEventListener('click', () => header.classList.toggle('collapsed')); groupEl.appendChild(header); const items = document.createElement('div'); items.className = 'fg-items'; for (const fname of group.files) { const f = byName[fname]; if (!f) continue; const item = document.createElement('div'); item.className = 'file-item' + (f.exists ? '' : ' missing'); item.dataset.name = fname; if (fname === activeFileName) item.classList.add('active'); const nameEl = document.createElement('div'); nameEl.className = 'fi-name'; nameEl.textContent = fname; item.appendChild(nameEl); const metaEl = document.createElement('div'); metaEl.className = 'fi-meta'; metaEl.innerHTML = `${fmtSize(f.size)}` + (f.modified ? `${fmtModified(f.modified)}` : ''); item.appendChild(metaEl); item.addEventListener('click', () => loadFile(fname)); items.appendChild(item); } groupEl.appendChild(items); fileSidebar.appendChild(groupEl); } } function setActiveFile(name) { activeFileName = name; fileSidebar.querySelectorAll('.file-item').forEach(el => { el.classList.toggle('active', el.dataset.name === name); }); document.getElementById('file-modal-title').textContent = name; } function setFileMode(mode) { fileMode = mode; if (mode === 'edit') { fileEditor.classList.remove('hidden'); filePreview.classList.remove('active'); fileRawBtn.classList.add('active'); filePreviewBtn.classList.remove('active'); } else { fileEditor.classList.add('hidden'); filePreview.classList.add('active'); fileRawBtn.classList.remove('active'); filePreviewBtn.classList.add('active'); if (typeof marked !== 'undefined') { filePreview.innerHTML = marked.parse(fileEditor.value); filePreview.querySelectorAll('a').forEach(a => { a.target = '_blank'; a.rel = 'noopener noreferrer'; }); } } } async function loadFile(name) { setActiveFile(name); const res = await fetch(`/files/${encodeURIComponent(name)}?${_fileParams}`); if (!res.ok) { fileEditor.value = `Error loading ${name}`; return; } const data = await res.json(); fileEditor.value = data.content; setFileMode(fileMode); } async function openFileModal() { const res = await fetch(`/files?${_fileParams}`); const data = await res.json(); renderFileSidebar(data.files); fileModal.classList.add('open'); // Load first existing file const first = data.files.find(f => f.exists) || data.files[0]; if (first) await loadFile(first.name); } filesBtn.addEventListener('click', () => { settings_dd_el.classList.remove('open'); openFileModal(); }); fileRawBtn.addEventListener('click', () => setFileMode('edit')); filePreviewBtn.addEventListener('click', () => setFileMode('preview')); fileSaveBtn.addEventListener('click', async () => { if (!activeFileName) return; const res = await fetch(`/files/${encodeURIComponent(activeFileName)}?${_fileParams}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: fileEditor.value }), }); if (res.ok) { showToast('File saved', 'success'); // Refresh sidebar to update size/modified const listRes = await fetch(`/files?${_fileParams}`); const listData = await listRes.json(); renderFileSidebar(listData.files); } else { showToast('Save failed', 'error'); } }); fileCloseBtn.addEventListener('click', () => fileModal.classList.remove('open')); fileModal.addEventListener('click', (e) => { if (e.target === fileModal) fileModal.classList.remove('open'); }); // ── Session search ──────────────────────────────────────────── const sessionSearchInput = document.getElementById('session-search-input'); const sessionSearchBtn = document.getElementById('session-search-btn'); const sessionSearchResults = document.getElementById('session-search-results'); function _showFileView() { fileEditor.style.display = ''; filePreview.style.display = ''; sessionSearchResults.style.display = 'none'; } function _showSearchResults(html) { fileEditor.style.display = 'none'; filePreview.style.display = 'none'; sessionSearchResults.style.display = ''; sessionSearchResults.innerHTML = html; } async function runSessionSearch() { const q = sessionSearchInput.value.trim(); if (q.length < 2) return; sessionSearchBtn.disabled = true; sessionSearchBtn.textContent = '…'; try { const res = await fetch(`/sessions/search?q=${encodeURIComponent(q)}&${_fileParams}&limit=30`); const data = await res.json(); if (!res.ok) { _showSearchResults(`
Error: ${data.detail || res.status}
`); return; } if (!data.matches.length) { _showSearchResults(`No results for "${_esc(q)}" in ${data.total_files_searched} session file(s).
`); return; } let html = `Search failed: ${e.message}
`); } finally { sessionSearchBtn.disabled = false; sessionSearchBtn.textContent = 'Go'; } } sessionSearchBtn.addEventListener('click', runSessionSearch); sessionSearchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') runSessionSearch(); }); // When a file is clicked, switch back from search results to editor fileSidebar.addEventListener('click', () => { if (sessionSearchResults.style.display !== 'none') _showFileView(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (fileModal.classList.contains('open')) fileModal.classList.remove('open'); } // Ctrl+S to save when file modal is open if ((e.ctrlKey || e.metaKey) && e.key === 's' && fileModal.classList.contains('open')) { e.preventDefault(); fileSaveBtn.click(); } }); // ── Real-time Talk updates (SSE) ───────────────────────────── const evtSource = new EventSource('/events'); // Close cleanly on navigation so the browser doesn't log "connection interrupted" window.addEventListener('beforeunload', () => evtSource.close()); evtSource.onerror = () => { // EventSource auto-reconnects — nothing to do; suppress console noise }; evtSource.onmessage = (e) => { let data; try { data = JSON.parse(e.data); } catch { return; } if (data.type === 'keepalive') return; if (data.type === 'claude_auth_expired') { let banner = document.getElementById('claude-auth-banner'); if (!banner) { banner = document.createElement('div'); banner.id = 'claude-auth-banner'; banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:9999;background:#7c2d12;color:#fef2f2;padding:0.6rem 1rem;font-size:0.85rem;display:flex;align-items:center;justify-content:space-between;gap:1rem;'; banner.innerHTML = '⚠️ Claude authentication expired — runclaude in your terminal to re-authenticate, then reload.'
+ '';
document.body.prepend(banner);
}
return;
}
if (data.type !== 'nct_message' && data.type !== 'nct_response') return;
if (sessionId === data.session_id) {
// Active session — append live
if (data.type === 'nct_message') {
// Clear any stale thinking div before new user msg
if (talkThinkingDiv) { talkThinkingDiv.remove(); talkThinkingDiv = null; }
addMessage('user', data.content);
talkThinkingDiv = addMessage('assistant thinking', '✨ thinking…');
} else {
if (talkThinkingDiv) {
talkThinkingDiv.className = 'message assistant';
setMessageText(talkThinkingDiv, 'assistant', data.content);
talkThinkingDiv = null;
} else {
addMessage('assistant', data.content);
}
scrollToBottom();
}
} else {
// Different session — light badge on Sessions button
if (data.type === 'nct_message') {
sessionsBtn.classList.add('talk-badge');
}
}
};
// ── Theme toggle ──────────────────────────────────────────────
const themeBtn = document.getElementById('theme-btn');
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
themeBtn.textContent = theme === 'dark' ? '☀' : '☾';
themeBtn.title = theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
}
{
const saved = localStorage.getItem('theme');
const sysDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
applyTheme(saved || (sysDark ? 'dark' : 'light'));
}
themeBtn.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', next);
applyTheme(next);
});
// ── Font size cycle ───────────────────────────────────────────
const fontSizeBtn = document.getElementById('font-size-btn');
const fontSizes = ['normal', 'large', 'small'];
const fontSizePx = { normal: '21px', large: '25px', small: '17px' };
const fontSizeLbl = { normal: 'Aa', large: 'A+', small: 'A−' };
function applyFontSize(size) {
document.documentElement.style.fontSize = fontSizePx[size];
fontSizeBtn.textContent = fontSizeLbl[size];
fontSizeBtn.title = `Font: ${size} — click to cycle`;
}
{
const saved = localStorage.getItem('font-size') || 'normal';
applyFontSize(saved);
}
fontSizeBtn.addEventListener('click', () => {
const current = localStorage.getItem('font-size') || 'normal';
const next = fontSizes[(fontSizes.indexOf(current) + 1) % fontSizes.length];
localStorage.setItem('font-size', next);
applyFontSize(next);
});
// ── Context panel — tier + memory toggles + distill ───────────
const ctxOpenBtn = document.getElementById('ctx-open-btn');
const ctxPanel = document.getElementById('ctx-panel');
const distillStatus = document.getElementById('ctx-distill-status');
let currentTier = parseInt(localStorage.getItem('ctx-tier') || '2');
let memLong = localStorage.getItem('mem-long') !== 'false';
let memMid = localStorage.getItem('mem-mid') !== 'false';
let memShort = localStorage.getItem('mem-short') !== 'false';
function updateTierUI() {
document.querySelectorAll('.ctx-btn[data-tier]').forEach(btn => {
btn.classList.toggle('active', parseInt(btn.dataset.tier) === currentTier);
});
ctxOpenBtn.querySelector('.tier-badge').textContent = currentTier;
}
function updateMemUI() {
document.getElementById('mem-long-btn').classList.toggle('mem-on', memLong);
document.getElementById('mem-mid-btn').classList.toggle('mem-on', memMid);
document.getElementById('mem-short-btn').classList.toggle('mem-on', memShort);
}
function formatNextRun(iso) {
if (!iso) return 'n/a';
const dt = new Date(iso);
const now = new Date();
const diffMs = dt - now;
const diffDays = Math.floor(diffMs / 86400000);
const time = dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (diffDays === 0) return `today ${time}`;
if (diffDays === 1) return `tomorrow ${time}`;
return dt.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time;
}
async function loadSchedule() {
const schedEl = document.getElementById('ctx-schedule');
try {
const res = await fetch('/distill/status');
const d = await res.json();
if (!d.enabled || !d.jobs.length) {
schedEl.textContent = 'auto-distill disabled';
return;
}
schedEl.innerHTML = d.jobs
.map(j => `${j.id.replace('distill_', '').padEnd(6)} → ${formatNextRun(j.next_run)}`)
.join('claude');
}
if (d.gemini?.warning) {
warnings.push('⚠ Gemini CLI not authenticated');
fixes.push('gemini');
}
if (!warnings.length) {
authBanner.classList.remove('show');
return;
}
authBannerMsg.innerHTML = warnings.join('