// Per-agent web UI. Renders title + login/online view from `/api/state`, // tails `/events/stream` for live claude events, drives async-form // actions (send / login/* / dashboard rebuild). (() => { // ─── helpers ──────────────────────────────────────────────────────────── const $ = (id) => document.getElementById(id); const escText = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&':'&', '<':'<', '>':'>', '"':'"' }[c]) ); const el = (tag, attrs = {}, ...children) => { const e = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k === 'class') e.className = v; else if (k === 'html') e.innerHTML = v; else e.setAttribute(k, v); } for (const c of children) { if (c == null) continue; e.append(c.nodeType ? c : document.createTextNode(c)); } return e; }; // ─── async-form submit (shared with dashboard) ────────────────────────── document.addEventListener('submit', async (e) => { const f = e.target; if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return; e.preventDefault(); if (f.dataset.confirm && !confirm(f.dataset.confirm)) return; const btn = f.querySelector('button[type="submit"], button:not([type])'); const original = btn ? btn.innerHTML : ''; if (btn) { btn.disabled = true; btn.innerHTML = ''; } try { const resp = await fetch(f.action, { method: f.method || 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(new FormData(f)), redirect: 'manual', }); const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400); if (!ok) { const text = await resp.text().catch(() => ''); alert('action failed: ' + resp.status + (text ? '\n\n' + text : '')); if (btn) { btn.disabled = false; btn.innerHTML = original; } return; } // Clear text inputs the operator typed into (the form value was sent). f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; }); // Re-enable the button — refreshState() often skips re-rendering the // form (status unchanged), so without this the spinner sticks and // the operator can't submit again. if (btn) { btn.disabled = false; btn.innerHTML = original; } refreshState(); } catch (err) { alert('action failed: ' + err); if (btn) { btn.disabled = false; btn.innerHTML = original; } } }); // ─── state rendering ──────────────────────────────────────────────────── function setHeader(label, dashboardPort) { $('banner').textContent = `░▒▓█▓▒░ ${label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░`; const title = $('title'); title.textContent = `◆ ${label} ◆ `; // ↑ DASHB04RD — back-link to the host dashboard. Opens in a new // tab to keep the agent page anchored where the operator is. const dashUrl = `${location.protocol}//${location.hostname}:${dashboardPort}/`; title.append( el('a', { href: dashUrl, target: '_blank', rel: 'noopener', class: 'btn-dashlink', title: 'host dashboard', }, '↑ DASHB04RD'), ' ', ); const btn = el('a', { href: '#', class: 'btn-rebuild', id: 'rebuild-btn', }, '↻ R3BU1LD'); btn.addEventListener('click', (e) => { e.preventDefault(); if (!confirm(`rebuild ${label}? container will hot-reload.`)) return; const f = document.createElement('form'); f.method = 'POST'; f.action = `${dashUrl}rebuild/${label}`; document.body.appendChild(f); f.submit(); }); title.append(btn); document.title = `${label} // hyperhive`; } function renderOnline(_label, _root) { // Online state is conveyed by the `#alive-badge` chip in the // state row — no longer a separate paragraph in the status // block (keeps the terminal the star, status row stays compact). } function renderNeedsLoginIdle(root) { root.append( el('p', { class: 'status-needs-login' }, '◌ NEEDS L0G1N'), el('p', { html: 'No Claude session in ~/.claude/. The harness is up but the turn loop is paused until you log in.', }), ); const start = el('form', { action: '/login/start', method: 'POST', 'data-async': '', }); start.append( el('button', { type: 'submit', class: 'btn btn-login' }, '◆ ST4RT L0G1N'), ); root.append(start); root.append(el('p', { class: 'meta', html: 'Spawns claude auth login over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.', })); } function renderLoginInProgress(s, root) { root.append(el('p', { class: 'status-needs-login' }, '◌ L0G1N 1N PR0GRESS')); if (s.url) { const link = el('a', { href: s.url, target: '_blank', rel: 'noreferrer', }, s.url); root.append(el('p', {}, '▶ ', link)); root.append(el('p', { class: 'meta' }, 'open this URL in a browser, complete the OAuth flow, paste the resulting code below.', )); } else { root.append(el('p', { class: 'meta' }, 'waiting for claude to emit an OAuth URL on stdout… (output below)', )); } if (!s.finished) { const code = el('form', { action: '/login/code', method: 'POST', class: 'loginform', 'data-async': '', }); code.append( el('input', { name: 'code', placeholder: 'paste OAuth code here', required: '', autocomplete: 'off', }), el('button', { type: 'submit', class: 'btn btn-login' }, '◆ S3ND C0DE'), ); root.append(code); } const cancel = el('form', { action: '/login/cancel', method: 'POST', 'data-async': '', style: 'margin-top: 0.4em;', }); cancel.append(el('button', { type: 'submit', class: 'btn btn-cancel' }, 'cancel + kill')); root.append(cancel); if (s.finished) { root.append(el('p', { class: 'status-needs-login' }, `claude process exited: ${s.exit_note || 'exited'}. Start over if needed.`, )); } root.append(el('h3', {}, 'output')); root.append(el('pre', { class: 'diff' }, s.output || '')); } let headerSet = false; let lastStatus = null; let lastOutputLen = -1; let pollTimer = null; let termInputRendered = false; // Filled in by the live-event IIFE below. Used by the slash-command // dispatcher to print local-only rows ('help', errors) and to clear // the terminal on `/clear`. let termAPI = null; const SLASH_COMMANDS = [ { name: '/help', desc: 'list slash commands' }, { name: '/clear', desc: 'wipe the terminal panel (local-only)' }, { name: '/cancel', desc: 'SIGINT the in-flight claude turn' }, { name: '/compact', desc: 'compact the persistent claude session' }, { name: '/model', desc: '/model — switch claude model for future turns' }, { name: '/new-session', desc: 'next turn runs without --continue (fresh claude session)' }, ]; async function postModel(name) { try { const resp = await fetch('/api/model', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ model: name }), redirect: 'manual', }); const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400); if (!ok && termAPI) { const text = await resp.text().catch(() => ''); termAPI.row('turn-end-fail', '✗ /model failed: ' + resp.status + (text ? ' — ' + text : '')); } else { refreshState(); } } catch (err) { if (termAPI) termAPI.row('turn-end-fail', '✗ /model failed: ' + err); } } async function postSimple(url, label) { try { const resp = await fetch(url, { method: 'POST', redirect: 'manual' }); const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400); if (!ok && termAPI) { termAPI.row('turn-end-fail', '✗ ' + label + ' failed: http ' + resp.status); } } catch (err) { if (termAPI) termAPI.row('turn-end-fail', '✗ ' + label + ' failed: ' + err); } } const postCancelTurn = () => postSimple('/api/cancel', '/cancel'); const postCompact = () => postSimple('/api/compact', '/compact'); const postNewSession = () => postSimple('/api/new-session', '/new-session'); function handleSlashCommand(line) { if (!termAPI) return false; const trimmed = line.trim(); if (!trimmed.startsWith('/')) return false; const [cmd] = trimmed.split(/\s+/); switch (cmd) { case '/help': termAPI.row('note', '· /help'); for (const c of SLASH_COMMANDS) { termAPI.row('note', ' ' + c.name.padEnd(10) + ' — ' + c.desc); } return true; case '/clear': termAPI.clear(); termAPI.row('note', '· terminal cleared (local view only — server history kept)'); return true; case '/cancel': postCancelTurn(); return true; case '/compact': postCompact(); return true; case '/new-session': if (window.confirm('arm a fresh claude session for the next turn? all prior --continue context will be dropped.')) { postNewSession(); } return true; case '/model': { const parts = trimmed.split(/\s+/); if (parts.length < 2 || !parts[1]) { termAPI.row('turn-end-fail', '✗ /model needs a name (e.g. /model haiku, /model sonnet, /model opus)'); } else { postModel(parts[1]); } return true; } default: termAPI.row('turn-end-fail', '✗ unknown slash command: ' + cmd + ' — try /help'); return true; } } // Cycle through commands when operator hits Tab on a `/…` prefix. function completeSlash(prefix) { const matches = SLASH_COMMANDS.filter((c) => c.name.startsWith(prefix)); if (!matches.length) return null; // Cycle: when the current prefix already equals a command name, // advance to the next match. const idx = matches.findIndex((c) => c.name === prefix); return matches[(idx + 1) % matches.length].name; } function renderTermInput(label, online) { const slot = $('term-input'); if (!slot) return; if (!termInputRendered) { slot.innerHTML = ''; const form = el('form', { action: '/send', method: 'POST', class: 'sendform-term', 'data-async': '', }); const ta = el('textarea', { name: 'body', placeholder: 'message ' + label + '…', required: '', autocomplete: 'off', rows: '1', }); // Enter submits, Shift+Enter inserts a newline. Auto-grow up to // ~8 rows of content, then scroll inside the textarea. const MAX_PX = 12 * 16; // ~8 lines @ 1.5 line-height, 1em base const grow = () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, MAX_PX) + 'px'; }; ta.addEventListener('input', grow); ta.addEventListener('keydown', (e) => { // Tab-complete slash commands when the buffer starts with `/`. if (e.key === 'Tab' && ta.value.startsWith('/') && !ta.value.includes(' ')) { const next = completeSlash(ta.value); if (next) { e.preventDefault(); ta.value = next; return; } } if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); const line = ta.value; if (!line.trim()) return; // Intercept slash commands locally; never send them to the agent. if (line.trim().startsWith('/')) { if (handleSlashCommand(line)) { ta.value = ''; grow(); return; } } form.requestSubmit(); } }); // Reset height after async submit clears the value. form.addEventListener('submit', () => setTimeout(grow, 0)); form.append( el('span', { class: 'prompt' }, 'operator@' + label + ' ▸'), ta, el('span', { class: 'submit-hint' }, '↵ send · ⇧↵ newline · /help'), ); slot.append(form); termInputRendered = true; } slot.classList.toggle('disabled', !online); const ta = slot.querySelector('textarea'); if (ta) ta.disabled = !online; } // Granular state badge: idle / thinking / offline. Driven from SSE // turn_start/turn_end. Age timer ticks client-side; badge re-renders // each second so the "· 12s" suffix stays current. State changes // trigger a short flash animation via .state-just-changed. const STATE_LABELS = { loading: { glyph: '…', text: 'booting' }, offline: { glyph: '○', text: 'offline' }, idle: { glyph: '💤', text: 'idle' }, thinking: { glyph: '🧠', text: 'thinking' }, compacting: { glyph: '📦', text: 'compacting' }, }; let stateName = 'loading'; let stateSince = Date.now(); let stateTickTimer = null; function fmtAge(ms) { const s = Math.floor(ms / 1000); if (s < 60) return s + 's'; const m = Math.floor(s / 60); if (m < 60) return m + 'm ' + (s % 60) + 's'; const h = Math.floor(m / 60); return h + 'h ' + (m % 60) + 'm'; } const STATE_TOOLTIPS = { loading: 'harness not yet contacted', offline: 'harness unreachable or claude not logged in', idle: 'turn loop running, no claude invocation in flight', thinking: 'claude is executing the current turn', compacting: 'operator-triggered /compact running on the persistent session', }; function renderStateBadge() { const badge = $('state-badge'); if (!badge) return; const def = STATE_LABELS[stateName] || STATE_LABELS.loading; const age = fmtAge(Date.now() - stateSince); badge.textContent = def.glyph + ' ' + def.text + ' · ' + age; badge.className = 'state-badge state-' + stateName; badge.title = (STATE_TOOLTIPS[stateName] || '') + '\nin this state for ' + age; const cancelBtn = $('cancel-btn'); if (cancelBtn) cancelBtn.hidden = stateName !== 'thinking'; } function setState(next) { setStateAbs(next, Math.floor(Date.now() / 1000)); } /// Set state with an authoritative since-unix from the server. Lets /// `last turn` track the actual server-side duration rather than /// whatever the client perceived between SSE events. function setStateAbs(next, sinceUnix) { if (next === stateName && sinceUnix * 1000 === stateSince) return; if (stateName === 'thinking' && next !== 'thinking') { const elapsedMs = Date.now() - stateSince; renderLastTurn(elapsedMs); } const flashing = next !== stateName; stateName = next; stateSince = sinceUnix * 1000; const badge = $('state-badge'); if (badge && flashing) { badge.classList.remove('state-just-changed'); void badge.offsetWidth; badge.classList.add('state-just-changed'); } renderStateBadge(); } function renderInbox(rows) { const root = $('inbox-section'); const list = $('inbox-list'); const summary = $('inbox-summary'); if (!root || !list || !summary) return; if (!rows.length) { root.hidden = true; return; } root.hidden = false; summary.textContent = 'inbox · ' + rows.length; list.innerHTML = ''; const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(5, 19); for (const m of rows) { const li = el('li'); li.append( el('span', { class: 'inbox-ts' }, fmt(m.at)), ' ', el('span', { class: 'inbox-from' }, m.from), ' ', el('span', { class: 'inbox-sep' }, '→'), ' ', el('span', { class: 'inbox-body' }, m.body), ); list.append(li); } } // Harness reachability badge: derived from the same `s.status` the // status block reads. Each status maps to a glyph + label + colour // class. Lives in the state row so the operator sees boot/login/ // online without losing terminal real-estate to a paragraph. const ALIVE_LABELS = { loading: { glyph: '…', text: 'connecting', cls: 'status-loading' }, online: { glyph: '●', text: 'alive', cls: 'status-online' }, needs_login_idle: { glyph: '◌', text: 'needs login', cls: 'status-needs-login' }, needs_login_in_progress: { glyph: '◌', text: 'logging in', cls: 'status-needs-login' }, offline: { glyph: '○', text: 'offline', cls: 'status-offline' }, }; function renderAliveBadge(status) { const el_ = $('alive-badge'); if (!el_) return; const def = ALIVE_LABELS[status] || ALIVE_LABELS.loading; el_.textContent = def.glyph + ' ' + def.text; el_.className = 'status-badge ' + def.cls; } function renderModelChip(model) { const el_ = $('model-chip'); if (!el_) return; if (!model) { el_.hidden = true; return; } el_.hidden = false; el_.textContent = 'model · ' + model; el_.title = `claude --model ${model}\nset via the operator's /model command; persists across turns until changed`; } // Context badge — mirrors Claude Code's bottom-right "N tokens" // indicator. Primary number is total prompt tokens used in the // current context window (input + both cache axes); hover for the // breakdown including output. Kept as chrome on the state row so // the terminal stays the star. function renderTokenUsage(u) { const el_ = $('ctx-badge'); if (!el_) return; if (!u) { el_.hidden = true; return; } const ctx = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens; const fmt = (n) => n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n); el_.hidden = false; el_.title = [ 'context window in use', 'input: ' + u.input_tokens, 'cache_read: ' + u.cache_read_input_tokens, 'cache_write: ' + u.cache_creation_input_tokens, 'output (last turn): ' + u.output_tokens, ].join('\n'); el_.textContent = 'ctx · ' + fmt(ctx); } function renderLastTurn(ms) { const el_ = $('last-turn'); if (!el_) return; let s = ''; if (ms < 1000) s = ms + 'ms'; else if (ms < 60_000) s = (ms / 1000).toFixed(1) + 's'; else s = Math.floor(ms / 60_000) + 'm ' + Math.floor((ms / 1000) % 60) + 's'; el_.textContent = '· last turn ' + s; el_.title = `wall-clock duration of the last completed claude turn (${ms} ms)`; el_.hidden = false; } function startStateTicker() { if (stateTickTimer) return; stateTickTimer = setInterval(renderStateBadge, 1000); } startStateTicker(); // Wire the cancel-turn button (visible only while state === thinking). (() => { const btn = $('cancel-btn'); if (!btn) return; btn.addEventListener('click', () => { btn.disabled = true; postCancelTurn().finally(() => { btn.disabled = false; }); }); })(); // Wire the new-session button (always visible; arms a one-shot for // the next turn). Mildly destructive (drops --continue context) so // we confirm before posting. (() => { const btn = $('new-session-btn'); if (!btn) return; btn.addEventListener('click', () => { if (!window.confirm('arm a fresh claude session for the next turn? all prior --continue context will be dropped.')) return; btn.disabled = true; postNewSession().finally(() => { btn.disabled = false; }); }); })(); // Track banner activity by reference-counting in-flight turns. A turn // can begin while the previous turn_end is still in the pipeline (rare // but happens on tight wake cycles), so we count rather than toggle. let activeTurns = 0; function setBannerActive(on) { const banner = $('banner'); if (!banner) return; if (on) { activeTurns += 1; banner.classList.add('active'); } else { activeTurns = Math.max(0, activeTurns - 1); if (activeTurns === 0) banner.classList.remove('active'); } } async function refreshState() { try { const resp = await fetch('/api/state'); if (!resp.ok) throw new Error('http ' + resp.status); const s = await resp.json(); if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; } renderTermInput(s.label, s.status === 'online'); renderInbox(s.inbox || []); // Authoritative state comes from the harness via /api/state. // Login-not-yet → 'offline'; otherwise use the server-reported // turn_state (idle / thinking / compacting). stateSince in // unix-seconds is converted to a client-side Date.now() anchor. if (s.status !== 'online') { setState('offline'); } else if (s.turn_state) { setStateAbs(s.turn_state, s.turn_state_since); } renderAliveBadge(s.status); renderModelChip(s.model); renderTokenUsage(s.token_usage); // Skip the re-render if nothing structurally changed. The most // common case is `online` polling itself — without this guard, the // operator's gets clobbered every cycle. const outLen = s.session?.output?.length ?? -1; const dirty = s.status !== lastStatus || (s.status === 'needs_login_in_progress' && outLen !== lastOutputLen); if (dirty) { const root = $('status'); root.innerHTML = ''; if (s.status === 'online') renderOnline(s.label, root); else if (s.status === 'needs_login_idle') renderNeedsLoginIdle(root); else if (s.status === 'needs_login_in_progress') renderLoginInProgress(s.session || {}, root); lastStatus = s.status; lastOutputLen = outLen; } // Only poll while a login is in flight — otherwise SSE turn_end // events trigger a refresh, and the operator can type into the // send form without it getting cleared every few seconds. if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; } if (s.status === 'needs_login_in_progress') { pollTimer = setTimeout(refreshState, 1500); } } catch (err) { console.error('refreshState failed', err); pollTimer = setTimeout(refreshState, 5000); } } refreshState(); // ─── live event stream ────────────────────────────────────────────────── // Scrolling, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS // (window.HiveTerminal). What stays here is the per-kind rendering: // turn framing, claude stream-json interpretation, tool_use prettyprint, // tool_result collapse, +/- diff bodies for Write/Edit. (function() { const log = $('live'); if (!log || !window.HiveTerminal) return; log.innerHTML = ''; function trim(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; } // Pretty-print a tool call: per-known-tool format, fallback to JSON // for unknown tools. function fmtToolUse(c) { const name = c.name || ''; const input = c.input || {}; const short = name.startsWith('mcp__hyperhive__') ? name.slice('mcp__hyperhive__'.length) + '*' : name; switch (name) { case 'Read': return short + ' ' + (input.file_path || ''); case 'Write': return short + ' ' + (input.file_path || ''); case 'Edit': return short + ' ' + (input.file_path || ''); case 'Glob': return short + ' ' + (input.pattern || ''); case 'Grep': return short + ' ' + (input.pattern || ''); case 'Bash': return short + ' $ ' + (input.command || ''); case 'TodoWrite': return short + ' (' + ((input.todos || []).length) + ' items)'; case 'mcp__hyperhive__send': return short + ' → ' + (input.to || '?') + ': ' + JSON.stringify(input.body || '').slice(0, 80); case 'mcp__hyperhive__recv': return short + '()'; case 'mcp__hyperhive__request_spawn': return short + ' ' + (input.name || ''); case 'mcp__hyperhive__kill': return short + ' ' + (input.name || ''); case 'mcp__hyperhive__request_apply_commit': return short + ' ' + (input.agent || '') + ' @ ' + (input.commit_ref || '').slice(0, 12); default: return name + ' ' + trim(JSON.stringify(input), 200); } } // Build a "rich" tool_use row for tools whose input has a body we // want the operator to see in full. Returns null for any other tool // so the caller falls back to the flat-row path. // Write: every input.content line is "+". // Edit: old_string lines as "-", new_string lines as "+". // mcp__hyperhive__send: collapsed
, full body text inside. function renderRichToolUse(c, api) { const name = c.name || ''; const input = c.input || {}; if (name === 'Write' || name === 'Edit') { const path = input.file_path || '?'; let body; let plus = 0; let minus = 0; if (name === 'Write') { const content = String(input.content || ''); const lines = content.split('\n'); plus = lines.length; body = lines.map(l => '+ ' + l).join('\n'); } else { const oldLines = String(input.old_string || '').split('\n'); const newLines = String(input.new_string || '').split('\n'); minus = oldLines.length; plus = newLines.length; body = oldLines.map(l => '- ' + l).join('\n') + '\n' + newLines.map(l => '+ ' + l).join('\n'); } const summary = '→ ' + name + ' ' + path + ' · ' + (minus ? '-' + minus + ' ' : '') + '+' + plus; return api.detailsDiff('tool-use', summary, body); } if (name === 'mcp__hyperhive__send') { const to = input.to || '?'; const body = String(input.body || ''); const headline = body.replace(/\s+/g, ' ').trim().slice(0, 80); const lines = body.split('\n').length; const summary = '→ send → ' + to + (lines > 1 ? ` · ${lines}L` : '') + (headline ? ' · ' + headline + (body.length > 80 ? '…' : '') : ''); return api.details('tool-use', summary, body); } return null; } function renderToolResult(c, api) { const txt = Array.isArray(c.content) ? c.content.map(p => p.text || '').join('') : (c.content || ''); const summary = '← ' + (() => { const trimmed = txt.replace(/\s+/g, ' ').trim(); if (!trimmed) return '(empty)'; if (trimmed.length <= 120) return trimmed; const lines = txt.split('\n').filter(l => l.length).length; const headline = trimmed.slice(0, 90) + '…'; return `${lines}L · ${headline}`; })(); if (!txt.trim() || txt.length <= 120) { api.row('tool-result', summary); } else { api.details('tool-result-block', summary, txt); } } function renderStream(v, api) { // Drop session init, claude's result line, rate-limit — noise. // TurnEnd communicates pass/fail; session init isn't actionable. if (v.type === 'system' && v.subtype === 'init') return; if (v.type === 'rate_limit_event') return; if (v.type === 'result') return; if (v.type === 'assistant' && v.message && v.message.content) { for (const c of v.message.content) { if (c.type === 'text' && c.text && c.text.trim()) api.row('text', c.text); else if (c.type === 'thinking') { const txt = (c.thinking || c.text || '').trim(); api.row('thinking', txt ? '· ' + txt : '· thinking …'); } else if (c.type === 'tool_use') { if (!renderRichToolUse(c, api)) { api.row('tool-use', '→ ' + fmtToolUse(c)); } } } return; } if (v.type === 'user' && v.message && v.message.content) { for (const c of v.message.content) { if (c.type === 'tool_result') renderToolResult(c, api); } return; } api.row('sys', '· ' + trim(JSON.stringify(v), 200)); } // Count open turns across the backfill replay so the live banner + // state badge reflect whatever the history last left running. With // shared HiveTerminal this is computed inside each renderer instead // of in a second walk over the events list. let openTurnsFromHistory = 0; const term = HiveTerminal.create({ logEl: log, historyUrl: '/events/history', streamUrl: '/events/stream', renderers: { turn_start(ev, api) { if (api.fromHistory) openTurnsFromHistory += 1; else { setBannerActive(true); setState('thinking'); } const block = api.row('turn-start', '◆ TURN ← ' + ev.from); if (ev.unread > 0) { const badge = document.createElement('span'); badge.className = 'unread-badge'; badge.textContent = '· ' + ev.unread + ' unread'; block.appendChild(badge); } const body = document.createElement('div'); body.className = 'turn-body'; body.textContent = ev.body; block.appendChild(body); }, turn_end(ev, api) { if (api.fromHistory) { openTurnsFromHistory = Math.max(0, openTurnsFromHistory - 1); } else { setBannerActive(false); setState('idle'); // Login may have just landed (or session re-enters Online). refreshState(); } const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail'; api.row(cls, (ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : '')); }, note(ev, api) { api.row('note', '· ' + ev.text); }, stream(ev, api) { const v = Object.assign({}, ev); delete v.kind; renderStream(v, api); }, }, onBackfillDone() { // If the last replayed turn never closed, the banner shimmer + // thinking badge should be on. Apply in one pass after replay. for (let i = 0; i < openTurnsFromHistory; i++) setBannerActive(true); if (openTurnsFromHistory > 0) setState('thinking'); }, }); // Expose the panel API for slash commands (`/help`, `/clear`). termAPI = { row: (cls, text) => term.row(cls, text), clear: () => { log.innerHTML = ''; }, }; })(); // Avoid unused-var lint while keeping `escText` available for future use. void escText; })();