From f27108aecfb8206b51c7b23accc536b0c6a862c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 11:53:50 +0200 Subject: [PATCH] agent: route terminal scroll+backfill+SSE through hive-fr0nt::TERMINAL_JS --- hive-ag3nt/assets/app.js | 296 ++++++++++------------------------ hive-ag3nt/assets/index.html | 1 + hive-ag3nt/src/web_ui.rs | 8 + hive-fr0nt/assets/terminal.js | 217 +++++++++++++++++++++++++ hive-fr0nt/src/lib.rs | 1 + 5 files changed, 308 insertions(+), 215 deletions(-) create mode 100644 hive-fr0nt/assets/terminal.js diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index 3c9461b..14d6167 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -532,98 +532,15 @@ 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) return; - let placeholder = log.firstChild; - function setPlaceholder(text) { - log.innerHTML = ''; - const span = document.createElement('div'); - span.className = 'meta'; - span.textContent = text; - log.appendChild(span); - placeholder = span; - } - function clearPlaceholder() { - if (placeholder) { log.innerHTML = ''; placeholder = null; } - } - // Backfill replays mark rows .no-anim so we don't stagger 100 fade-ins - // on page load. Set via `currentNoAnim` before the row helpers fire. - let currentNoAnim = false; - // Expose the panel API for slash commands (`/help`, `/clear`). - termAPI = { - row: (cls, text) => row(cls, text), - clear: () => { log.innerHTML = ''; placeholder = null; }, - }; + if (!log || !window.HiveTerminal) return; + log.innerHTML = ''; - // Sticky-bottom auto-scroll. If the user is reading scrolled-up, new - // rows do NOT yank the view. A floating "↓ N new" pill appears in - // the bottom-right corner; clicking it jumps to bottom and clears - // the counter. Scrolling back near the bottom also clears it. - const NEAR_BOTTOM_PX = 48; - let unseen = 0; - let pill = null; - function isNearBottom() { - return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX; - } - function ensurePill() { - if (pill) return pill; - pill = document.createElement('button'); - pill.type = 'button'; - pill.className = 'tail-pill'; - pill.addEventListener('click', () => { - log.scrollTop = log.scrollHeight; - }); - log.parentElement.appendChild(pill); - return pill; - } - function updatePill() { - if (unseen <= 0) { - if (pill) pill.classList.remove('visible'); - return; - } - ensurePill(); - pill.textContent = '↓ ' + unseen + ' new'; - pill.classList.add('visible'); - } - log.addEventListener('scroll', () => { - if (isNearBottom()) { - unseen = 0; - updatePill(); - } - }); - function afterAppend() { - if (currentNoAnim || isNearBottom()) { - log.scrollTop = log.scrollHeight; - } else { - unseen += 1; - updatePill(); - } - } - function row(cls, text) { - clearPlaceholder(); - const e = document.createElement('div'); - e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); - e.textContent = text; - log.appendChild(e); - afterAppend(); - return e; - } - function details(cls, summary, body) { - clearPlaceholder(); - const d = document.createElement('details'); - d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); - const s = document.createElement('summary'); - s.textContent = summary; - d.appendChild(s); - const pre = document.createElement('pre'); - pre.className = 'tool-body'; - pre.textContent = body; - d.appendChild(pre); - log.appendChild(d); - afterAppend(); - return d; - } 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. @@ -650,16 +567,13 @@ 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. - // + // 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. Truncating to 80 chars in the summary was hiding - // anything past the first sentence. - function renderRichToolUse(c) { + // 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') { @@ -683,7 +597,7 @@ } const summary = '→ ' + name + ' ' + path + ' · ' + (minus ? '-' + minus + ' ' : '') + '+' + plus; - return detailsDiff('tool-use', summary, body); + return api.detailsDiff('tool-use', summary, body); } if (name === 'mcp__hyperhive__send') { const to = input.to || '?'; @@ -692,35 +606,11 @@ const lines = body.split('\n').length; const summary = '→ send → ' + to + (lines > 1 ? ` · ${lines}L` : '') + (headline ? ' · ' + headline + (body.length > 80 ? '…' : '') : ''); - return details('tool-use', summary, body); + return api.details('tool-use', summary, body); } return null; } - function detailsDiff(cls, summary, body) { - clearPlaceholder(); - const d = document.createElement('details'); - d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); - const s = document.createElement('summary'); - s.textContent = summary; - d.appendChild(s); - const pre = document.createElement('pre'); - pre.className = 'tool-body diff-body'; - // Color each line by its leading +/-. - for (const line of body.split('\n')) { - const span = document.createElement('span'); - if (line.startsWith('+ ')) span.className = 'diff-add'; - else if (line.startsWith('- ')) span.className = 'diff-del'; - else span.className = 'diff-ctx'; - span.textContent = line + '\n'; - pre.appendChild(span); - } - d.appendChild(pre); - log.appendChild(d); - afterAppend(); - return d; - } - - function renderToolResult(c) { + function renderToolResult(c, api) { const txt = Array.isArray(c.content) ? c.content.map(p => p.text || '').join('') : (c.content || ''); @@ -732,33 +622,28 @@ const headline = trimmed.slice(0, 90) + '…'; return `${lines}L · ${headline}`; })(); - // For empty / short results, render as a flat row (no expand). if (!txt.trim() || txt.length <= 120) { - row('tool-result', summary); + api.row('tool-result', summary); } else { - details('tool-result-block', summary, txt); + api.details('tool-result-block', summary, txt); } } - function renderStream(v) { - // Drop session init, claude's result line, rate-limit — they're - // noise. TurnEnd communicates pass/fail; session init data isn't - // actionable. + 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()) row('text', c.text); + 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(); - row('thinking', txt ? '· ' + txt : '· thinking …'); + api.row('thinking', txt ? '· ' + txt : '· thinking …'); } else if (c.type === 'tool_use') { - // Write/Edit get a +/- diff body; send gets a collapsed - //
with the full body text; everything else - // stays as the flat row produced by fmtToolUse. - if (!renderRichToolUse(c)) { - row('tool-use', '→ ' + fmtToolUse(c)); + if (!renderRichToolUse(c, api)) { + api.row('tool-use', '→ ' + fmtToolUse(c)); } } } @@ -766,90 +651,71 @@ } if (v.type === 'user' && v.message && v.message.content) { for (const c of v.message.content) { - if (c.type === 'tool_result') renderToolResult(c); + if (c.type === 'tool_result') renderToolResult(c, api); } return; } - row('sys', '· ' + trim(JSON.stringify(v), 200)); - } - function handle(ev, opts) { - const fromHistory = !!(opts && opts.fromHistory); - if (ev.kind === 'turn_start') { - if (!fromHistory) { setBannerActive(true); setState('thinking'); } - const block = 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); - return; - } - if (ev.kind === 'turn_end') { - if (!fromHistory) { setBannerActive(false); setState('idle'); } - const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail'; - row(cls, (ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : '')); - // Login may have just landed (or session re-enters Online). Pull - // fresh state so the form view reflects it. - if (!fromHistory) refreshState(); - return; - } - if (ev.kind === 'note') { - row('note', '· ' + ev.text); - return; - } - if (ev.kind === 'stream') { - const v = Object.assign({}, ev); delete v.kind; - renderStream(v); - return; - } - row('note', JSON.stringify(ev)); + api.row('sys', '· ' + trim(JSON.stringify(v), 200)); } - // Backfill the last N events before subscribing live. Walk through - // turn_start/turn_end to leave the banner-active counter in the right - // state: if the history's last turn never closed, we *do* want the - // banner shimmer to be on. fromHistory=true on the replay; we apply - // the final activity state in one pass at the end. - async function backfill() { - try { - const resp = await fetch('/events/history'); - if (!resp.ok) return; - const events = await resp.json(); - let openTurns = 0; - currentNoAnim = true; - for (const ev of events) { - handle(ev, { fromHistory: true }); - if (ev.kind === 'turn_start') openTurns += 1; - else if (ev.kind === 'turn_end') openTurns = Math.max(0, openTurns - 1); - } - currentNoAnim = false; - for (let i = 0; i < openTurns; i++) setBannerActive(true); - if (openTurns > 0) setState('thinking'); - if (events.length) row('note', '─── live (older above) ───'); - else setPlaceholder('(connected — waiting for events)'); - } catch (err) { - // Best effort; SSE will catch up. - console.warn('history backfill failed', err); - } - } + // 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; - backfill().then(() => { - const es = new EventSource('/events/stream'); - es.onopen = () => { /* no placeholder — backfill already painted */ }; - es.onmessage = (e) => { - try { handle(JSON.parse(e.data)); } - catch (err) { row('note', '[parse err] ' + e.data); } - }; - es.onerror = () => { - if (es.readyState === EventSource.CONNECTING) row('note', '[reconnecting…]'); - else row('note', '[disconnected]'); - }; + 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. diff --git a/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html index 5ebae41..6180184 100644 --- a/hive-ag3nt/assets/index.html +++ b/hive-ag3nt/assets/index.html @@ -33,6 +33,7 @@
+ diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 6feef41..5eb13fc 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -93,6 +93,7 @@ pub async fn serve( .route("/", get(serve_index)) .route("/static/agent.css", get(serve_css)) .route("/static/app.js", get(serve_app_js)) + .route("/static/hive-fr0nt.js", get(serve_shared_js)) .route("/api/state", get(api_state)) .route("/events/stream", get(events_stream)) .route("/events/history", get(events_history)) @@ -181,6 +182,13 @@ async fn serve_app_js() -> impl IntoResponse { ) } +async fn serve_shared_js() -> impl IntoResponse { + ( + [("content-type", "application/javascript")], + hive_fr0nt::TERMINAL_JS, + ) +} + #[derive(Serialize)] struct StateSnapshot { label: String, diff --git a/hive-fr0nt/assets/terminal.js b/hive-fr0nt/assets/terminal.js new file mode 100644 index 0000000..625af28 --- /dev/null +++ b/hive-fr0nt/assets/terminal.js @@ -0,0 +1,217 @@ +// Shared terminal pane: sticky-bottom log + "↓ N new" pill + history +// backfill + live SSE. Pages provide a kind→renderer map; this module +// owns scroll behaviour, animation suppression on backfill, and the +// EventSource lifecycle. +// +// Usage: +// +// HiveTerminal.create({ +// logEl: document.getElementById('msgflow'), +// historyUrl: '/messages/history?limit=200', // optional +// streamUrl: '/messages/stream', +// renderers: { +// sent: (ev, api) => api.row('msgrow sent', ...), +// delivered: (ev, api) => api.row('msgrow delivered', ...), +// _default: (ev, api) => api.row('note', JSON.stringify(ev)), +// }, +// onLiveEvent: (ev) => { /* side effects: notifications, state pokes */ }, +// onBackfillDone: (count) => { /* one-shot after history replay */ }, +// pillAnchor: document.getElementById('msgflow').parentElement, +// }); +// +// Renderers receive (ev, api) where api exposes: +// +// api.row(cls, text) → appends a flat
+// api.details(cls, summary, body) → appends
+// with a +// api.detailsDiff(cls, summary, body) → ditto but body is line-coloured by +// leading "+ " / "- " prefix +// api.placeholder(text) → replaces log content with a single +// muted "(placeholder)" row, cleared +// on the next real row +// api.fromHistory → true while backfill is replaying +// +// Each kind is dispatched to `renderers[ev.kind]`; unknown kinds fall +// through to `renderers._default` (which itself defaults to a JSON-dump +// note row). The convention is that the SSE/history endpoints emit +// objects with a `kind` field. +// +// Backfill is best-effort: if `historyUrl` is unset or the fetch fails, +// we skip straight to SSE. The optional `onBackfillDone(count)` hook +// fires after replay finishes (or after a failed/skipped fetch with +// count=0); pages use it to set state flags from the replayed history. + +(function () { + const NEAR_BOTTOM_PX = 48; + + function create(opts) { + const log = opts.logEl; + if (!log) throw new Error('HiveTerminal.create: logEl is required'); + const renderers = opts.renderers || {}; + const defaultRender = renderers._default + || ((ev, api) => api.row('note', JSON.stringify(ev))); + const pillAnchor = opts.pillAnchor || log.parentElement || log; + + let placeholderEl = null; + let pill = null; + let unseen = 0; + let currentNoAnim = false; + + function isNearBottom() { + return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX; + } + function ensurePill() { + if (pill) return pill; + pill = document.createElement('button'); + pill.type = 'button'; + pill.className = 'tail-pill'; + pill.addEventListener('click', () => { log.scrollTop = log.scrollHeight; }); + pillAnchor.appendChild(pill); + return pill; + } + function updatePill() { + if (unseen <= 0) { + if (pill) pill.classList.remove('visible'); + return; + } + ensurePill(); + pill.textContent = '↓ ' + unseen + ' new'; + pill.classList.add('visible'); + } + log.addEventListener('scroll', () => { + if (isNearBottom()) { unseen = 0; updatePill(); } + }); + function afterAppend() { + if (currentNoAnim || isNearBottom()) { + log.scrollTop = log.scrollHeight; + } else { + unseen += 1; + updatePill(); + } + } + function clearPlaceholder() { + if (placeholderEl && placeholderEl.parentElement === log) { + log.removeChild(placeholderEl); + } + placeholderEl = null; + } + function placeholder(text) { + clearPlaceholder(); + const e = document.createElement('div'); + e.className = 'row note'; + e.textContent = text; + log.appendChild(e); + placeholderEl = e; + } + function row(cls, text) { + clearPlaceholder(); + const e = document.createElement('div'); + e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); + e.textContent = text; + log.appendChild(e); + afterAppend(); + return e; + } + function details(cls, summary, body) { + clearPlaceholder(); + const d = document.createElement('details'); + d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); + const s = document.createElement('summary'); + s.textContent = summary; + d.appendChild(s); + const pre = document.createElement('pre'); + pre.className = 'tool-body'; + pre.textContent = body; + d.appendChild(pre); + log.appendChild(d); + afterAppend(); + return d; + } + function detailsDiff(cls, summary, body) { + clearPlaceholder(); + const d = document.createElement('details'); + d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); + const s = document.createElement('summary'); + s.textContent = summary; + d.appendChild(s); + const pre = document.createElement('pre'); + pre.className = 'tool-body diff-body'; + for (const line of String(body).split('\n')) { + const span = document.createElement('span'); + if (line.startsWith('+ ')) span.className = 'diff-add'; + else if (line.startsWith('- ')) span.className = 'diff-del'; + else span.className = 'diff-ctx'; + span.textContent = line + '\n'; + pre.appendChild(span); + } + d.appendChild(pre); + log.appendChild(d); + afterAppend(); + return d; + } + + function api(extra) { + return Object.assign({ + row, details, detailsDiff, placeholder, + fromHistory: false, + }, extra || {}); + } + function dispatch(ev, fromHistory) { + const r = renderers[ev.kind] || defaultRender; + try { + r(ev, api({ fromHistory })); + } catch (err) { + console.error('terminal renderer threw', ev, err); + row('note', '[render err] ' + (err && err.message ? err.message : err)); + } + } + + async function backfill() { + if (!opts.historyUrl) { + if (opts.onBackfillDone) opts.onBackfillDone(0); + return; + } + try { + const resp = await fetch(opts.historyUrl); + if (!resp.ok) { + if (opts.onBackfillDone) opts.onBackfillDone(0); + return; + } + const events = await resp.json(); + currentNoAnim = true; + for (const ev of events) dispatch(ev, true); + currentNoAnim = false; + if (events.length) row('note', '─── live (older above) ───'); + else placeholder('(connected — waiting for events)'); + if (opts.onBackfillDone) opts.onBackfillDone(events.length); + } catch (err) { + console.warn('history backfill failed', err); + if (opts.onBackfillDone) opts.onBackfillDone(0); + } + } + + function subscribe() { + const es = new EventSource(opts.streamUrl); + es.onmessage = (e) => { + let ev; + try { ev = JSON.parse(e.data); } + catch (err) { row('note', '[parse err] ' + e.data); return; } + dispatch(ev, false); + if (opts.onLiveEvent) { + try { opts.onLiveEvent(ev); } + catch (err) { console.error('onLiveEvent threw', err); } + } + }; + es.onerror = () => { + if (es.readyState === EventSource.CONNECTING) row('note', '[reconnecting…]'); + else row('note', '[disconnected]'); + }; + return es; + } + + const ready = backfill().then(subscribe); + return { row, details, detailsDiff, placeholder, ready }; + } + + window.HiveTerminal = { create }; +})(); diff --git a/hive-fr0nt/src/lib.rs b/hive-fr0nt/src/lib.rs index b66ad51..8bec6aa 100644 --- a/hive-fr0nt/src/lib.rs +++ b/hive-fr0nt/src/lib.rs @@ -31,3 +31,4 @@ pub const BASE_CSS: &str = include_str!("../assets/base.css"); pub const TERMINAL_CSS: &str = include_str!("../assets/terminal.css"); +pub const TERMINAL_JS: &str = include_str!("../assets/terminal.js");