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 heightRow = document.getElementById('height-row'); const heightSel = document.getElementById('height-sel'); const enterToggle = document.getElementById('enter-toggle'); const noteTypeBtnEl = document.getElementById('note-type-btn'); const noteBtnEl = document.getElementById('note-btn'); const stopBtn = document.getElementById('stop'); let sessionId = null; let primaryBackend = 'claude'; let activeController = null; let currentHistory = []; // mirrors backend session [{role, content}, ...] let talkThinkingDiv = null; // pending "thinking…" bubble for live Talk updates // ── 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 ────────────────────────────────────────── let maxHeight = parseInt(localStorage.getItem('maxHeight') || '120'); function syncHeight() { inputEl.style.height = 'auto'; inputEl.style.maxHeight = maxHeight + 'px'; const sh = inputEl.scrollHeight; inputEl.style.height = Math.min(sh, maxHeight) + 'px'; // Show semi-hidden controls when content exceeds ~3 lines or a larger max is set const showExtras = sh > 80 || maxHeight > 120; heightRow.style.display = showExtras ? 'flex' : 'none'; enterToggle.style.display = showExtras ? 'block' : 'none'; } heightSel.value = String(maxHeight); heightSel.addEventListener('change', () => { maxHeight = parseInt(heightSel.value); localStorage.setItem('maxHeight', maxHeight); syncHeight(); }); // ── Note mode ──────────────────────────────────────────────── let noteMode = false; let notePublic = false; function updateInputMode() { if (noteMode) { noteBtnEl.classList.add('active'); noteTypeBtnEl.style.display = 'block'; sendBtn.textContent = 'Add Note'; inputEl.classList.add('note-mode'); if (notePublic) { inputEl.classList.add('public'); noteBtnEl.classList.add('public'); noteTypeBtnEl.textContent = 'public'; noteTypeBtnEl.classList.add('public'); } else { inputEl.classList.remove('public'); noteBtnEl.classList.remove('public'); noteTypeBtnEl.textContent = 'private'; noteTypeBtnEl.classList.remove('public'); } } else { noteBtnEl.classList.remove('active', 'public'); noteTypeBtnEl.style.display = 'none'; sendBtn.textContent = 'Send'; inputEl.classList.remove('note-mode', 'public'); } updateInputPlaceholder(); } function updateInputPlaceholder() { if (noteMode) { inputEl.placeholder = notePublic ? 'Public note — LLM sees this next turn…' : 'Private note — only you see this…'; } else { inputEl.placeholder = ctrlEnterMode ? 'Message Inara… (Ctrl+Enter to send)' : 'Message Inara…'; } } noteBtnEl.addEventListener('click', () => { noteMode = !noteMode; updateInputMode(); inputEl.focus(); }); noteTypeBtnEl.addEventListener('click', () => { notePublic = !notePublic; updateInputMode(); }); // ── Backend toggle ─────────────────────────────────────────── fetch('/backend').then(r => r.json()).then(d => setBackendUI(d.primary)); function setBackendUI(backend) { primaryBackend = backend; backendToggle.textContent = backend; backendToggle.className = 'ctx-btn' + (backend === 'gemini' ? ' mem-on' : ''); } backendToggle.addEventListener('click', async () => { const next = primaryBackend === 'claude' ? 'gemini' : 'claude'; const res = await fetch('/backend', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ primary: next }), }); const d = await res.json(); setBackendUI(d.primary); addMessage('system', `Backend: ${d.primary} (fallback: ${d.fallback})`); }); // ── Sessions panel ─────────────────────────────────────────── sessionsBtn.addEventListener('click', async (e) => { e.stopPropagation(); if (sessionsPanel.classList.contains('open')) { sessionsPanel.classList.remove('open'); return; } const res = await fetch('/sessions'); const data = await res.json(); renderPanel(data.sessions); sessionsPanel.classList.add('open'); }); document.addEventListener('click', (e) => { if (!sessionsPanel.contains(e.target) && e.target !== sessionsBtn) { sessionsPanel.classList.remove('open'); } }); function renderPanel(sessions) { sessionsPanel.innerHTML = ''; const newItem = makeItem('new', '+ New session', ''); newItem.addEventListener('click', () => { sessionId = null; currentHistory = []; messagesEl.innerHTML = ''; sessionEl.textContent = ''; addMessage('system', 'New session'); sessionsPanel.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 item = makeItem( s.session_id === sessionId ? 'active' : '', s.session_id, `${s.message_count} msgs · ${timeAgo(s.updated)}` ); item.addEventListener('click', () => resumeSession(s.session_id)); 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) { talkThinkingDiv = null; if (id && id.startsWith('nct_')) sessionsBtn.classList.remove('talk-badge'); const res = await fetch(`/history/${id}`); const data = await res.json(); messagesEl.innerHTML = ''; sessionId = id; sessionEl.textContent = `session: ${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); } addMessage('system', `Resumed session ${id}`); scrollToBottom(); sessionsPanel.classList.remove('open'); inputEl.focus(); } 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); 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.textContent = text; } // 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.textContent = 'edit'; editBtn.addEventListener('click', () => { startEdit(msgDiv); }); const delBtn = document.createElement('button'); delBtn.className = 'msg-act-btn del'; delBtn.textContent = 'del'; delBtn.addEventListener('click', () => { deleteMsg(wrapper); }); actionsDiv.appendChild(editBtn); actionsDiv.appendChild(delBtn); } // 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.textContent = 'Save'; saveBtn.className = 'edit-save-btn'; const cancelBtn = document.createElement('button'); cancelBtn.textContent = 'Cancel'; cancelBtn.className = 'edit-cancel-btn'; const btnRow = document.createElement('div'); btnRow.className = 'edit-btns'; btnRow.appendChild(saveBtn); btnRow.appendChild(cancelBtn); 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}`, { 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); div.querySelectorAll('a').forEach(a => { a.target = '_blank'; a.rel = 'noopener noreferrer'; }); div.appendChild(makeCopyBtn(div)); } else { div.textContent = text; } } function makeCopyBtn(div) { const btn = document.createElement('button'); btn.className = 'copy-btn'; btn.textContent = 'copy'; btn.addEventListener('click', (e) => { e.stopPropagation(); const text = div.dataset.raw || ''; if (navigator.clipboard) { navigator.clipboard.writeText(text).catch(() => fallbackCopy(text)); } else { fallbackCopy(text); } btn.textContent = '✓'; btn.classList.add('copied'); setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('copied'); }, 1500); }); return btn; } async function addNote() { const text = inputEl.value.trim(); if (!text) return; inputEl.value = ''; syncHeight(); if (!notePublic) { // 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) { addMessage('system', `Note save failed: ${err.message}`); } } stopBtn.addEventListener('click', () => { if (activeController) activeController.abort(); }); async function sendMessage() { const text = inputEl.value.trim(); if (!text || activeController) return; inputEl.value = ''; syncHeight(); sendBtn.style.display = 'none'; stopBtn.style.display = 'block'; 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…'); try { const res = await fetch('/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text, session_id: sessionId, tier: currentTier, include_long: memLong, include_mid: memMid, include_short: memShort, }), 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}`; thinkingDiv.className = 'message assistant'; setMessageText(thinkingDiv, 'assistant', data.response); const assistHistIdx = currentHistory.length; currentHistory.push({ role: 'assistant', content: data.response }); attachHistoryControls(thinkingDiv, assistHistIdx); if (data.fallback_used) { addMessage('system', `⚡ ${primaryBackend} unavailable — answered by ${data.backend}`); } } else if (data.type === 'error') { throw new Error(data.message); } } } } 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(); } sendBtn.addEventListener('click', () => { if (noteMode) addNote(); else sendMessage(); }); inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { const shouldSend = ctrlEnterMode ? (e.ctrlKey || e.metaKey) : !e.shiftKey; if (shouldSend) { e.preventDefault(); if (noteMode) addNote(); else sendMessage(); } } }); inputEl.addEventListener('input', syncHeight); // ── File editor ────────────────────────────────────────────── const fileModal = document.getElementById('file-modal'); const fileSelect = document.getElementById('file-select'); 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 fileSavedMsg = document.getElementById('file-saved-msg'); const fileCloseBtn = document.getElementById('file-close-btn'); const filesBtn = document.getElementById('files-btn'); let fileMode = 'preview'; // 'edit' or 'preview' 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) { const res = await fetch(`/files/${encodeURIComponent(name)}`); if (!res.ok) { fileEditor.value = `Error loading ${name}`; return; } const data = await res.json(); fileEditor.value = data.content; document.getElementById('file-modal-title').textContent = name; setFileMode(fileMode); } async function openFileModal() { // Populate the file list const res = await fetch('/files'); const data = await res.json(); fileSelect.innerHTML = ''; for (const f of data.files) { const opt = document.createElement('option'); opt.value = f.name; opt.textContent = f.name + (f.exists ? '' : ' (missing)'); fileSelect.appendChild(opt); } fileModal.classList.add('open'); await loadFile(fileSelect.value); } filesBtn.addEventListener('click', openFileModal); fileSelect.addEventListener('change', () => loadFile(fileSelect.value)); fileRawBtn.addEventListener('click', () => setFileMode('edit')); filePreviewBtn.addEventListener('click', () => setFileMode('preview')); fileSaveBtn.addEventListener('click', async () => { const name = fileSelect.value; const res = await fetch(`/files/${encodeURIComponent(name)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: fileEditor.value }), }); if (res.ok) { fileSavedMsg.classList.add('show'); setTimeout(() => fileSavedMsg.classList.remove('show'), 2000); } }); fileCloseBtn.addEventListener('click', () => fileModal.classList.remove('open')); fileModal.addEventListener('click', (e) => { if (e.target === fileModal) fileModal.classList.remove('open'); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (fileModal.classList.contains('open')) fileModal.classList.remove('open'); if (document.getElementById('help-modal')?.classList.contains('open')) document.getElementById('help-modal').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'); 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'); 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: '16px', large: '18px', small: '14px' }; 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); } ctxOpenBtn.addEventListener('click', (e) => { e.stopPropagation(); ctxPanel.classList.toggle('open'); }); document.addEventListener('click', (e) => { if (!ctxPanel.contains(e.target) && e.target !== ctxOpenBtn) { ctxPanel.classList.remove('open'); } }); document.querySelectorAll('.ctx-btn[data-tier]').forEach(btn => { btn.addEventListener('click', () => { currentTier = parseInt(btn.dataset.tier); localStorage.setItem('ctx-tier', currentTier); updateTierUI(); }); }); document.getElementById('mem-long-btn').addEventListener('click', () => { memLong = !memLong; localStorage.setItem('mem-long', memLong); updateMemUI(); }); document.getElementById('mem-mid-btn').addEventListener('click', () => { memMid = !memMid; localStorage.setItem('mem-mid', memMid); updateMemUI(); }); document.getElementById('mem-short-btn').addEventListener('click', () => { memShort = !memShort; localStorage.setItem('mem-short', memShort); updateMemUI(); }); function showDistillStatus(msg, isErr) { distillStatus.textContent = msg; distillStatus.classList.toggle('err', !!isErr); distillStatus.classList.add('show'); setTimeout(() => distillStatus.classList.remove('show'), 5000); } async function runDistill(endpoint) { showDistillStatus('distilling…', false); try { const res = await fetch(`/distill/${endpoint}`, { method: 'POST' }); const d = await res.json(); if (!res.ok || d.ok === false) { const err = d.error || d.mid?.error || d.long?.error || `HTTP ${res.status}`; showDistillStatus(`✗ ${err}`, true); } else { showDistillStatus(`✓ ${endpoint} done`, false); } } catch (err) { showDistillStatus(`✗ ${err.message}`, true); } } document.getElementById('distill-short-btn').addEventListener('click', () => runDistill('short')); document.getElementById('distill-mid-btn').addEventListener('click', () => runDistill('mid')); document.getElementById('distill-long-btn').addEventListener('click', () => runDistill('long')); document.getElementById('distill-all-btn').addEventListener('click', () => runDistill('all')); updateTierUI(); updateMemUI(); // ── Init ───────────────────────────────────────────────────── updateEnterToggleUI(); syncHeight(); addMessage('system', 'Session started'); // ── Help modal ──────────────────────────────────────────────── const helpBtn = document.getElementById('help-btn'); const helpModal = document.getElementById('help-modal'); const helpBody = document.getElementById('help-modal-body'); const helpClose = document.getElementById('help-close-btn'); async function openHelp() { helpBody.textContent = 'Loading…'; helpModal.classList.add('open'); try { const res = await fetch('/files/HELP.md'); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); helpBody.innerHTML = marked.parse(data.content); helpBody.querySelectorAll('a').forEach(a => { a.target = '_blank'; a.rel = 'noopener noreferrer'; }); } catch (err) { helpBody.textContent = `Failed to load help: ${err.message}`; } } helpBtn.addEventListener('click', openHelp); helpClose.addEventListener('click', () => helpModal.classList.remove('open')); helpModal.addEventListener('click', (e) => { if (e.target === helpModal) helpModal.classList.remove('open'); }); // ── Auth token warning banner ───────────────────────────── const authBanner = document.getElementById('auth-banner'); const authBannerMsg = document.getElementById('auth-banner-msg'); const authBannerClose = document.getElementById('auth-banner-close'); async function checkAuthStatus() { try { const res = await fetch('/auth/status'); if (!res.ok) return; const d = await res.json(); if (!d.warning) return; const msg = d.expired ? '✕ Claude CLI token has expired' : `⚠ Claude CLI token expires in ${d.hours_remaining}h`; authBannerMsg.textContent = msg; authBanner.classList.toggle('expired', !!d.expired); authBanner.classList.add('show'); } catch { /* silently ignore — don't break the UI */ } } authBannerClose.addEventListener('click', () => authBanner.classList.remove('show')); checkAuthStatus(); // Re-check every 30 minutes setInterval(checkAuthStatus, 30 * 60 * 1000);