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'); // ── Utilities ───────────────────────────────────────────────── function escapeHtml(str) { return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } // ── 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'; } const modeSelectEl = document.getElementById('mode-select'); 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]; // Drive row/column layout via data attribute if (modeSelectEl) modeSelectEl.dataset.size = HEIGHT_LABELS[i].toLowerCase(); } 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 name = p.name || p; const emoji = p.emoji || '✨'; const a = document.createElement('a'); a.href = `/${CORTEX_USER}/${name}`; if (name === CORTEX_PERSONA) a.classList.add('active'); const emojiEl = document.createElement('span'); emojiEl.className = 'pd-emoji'; emojiEl.textContent = emoji; a.appendChild(emojiEl); const nameEl = document.createElement('span'); nameEl.textContent = name.charAt(0).toUpperCase() + name.slice(1); a.appendChild(nameEl); 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); _maybeShowNoBanner(availableRoles); }); function _maybeShowNoBanner(roles) { const key = 'cx_no_model_banner_dismissed'; if (roles.length > 0) { localStorage.removeItem(key); return; } if (localStorage.getItem(key)) return; const banner = document.createElement('div'); banner.id = 'no-model-banner'; banner.style.cssText = [ 'background:#1c1a0a','border-bottom:1px solid #78350f', 'color:#fbbf24','font-size:0.82rem','padding:0.55rem 1rem', 'display:flex','align-items:center','gap:0.75rem','flex-shrink:0', ].join(';'); banner.innerHTML = ` ⚡ Using server default model — add your own for more choices and to track your usage. Set up OpenRouter → `; // Insert at the top of #chat-col (or body if not found) const col = document.getElementById('chat-col') || document.body.firstElementChild; col.insertBefore(banner, col.firstChild); } 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 = document.createElement('div'); item.className = 'session-item' + (s.session_id === sessionId ? ' active' : ''); // ── Edit button (left) ────────────────────────────────── const editBtn = document.createElement('button'); editBtn.className = 'session-edit-btn'; editBtn.textContent = '✎'; editBtn.title = 'Rename session'; // ── Body: name (top) + meta (below) ───────────────────── const bodyEl = document.createElement('div'); bodyEl.className = 'session-body'; const labelEl = document.createElement('span'); labelEl.className = 'session-id'; labelEl.textContent = displayName; const metaEl = document.createElement('span'); metaEl.className = 'session-meta'; metaEl.textContent = `${s.message_count} msgs · ${timeAgo(s.updated)}`; bodyEl.append(labelEl, metaEl); // ── Delete button (far right) ──────────────────────────── const delBtn = document.createElement('button'); delBtn.className = 'session-delete-btn'; delBtn.title = 'Delete session'; delBtn.textContent = '×'; item.append(editBtn, bodyEl, delBtn); // Click anywhere on the row (not a button) → resume item.addEventListener('click', (e) => { if (!e.target.closest('button')) resumeSession(s.session_id); }); // ── Edit mode ──────────────────────────────────────────── function enterEditMode(e) { e.stopPropagation(); const input = document.createElement('input'); input.className = 'session-rename-input'; input.value = s.name || ''; input.placeholder = s.session_id; // Swap body + delete for the input bodyEl.hidden = true; delBtn.hidden = true; editBtn.textContent = '✓'; editBtn.title = 'Save name'; editBtn.className = 'session-save-btn'; editBtn.onclick = async (e) => { e.stopPropagation(); await commitRename(); }; editBtn.after(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 }), }); if (sessionId === s.session_id) sessionEl.textContent = `session: ${newName || s.session_id}`; if (newName) showToast('Session renamed', 'success'); const res = await fetch(`/sessions?${_fileParams}`); renderPanel((await res.json()).sessions); } function cancelEdit() { input.remove(); bodyEl.hidden = false; delBtn.hidden = false; editBtn.textContent = '✎'; editBtn.title = 'Rename session'; editBtn.className = 'session-edit-btn'; editBtn.onclick = enterEditMode; } input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commitRename(); } if (e.key === 'Escape') { e.preventDefault(); cancelEdit(); } }); } editBtn.onclick = enterEditMode; // ── Delete ─────────────────────────────────────────────── 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}`); renderPanel((await res.json()).sessions); }); 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(); // Prefer name from API response, fall back to sessionNames map, then raw ID const displayName = data.name || sessionNames.get(id) || id; sessionNames.set(id, displayName); messagesEl.innerHTML = ''; sessionId = id; sessionEl.textContent = `session: ${displayName}`; 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: ${displayName}`); 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(); // Auto-name the session from the first user message if (wasNewSession) { const autoName = text.slice(0, 60).trimEnd() + (text.length > 60 ? '…' : ''); fetch(`/sessions/${sessionId}?${_fileParams}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: autoName }), }).then(() => { sessionEl.textContent = `session: ${autoName}`; sessionNames.set(sessionId, autoName); }).catch(() => {}); } 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; const wasNewSession = !sessionId; 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; } if (job.status === 'awaiting_confirmation') { const pc = job.pending_confirmation || {}; const toolNames = (pc.tools || []).map(t => t.name).join(', '); thinkingDiv.className = 'message assistant'; thinkingDiv.innerHTML = `
${escapeHtml(pc.message || 'Confirm this action?')}
Tool${(pc.tools||[]).length !== 1 ? 's' : ''}: ${escapeHtml(toolNames)}
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 !== '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'); const _metaThemeColor = document.getElementById('meta-theme-color'); const _themeColors = { dark: '#1a1228', light: '#f2eef9' }; 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'; if (_metaThemeColor) _metaThemeColor.content = _themeColors[theme] || _themeColors.dark; } { 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'; const TIER_LABELS = { 1: 'Min', 2: 'Std', 3: 'Ext', 4: 'Full' }; function updateTierUI() { document.querySelectorAll('.ctx-btn[data-tier]').forEach(btn => { btn.classList.toggle('active', parseInt(btn.dataset.tier) === currentTier); }); ctxOpenBtn.querySelector('.tier-badge').textContent = TIER_LABELS[currentTier] || 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('