Files
Cortex-Inara/cortex/static/index.html
Scott Idem fa96c50935 UI: font size cycle button (Aa / A+ / A−)
Cycles normal (16px) → large (18px) → small (14px) on the root element
so all rem-based text scales together. Persisted in localStorage, applied
before first paint to avoid flash. Also include today's session log.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 21:40:14 -04:00

1775 lines
68 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cortex — Inara</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>✨</text></svg>">
<!-- Apply saved theme + font size before first paint to avoid flash -->
<script>
(function(){
var t = localStorage.getItem('theme');
if (!t) t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', t);
var sizes = { normal: '16px', large: '18px', small: '14px' };
var fs = localStorage.getItem('font-size') || 'normal';
document.documentElement.style.fontSize = sizes[fs] || '16px';
})();
</script>
<script src="/static/marked.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Dark theme (default) ───────────────────────────────── */
:root {
--bg: #1a1228;
--surface: #221840;
--border: #3a2852;
--user-bg: #5c1528;
--user-border: #7a1f36;
--inara-bg: #261d42;
--inara-border: #3d2a55;
--accent: #c4935a;
--text: #e8e0f0;
--muted: #9080a8;
--error-bg: #3b0f0f;
--error-border: #7f1d1d;
--error-text: #fca5a5;
--shadow: rgba(0,0,0,0.55);
--modal-overlay: rgba(0,0,0,0.72);
--code-bg: rgba(0,0,0,0.30);
--pre-bg: rgba(0,0,0,0.35);
--success: #6abf6a;
--success-dim: #2a4a2a;
}
/* ── Light theme ─────────────────────────────────────────── */
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--bg: #f2eef9;
--surface: #e8e1f4;
--border: #c0afd8;
--user-bg: #f5dae2;
--user-border: #d890aa;
--inara-bg: #ede5f8;
--inara-border: #b8a0d8;
--accent: #7a4818;
--text: #1c1030;
--muted: #60487a;
--error-bg: #fde8e8;
--error-border: #d88888;
--error-text: #8b0f0f;
--shadow: rgba(0,0,0,0.18);
--modal-overlay: rgba(0,0,0,0.45);
--code-bg: rgba(0,0,0,0.06);
--pre-bg: rgba(0,0,0,0.07);
--success: #1e6e1e;
--success-dim: #5aaa5a;
}
}
/* Manual overrides — take precedence over system preference */
[data-theme="dark"] {
--bg: #1a1228;
--surface: #221840;
--border: #3a2852;
--user-bg: #5c1528;
--user-border: #7a1f36;
--inara-bg: #261d42;
--inara-border: #3d2a55;
--accent: #c4935a;
--text: #e8e0f0;
--muted: #9080a8;
--error-bg: #3b0f0f;
--error-border: #7f1d1d;
--error-text: #fca5a5;
--shadow: rgba(0,0,0,0.55);
--modal-overlay: rgba(0,0,0,0.72);
--code-bg: rgba(0,0,0,0.30);
--pre-bg: rgba(0,0,0,0.35);
--success: #6abf6a;
--success-dim: #2a4a2a;
}
[data-theme="light"] {
--bg: #f2eef9;
--surface: #e8e1f4;
--border: #c0afd8;
--user-bg: #f5dae2;
--user-border: #d890aa;
--inara-bg: #ede5f8;
--inara-border: #b8a0d8;
--accent: #7a4818;
--text: #1c1030;
--muted: #60487a;
--error-bg: #fde8e8;
--error-border: #d88888;
--error-text: #8b0f0f;
--shadow: rgba(0,0,0,0.18);
--modal-overlay: rgba(0,0,0,0.45);
--code-bg: rgba(0,0,0,0.06);
--pre-bg: rgba(0,0,0,0.07);
--success: #1e6e1e;
--success-dim: #5aaa5a;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
display: flex;
flex-direction: column;
}
header {
padding: 12px 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
position: relative;
}
.header-emoji {
font-size: 1.6rem;
display: inline-block;
}
@keyframes shimmer {
0% { transform: scale(1) rotate(0deg); opacity: 1; }
25% { transform: scale(1.2) rotate(-12deg); opacity: 0.7; }
75% { transform: scale(1.2) rotate(12deg); opacity: 0.7; }
100% { transform: scale(1) rotate(0deg); opacity: 1; }
}
.header-emoji.processing { animation: shimmer 0.75s ease-in-out infinite; }
header .name { font-size: 1.1rem; font-weight: 600; color: var(--accent); }
header .subtitle { font-size: 0.78rem; color: var(--muted); }
.hdr-btn {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--muted);
font-size: 0.75rem;
padding: 5px 10px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.hdr-btn:hover { border-color: var(--muted); color: var(--text); }
#backend-toggle.gemini { border-color: var(--success-dim); color: var(--success); }
#sessions-btn { margin-left: auto; }
/* Sessions panel */
#sessions-panel {
display: none;
position: absolute;
top: calc(100% + 4px);
right: 20px;
width: 300px;
max-height: 340px;
overflow-y: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
z-index: 100;
box-shadow: 0 8px 24px var(--shadow);
}
#sessions-panel.open { display: block; }
.session-item {
padding: 10px 14px;
cursor: pointer;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.session-item:last-child { border-bottom: none; }
.session-item:hover { background: var(--bg); }
.session-item.new { color: var(--accent); justify-content: center; }
.session-id {
font-family: monospace;
font-size: 0.85rem;
color: var(--text);
}
.session-meta {
font-size: 0.72rem;
color: var(--muted);
white-space: nowrap;
text-align: right;
flex-shrink: 0;
}
/* Messages */
#messages {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.message {
max-width: 75%;
padding: 10px 14px;
border-radius: 12px;
line-height: 1.55;
word-wrap: break-word;
font-size: 0.95rem;
}
.message.user { white-space: pre-wrap; }
.message.user {
align-self: flex-end;
background: var(--user-bg);
border: 1px solid var(--user-border);
border-bottom-right-radius: 3px;
}
.message.assistant {
align-self: flex-start;
background: var(--inara-bg);
border: 1px solid var(--inara-border);
border-bottom-left-radius: 3px;
}
/* Markdown rendering inside assistant messages */
.message.assistant p { margin: 0 0 0.6em; }
.message.assistant p:last-child { margin-bottom: 0; }
.message.assistant ul,
.message.assistant ol { margin: 0.4em 0 0.6em 1.4em; padding: 0; }
.message.assistant li { margin-bottom: 0.2em; }
.message.assistant h1,
.message.assistant h2,
.message.assistant h3 { margin: 0.8em 0 0.3em; font-weight: 600;
color: var(--accent); line-height: 1.3; }
.message.assistant h1 { font-size: 1.1em; }
.message.assistant h2 { font-size: 1.0em; }
.message.assistant h3 { font-size: 0.95em; }
.message.assistant strong { color: var(--text); font-weight: 600; }
.message.assistant em { color: var(--accent); font-style: italic; }
.message.assistant a { color: var(--accent); text-decoration: underline; }
.message.assistant hr { border: none; border-top: 1px solid var(--border);
margin: 0.8em 0; }
.message.assistant blockquote {
border-left: 3px solid var(--border);
margin: 0.5em 0;
padding: 0.2em 0.8em;
color: var(--muted);
}
.message.assistant code {
font-family: 'Courier New', monospace;
font-size: 0.88em;
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.1em 0.35em;
}
.message.assistant pre {
background: var(--pre-bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
overflow-x: auto;
margin: 0.5em 0;
}
.message.assistant pre code {
background: none;
border: none;
padding: 0;
font-size: 0.85em;
}
.message.system {
align-self: center;
font-size: 0.72rem;
color: var(--muted);
background: none;
padding: 2px 0;
}
.message.error {
align-self: flex-start;
background: var(--error-bg);
border: 1px solid var(--error-border);
color: var(--error-text);
border-bottom-left-radius: 3px;
}
.message.thinking { color: var(--muted); font-style: italic; }
/* Copy button */
.message.assistant { position: relative; }
.copy-btn {
position: absolute;
top: 7px;
right: 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--muted);
font-size: 0.7rem;
padding: 2px 7px;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, color 0.15s, border-color 0.15s;
}
.message.assistant:hover .copy-btn { opacity: 1; }
.copy-btn:hover { color: var(--text); border-color: var(--muted); }
.copy-btn.copied { color: var(--success); border-color: var(--success-dim); }
/* Note messages */
.message.note-private {
align-self: flex-end;
background: rgba(100, 70, 5, 0.15);
border: 1px dashed rgba(180, 130, 40, 0.45);
border-bottom-right-radius: 3px;
font-size: 0.9rem;
max-width: 70%;
}
.message.note-public {
align-self: flex-end;
background: rgba(5, 70, 70, 0.15);
border: 1px dashed rgba(40, 170, 150, 0.45);
border-bottom-right-radius: 3px;
font-size: 0.9rem;
max-width: 70%;
}
.note-label {
display: block;
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 5px;
opacity: 0.65;
}
.message.note-private .note-label { color: #c9a84c; }
.message.note-public .note-label { color: #4abfb0; }
.message.note-private .note-content { color: #c9a84c; white-space: pre-wrap; }
.message.note-public .note-content { color: #4abfb0; white-space: pre-wrap; }
/* ── Input area ────────────────────────────────────────────── */
#input-area {
padding: 14px 20px;
background: var(--surface);
border-top: 1px solid var(--border);
display: flex;
gap: 10px;
align-items: flex-end;
}
#input {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: 10px 14px;
font-size: 0.95rem;
font-family: inherit;
resize: none;
line-height: 1.4;
overflow-y: auto;
transition: border-color 0.2s;
}
#input:focus { outline: none; border-color: var(--muted); }
#input.note-mode { border-color: rgba(180, 130, 40, 0.55); }
#input.note-mode:focus { border-color: rgba(180, 130, 40, 0.85); }
#input.note-mode.public { border-color: rgba(40, 170, 150, 0.55); }
#input.note-mode.public:focus { border-color: rgba(40, 170, 150, 0.85); }
/* Right column — all controls stacked, fixed width */
#right-col {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 4px;
flex-shrink: 0;
width: 88px;
}
/* Semi-hidden controls: height selector row */
#height-row {
display: none; /* shown by JS when content > 3 lines */
align-items: center;
gap: 4px;
}
#height-row span {
font-size: 0.65rem;
color: var(--muted);
flex-shrink: 0;
}
#height-sel {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
color: var(--muted);
font-size: 0.65rem;
padding: 2px 4px;
cursor: pointer;
min-width: 0;
}
#height-sel:focus { outline: none; border-color: var(--muted); }
/* Semi-hidden: enter-mode toggle */
#enter-toggle {
display: none; /* shown by JS when content > 3 lines */
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
color: var(--muted);
font-size: 0.68rem;
padding: 3px 6px;
cursor: pointer;
text-align: center;
transition: border-color 0.15s, color 0.15s;
}
#enter-toggle:hover { border-color: var(--muted); color: var(--text); }
/* Note type toggle — only visible in note mode */
#note-type-btn {
display: none;
background: var(--bg);
border: 1px solid rgba(180, 130, 40, 0.4);
border-radius: 5px;
color: rgba(180, 130, 40, 0.85);
font-size: 0.68rem;
padding: 3px 6px;
cursor: pointer;
text-align: center;
transition: opacity 0.15s;
}
#note-type-btn.public {
border-color: rgba(40, 170, 150, 0.4);
color: rgba(40, 170, 150, 0.85);
}
#note-type-btn:hover { opacity: 0.75; }
/* Note button */
#note-btn {
background: var(--bg);
border: 1px solid var(--border);
color: var(--muted);
border-radius: 8px;
padding: 8px 0;
cursor: pointer;
font-size: 0.85rem;
text-align: center;
transition: border-color 0.15s, color 0.15s;
}
#note-btn:hover { border-color: var(--muted); color: var(--text); }
#note-btn.active { border-color: rgba(180, 130, 40, 0.6); color: #c9a84c; }
#note-btn.active.public { border-color: rgba(40, 170, 150, 0.6); color: #4abfb0; }
/* Send button */
#send {
background: var(--user-bg);
border: 1px solid var(--user-border);
color: var(--text);
border-radius: 8px;
padding: 10px 0;
cursor: pointer;
font-size: 0.9rem;
text-align: center;
transition: background 0.15s;
}
#send:hover { background: var(--user-border); }
#send:disabled { background: var(--surface); color: var(--muted);
border-color: var(--border); cursor: not-allowed; }
/* Stop button */
#stop {
display: none;
background: var(--error-bg);
border: 1px solid var(--error-border);
color: var(--error-text);
border-radius: 8px;
padding: 10px 0;
cursor: pointer;
font-size: 0.9rem;
text-align: center;
transition: background 0.15s;
}
#stop:hover { background: #5c1a1a; }
#session-id {
font-size: 0.7rem;
color: var(--border);
padding: 0 20px 6px;
background: var(--surface);
}
/* ── Message wrappers (edit/delete controls) ──────────────── */
.msg-wrapper {
display: flex;
flex-direction: column;
max-width: 75%;
}
.msg-wrapper.user { align-self: flex-end; }
.msg-wrapper.assistant { align-self: flex-start; }
/* Inner message fills wrapper width */
.msg-wrapper .message.user,
.msg-wrapper .message.assistant {
align-self: stretch;
max-width: none;
}
.msg-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.15s;
padding: 2px 2px 0;
}
.msg-wrapper.user .msg-actions { justify-content: flex-end; }
.msg-wrapper.assistant .msg-actions { justify-content: flex-start; }
.msg-wrapper:hover .msg-actions { opacity: 1; }
.msg-act-btn {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--muted);
font-size: 0.65rem;
padding: 1px 6px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.msg-act-btn:hover { color: var(--text); border-color: var(--muted); }
.msg-act-btn.del:hover { color: var(--error-text); border-color: var(--error-border); }
/* Inline edit */
.edit-textarea {
width: 100%;
background: var(--bg);
border: 1px solid var(--muted);
border-radius: 6px;
color: var(--text);
padding: 6px 10px;
font-size: 0.9rem;
font-family: inherit;
resize: vertical;
line-height: 1.4;
}
.edit-textarea:focus { outline: none; border-color: var(--accent); }
.edit-btns {
display: flex;
gap: 6px;
margin-top: 6px;
justify-content: flex-end;
}
.edit-save-btn, .edit-cancel-btn {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--muted);
font-size: 0.75rem;
padding: 3px 10px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.edit-save-btn { border-color: var(--inara-border); color: var(--accent); }
.edit-save-btn:hover { background: var(--inara-bg); }
.edit-cancel-btn:hover { color: var(--text); border-color: var(--muted); }
/* ── File editor modal ───────────────────────────────────── */
#file-modal {
display: none;
position: fixed;
inset: 0;
background: var(--modal-overlay);
z-index: 200;
align-items: center;
justify-content: center;
}
#file-modal.open { display: flex; }
#file-modal-inner {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
width: min(860px, 96vw);
height: min(82vh, 800px);
display: flex;
flex-direction: column;
overflow: hidden;
}
#file-modal-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
background: var(--bg);
flex-shrink: 0;
}
#file-modal-header select {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text);
font-size: 0.85rem;
padding: 4px 8px;
cursor: pointer;
}
#file-modal-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--accent);
flex: 1;
}
.fm-btn {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
color: var(--muted);
font-size: 0.75rem;
padding: 4px 10px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.fm-btn:hover { color: var(--text); border-color: var(--muted); }
.fm-btn.active { color: var(--accent); border-color: var(--accent); }
.fm-btn.save { color: var(--accent); border-color: var(--inara-border); }
.fm-btn.save:hover { background: var(--inara-bg); }
#file-saved-msg {
font-size: 0.75rem;
color: #6abf6a;
opacity: 0;
transition: opacity 0.3s;
}
#file-saved-msg.show { opacity: 1; }
#file-modal-body {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
#file-editor {
flex: 1;
width: 100%;
background: var(--bg);
color: var(--text);
border: none;
outline: none;
padding: 16px;
font-family: 'Courier New', monospace;
font-size: 0.85rem;
line-height: 1.55;
resize: none;
display: block;
}
#file-preview {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
display: none;
line-height: 1.6;
}
#file-preview.active { display: block; }
#file-editor.hidden { display: none; }
/* Talk activity badge on Sessions button */
#sessions-btn.talk-badge::after {
content: '●';
color: #7cb9e8;
margin-left: 5px;
font-size: 0.55rem;
vertical-align: middle;
}
/* ── Context panel ───────────────────────────────────────── */
#ctx-panel {
display: none;
position: absolute;
top: calc(100% + 4px);
right: 20px;
width: 280px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
z-index: 100;
box-shadow: 0 8px 24px var(--shadow);
overflow: hidden;
}
#ctx-panel.open { display: block; }
.ctx-section {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
}
.ctx-section:last-child { border-bottom: none; }
.ctx-section-title {
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 8px;
}
.ctx-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.ctx-btn {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--muted);
font-size: 0.73rem;
padding: 4px 10px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.ctx-btn:hover { color: var(--text); border-color: var(--muted); }
.ctx-btn.active { color: var(--accent); border-color: var(--accent); }
.ctx-btn.mem-on { color: var(--success); border-color: var(--success-dim); }
#ctx-distill-status {
margin-top: 6px;
font-size: 0.68rem;
color: var(--success);
min-height: 1em;
opacity: 0;
transition: opacity 0.3s;
}
#ctx-distill-status.show { opacity: 1; }
#ctx-distill-status.err { color: var(--error-text); }
/* Theme toggle + font size */
#theme-btn { font-size: 0.85rem; padding: 5px 8px; }
#font-size-btn { min-width: 32px; text-align: center; }
/* Ctx header button — shows current tier as a dim superscript */
#ctx-open-btn .tier-badge {
font-size: 0.6em;
opacity: 0.7;
margin-left: 2px;
vertical-align: super;
}
</style>
</head>
<body>
<header>
<span class="header-emoji"></span>
<div>
<div class="name">Inara</div>
<div class="subtitle">Cortex · Local</div>
</div>
<button id="sessions-btn" class="hdr-btn">Sessions</button>
<button id="files-btn" class="hdr-btn">Files</button>
<button id="ctx-open-btn" class="hdr-btn" title="Context &amp; memory settings"><span class="tier-badge">2</span></button>
<button id="backend-toggle" class="hdr-btn" title="Click to switch primary backend">claude</button>
<button id="font-size-btn" class="hdr-btn" title="Cycle font size">Aa</button>
<button id="theme-btn" class="hdr-btn" title="Toggle light/dark mode"></button>
<div id="sessions-panel"></div>
<!-- Context / memory panel -->
<div id="ctx-panel">
<div class="ctx-section">
<div class="ctx-section-title">Context Tier</div>
<div class="ctx-row">
<button class="ctx-btn" data-tier="1" id="tier-1" title="Minimal (~1.5k tokens)">T1</button>
<button class="ctx-btn active" data-tier="2" id="tier-2" title="Standard (~5k tokens)">T2</button>
<button class="ctx-btn" data-tier="3" id="tier-3" title="Extended (~15k tokens)">T3</button>
<button class="ctx-btn" data-tier="4" id="tier-4" title="Full (~50k tokens)">T4</button>
</div>
</div>
<div class="ctx-section">
<div class="ctx-section-title">Memory Layers</div>
<div class="ctx-row">
<button class="ctx-btn mem-on" id="mem-long-btn" title="Long-term (MEMORY_LONG.md)">Long</button>
<button class="ctx-btn mem-on" id="mem-mid-btn" title="Mid-term (MEMORY_MID.md)">Mid</button>
<button class="ctx-btn mem-on" id="mem-short-btn" title="Short-term (MEMORY_SHORT.md)">Short</button>
</div>
</div>
<div class="ctx-section">
<div class="ctx-section-title">Distill Memory</div>
<div class="ctx-row">
<button class="ctx-btn" id="distill-short-btn" title="Roll session logs → MEMORY_SHORT (no LLM)">short</button>
<button class="ctx-btn" id="distill-mid-btn" title="Summarize short → MEMORY_MID (LLM)">mid</button>
<button class="ctx-btn" id="distill-long-btn" title="Integrate mid → MEMORY_LONG (LLM)">long</button>
<button class="ctx-btn" id="distill-all-btn" title="Run all three steps in sequence">all</button>
</div>
<div id="ctx-distill-status"></div>
</div>
</div>
</header>
<!-- File editor modal -->
<div id="file-modal">
<div id="file-modal-inner">
<div id="file-modal-header">
<span id="file-modal-title">Context Files</span>
<select id="file-select"></select>
<button class="fm-btn" id="file-raw-btn">edit</button>
<button class="fm-btn active" id="file-preview-btn">preview</button>
<button class="fm-btn save" id="file-save-btn">Save</button>
<span id="file-saved-msg">saved ✓</span>
<button class="fm-btn" id="file-close-btn"></button>
</div>
<div id="file-modal-body">
<textarea id="file-editor" spellcheck="false"></textarea>
<div id="file-preview"></div>
</div>
</div>
</div>
<div id="messages"></div>
<div id="session-id"></div>
<div id="input-area">
<textarea id="input" rows="1" placeholder="Message Inara… (Ctrl+Enter to send)" autofocus></textarea>
<div id="right-col">
<!-- Semi-hidden: appear when content > ~3 lines -->
<div id="height-row">
<span></span>
<select id="height-sel">
<option value="120">5 lines</option>
<option value="240">10 lines</option>
<option value="480">20 lines</option>
</select>
</div>
<button id="enter-toggle" title="Toggle send shortcut">⌃↵</button>
<!-- Note mode controls -->
<button id="note-type-btn">private</button>
<button id="note-btn">Note</button>
<button id="send">Send</button>
<button id="stop">Stop</button>
</div>
</div>
<script>
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 = 'hdr-btn' + (backend === 'gemini' ? ' gemini' : '');
}
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' && fileModal.classList.contains('open')) {
fileModal.classList.remove('open');
}
// Ctrl+S to save when 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');
</script>
</body>
</html>