From d943bddd9ee89a96dc0ca15b9dfd6ffb75be5a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 18:54:19 +0200 Subject: [PATCH] agent ui: input lives in terminal section, banner shimmer on activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent page restructure: - send form moves into the terminal panel as a prompt-style row beneath the live tail (status line stays above so it still reads as a header). - live panel + prompt share a single bordered 'terminal-wrap' box. - harness-alive / login-state status lines drop their decorative ascii bookends; just a leading dot/glyph remains. - banner gradient is now a real css gradient with a shimmer animation toggled by an .active class. turn_start adds it, turn_end removes it. dashboard side mirrors this: each broker sse event nudges a 4s shimmer window. - dashboard container rows drop their static ▓█▓▒░ / ▒░▒░░ glyph prefixes; the role chips already disambiguate m1nd vs ag3nt. - empty-state placeholders drop the ▓ bookends. terminal pre-fill: hive-ag3nt::events::Bus grows a 500-event ring buffer; new GET /events/history endpoint returns it. The agent JS fetches history before opening the SSE stream so opening the page mid- turn shows the last N events instead of a blank panel. The replay walks turn_start/turn_end pairs to seed the banner-active state correctly if a turn was still open. --- hive-ag3nt/assets/agent.css | 78 +++++++++++++++++---- hive-ag3nt/assets/app.js | 121 +++++++++++++++++++++++++-------- hive-ag3nt/assets/index.html | 9 +-- hive-ag3nt/src/events.rs | 27 +++++++- hive-ag3nt/src/web_ui.rs | 7 ++ hive-c0re/assets/app.js | 22 ++++-- hive-c0re/assets/dashboard.css | 21 +++++- 7 files changed, 227 insertions(+), 58 deletions(-) diff --git a/hive-ag3nt/assets/agent.css b/hive-ag3nt/assets/agent.css index 47882d3..9cc0eb0 100644 --- a/hive-ag3nt/assets/agent.css +++ b/hive-ag3nt/assets/agent.css @@ -21,12 +21,29 @@ body { line-height: 1.6; } .banner { - color: var(--purple); text-align: center; margin: 0 0 1em 0; font-size: 0.95em; - text-shadow: 0 0 6px rgba(203, 166, 247, 0.55), 0 0 14px rgba(203, 166, 247, 0.25); overflow-x: auto; + background: linear-gradient( + 90deg, + var(--purple-dim) 0%, + var(--purple) 50%, + var(--purple-dim) 100% + ); + background-size: 200% 100%; + background-position: 50% 0; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + filter: drop-shadow(0 0 6px rgba(203, 166, 247, 0.45)); +} +.banner.active { + animation: banner-shimmer 1.8s linear infinite; +} +@keyframes banner-shimmer { + from { background-position: 200% 0; } + to { background-position: -100% 0; } } h2, h3 { color: var(--purple); @@ -34,12 +51,6 @@ h2, h3 { letter-spacing: 0.15em; text-shadow: 0 0 8px rgba(203, 166, 247, 0.4); } -.divider { - color: var(--purple-dim); - overflow: hidden; - white-space: nowrap; - margin-bottom: 0.5em; -} .meta { color: var(--muted); font-size: 0.85em; } .status-online { color: var(--green); text-shadow: 0 0 6px rgba(166, 227, 161, 0.55); } .status-needs-login { color: var(--amber); text-shadow: 0 0 6px rgba(250, 179, 135, 0.55); } @@ -113,20 +124,59 @@ pre.diff { word-break: break-all; max-height: 30em; } -/* Terminal-ish look for the live panel. Crust as bg (almost-black), - slightly inset, mauve phosphor glow. */ -.live.terminal { +/* Terminal-ish wrapper holding the live output + prompt input as one + unit. Crust as bg (almost-black), slightly inset, mauve phosphor glow. */ +.terminal-wrap { background: #11111b; border: 1px solid var(--purple-dim); box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.7); border-radius: 4px; - padding: 0.8em 1em; - overflow-y: auto; - max-height: 32em; font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace; font-size: 0.92em; color: #cdd6f4; + margin-top: 0.6em; } +.live.terminal { + background: transparent; + border: 0; + box-shadow: none; + border-radius: 0; + padding: 0.8em 1em 0.4em; + overflow-y: auto; + max-height: 32em; + font-family: inherit; + font-size: inherit; + color: inherit; +} +.term-input { padding: 0.4em 1em 0.8em; } +.term-input .sendform-term { + display: flex; + align-items: center; + gap: 0.5em; + border-top: 1px dashed var(--purple-dim); + padding-top: 0.5em; +} +.term-input .prompt { + color: var(--green); + text-shadow: 0 0 6px rgba(166, 227, 161, 0.6); + user-select: none; + flex: 0 0 auto; +} +.term-input input { + flex: 1; + background: transparent; + border: 0; + outline: 0; + color: var(--fg); + font-family: inherit; + font-size: 1em; + padding: 0.2em 0; + caret-color: var(--green); +} +.term-input input::placeholder { color: var(--muted); } +.term-input .submit-hint { color: var(--muted); font-size: 0.8em; flex: 0 0 auto; } +.term-input.disabled .prompt { color: var(--muted); text-shadow: none; } +.term-input.disabled input { color: var(--muted); } .live { background: rgba(255, 255, 255, 0.02); border: 1px solid var(--purple-dim); diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index 3ae251c..cb89cbd 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -82,29 +82,15 @@ document.title = `${label} // hyperhive`; } - function renderOnline(label, root) { + function renderOnline(_label, root) { root.append( - el('p', { class: 'status-online' }, '▓█▓▒░ harness alive — turn loop running ▓█▓▒░'), + el('p', { class: 'status-online' }, '● harness alive — turn loop running'), ); - const form = el('form', { - action: '/send', method: 'POST', class: 'sendform', 'data-async': '', - }); - form.append( - el('input', { - name: 'body', placeholder: `message ${label} as operator…`, - required: '', autocomplete: 'off', - }), - el('button', { type: 'submit', class: 'btn btn-send' }, '◆ S3ND'), - ); - root.append(form); - root.append(el('p', { class: 'meta', html: - 'enqueued with from: operator on this agent\'s inbox; the next turn picks it up.', - })); } function renderNeedsLoginIdle(root) { root.append( - el('p', { class: 'status-needs-login' }, '▓█▓▒░ NEEDS L0G1N ▓█▓▒░'), + 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.', }), @@ -122,7 +108,7 @@ } function renderLoginInProgress(s, root) { - root.append(el('p', { class: 'status-needs-login' }, '▓█▓▒░ L0G1N 1N PR0GRESS ▓█▓▒░')); + 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', @@ -168,12 +154,56 @@ let lastStatus = null; let lastOutputLen = -1; let pollTimer = null; + let termInputRendered = false; + + 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': '', + }); + form.append( + el('span', { class: 'prompt' }, 'operator@' + label + ' ▸'), + el('input', { + name: 'body', placeholder: 'message ' + label + '…', + required: '', autocomplete: 'off', + }), + el('span', { class: 'submit-hint' }, 'enter ↵'), + ); + slot.append(form); + termInputRendered = true; + } + slot.classList.toggle('disabled', !online); + const input = slot.querySelector('input'); + if (input) input.disabled = !online; + } + + // 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'); // 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. @@ -315,8 +345,10 @@ } row('sys', '· ' + trim(JSON.stringify(v), 200)); } - function handle(ev) { + function handle(ev, opts) { + const fromHistory = !!(opts && opts.fromHistory); if (ev.kind === 'turn_start') { + if (!fromHistory) setBannerActive(true); const block = row('turn-start', '◆ TURN ← ' + ev.from); if (ev.unread > 0) { const badge = document.createElement('span'); @@ -331,11 +363,12 @@ return; } if (ev.kind === 'turn_end') { + if (!fromHistory) setBannerActive(false); 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. - refreshState(); + if (!fromHistory) refreshState(); return; } if (ev.kind === 'note') { @@ -349,16 +382,44 @@ } row('note', JSON.stringify(ev)); } - const es = new EventSource('/events/stream'); - es.onopen = () => setPlaceholder('(connected — waiting for events)'); - es.onmessage = (e) => { - try { handle(JSON.parse(e.data)); } - catch (err) { row('note', '[parse err] ' + e.data); } - }; - es.onerror = () => { - if (es.readyState === EventSource.CONNECTING) setPlaceholder('(reconnecting…)'); - else row('note', '[disconnected]'); - }; + + // 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; + 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); + } + for (let i = 0; i < openTurns; i++) setBannerActive(true); + 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); + } + } + + 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]'); + }; + }); })(); // 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 bfcdd46..7e3613e 100644 --- a/hive-ag3nt/assets/index.html +++ b/hive-ag3nt/assets/index.html @@ -8,15 +8,16 @@

◆ … ◆

-
══════════════════════════════════════════════════════════════
- -

live

-
connecting…

loading…

+
+
connecting…
+
+
+ diff --git a/hive-ag3nt/src/events.rs b/hive-ag3nt/src/events.rs index 54e5888..fb95223 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -8,12 +8,17 @@ //! future events; the dashboard JS deals with the cold-start case by //! showing "connecting…" until the first event arrives. -use std::sync::Arc; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; const CHANNEL_CAPACITY: usize = 256; +/// Max `LiveEvent`s the `Bus` keeps in its ring buffer. The web UI fetches +/// this on page load to backfill the terminal so the operator sees the +/// last turn(s) without having to wait for the next one. +const HISTORY_CAPACITY: usize = 500; /// One row of the agent's live stream. Serialised to JSON for SSE delivery. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -43,16 +48,27 @@ pub enum LiveEvent { #[derive(Clone)] pub struct Bus { tx: Arc>, + history: Arc>>, } impl Bus { #[must_use] pub fn new() -> Self { let (tx, _) = broadcast::channel(CHANNEL_CAPACITY); - Self { tx: Arc::new(tx) } + Self { + tx: Arc::new(tx), + history: Arc::new(Mutex::new(VecDeque::with_capacity(HISTORY_CAPACITY))), + } } pub fn emit(&self, event: LiveEvent) { + { + let mut h = self.history.lock().unwrap(); + if h.len() == HISTORY_CAPACITY { + h.pop_front(); + } + h.push_back(event.clone()); + } // Lagged subscribers drop events — fine; the UI is a tail, not a log. let _ = self.tx.send(event); } @@ -60,6 +76,13 @@ impl Bus { pub fn subscribe(&self) -> broadcast::Receiver { self.tx.subscribe() } + + /// Snapshot of the in-memory event ring buffer, oldest first. Drives the + /// terminal pre-fill when the operator opens the agent page. + #[must_use] + pub fn history(&self) -> Vec { + self.history.lock().unwrap().iter().cloned().collect() + } } impl Default for Bus { diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index edd4851..4e5c120 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -74,6 +74,7 @@ pub async fn serve( .route("/static/app.js", get(serve_app_js)) .route("/api/state", get(api_state)) .route("/events/stream", get(events_stream)) + .route("/events/history", get(events_history)) .route("/send", post(post_send)) .route("/login/start", post(post_login_start)) .route("/login/code", post(post_login_code)) @@ -206,6 +207,12 @@ async fn post_send(State(state): State, Form(form): Form) -> } } +async fn events_history( + State(state): State, +) -> axum::Json> { + axum::Json(state.bus.history()) +} + async fn events_stream( State(state): State, ) -> Sse>> { diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index a8d59f0..d16f3b8 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -114,7 +114,7 @@ } if (!s.containers.length && !s.transients.length) { - root.append(el('p', { class: 'empty' }, '▓ no managed containers ▓')); + root.append(el('p', { class: 'empty' }, 'no managed containers')); return; } @@ -123,8 +123,6 @@ const url = `http://${s.hostname}:${c.port}/`; const li = el('li'); li.append( - el('span', { class: 'glyph' }, c.is_manager ? '▓█▓▒░' : '▒░▒░░'), - ' ', el('a', { href: url }, c.name), ' ', el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' }, @@ -180,7 +178,7 @@ const root = $('questions-section'); root.innerHTML = ''; if (!s.questions || !s.questions.length) { - root.append(el('p', { class: 'empty' }, '▓ no pending questions ▓')); + root.append(el('p', { class: 'empty' }, 'no pending questions')); return; } const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); @@ -223,7 +221,7 @@ const root = $('inbox-section'); root.innerHTML = ''; if (!s.operator_inbox || !s.operator_inbox.length) { - root.append(el('p', { class: 'empty' }, '▓ no messages ▓')); + root.append(el('p', { class: 'empty' }, 'no messages')); return; } const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); @@ -245,7 +243,7 @@ const root = $('approvals-section'); root.innerHTML = ''; if (!s.approvals.length) { - root.append(el('p', { class: 'empty' }, '▓ queue empty ▓')); + root.append(el('p', { class: 'empty' }, 'queue empty')); return; } const ul = el('ul', { class: 'approvals' }); @@ -321,9 +319,21 @@ const es = new EventSource('/messages/stream'); const MAX_ROWS = 200; const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19); + // Animate the banner whenever a broker event lands. Each event nudges + // the shimmer window; if traffic stops, the class falls off after the + // grace timer. + const banner = document.querySelector('.banner'); + let bannerOffTimer = null; + function pulseBanner() { + if (!banner) return; + banner.classList.add('active'); + if (bannerOffTimer) clearTimeout(bannerOffTimer); + bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000); + } es.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } + pulseBanner(); // Live-update the inbox when claude sends to operator. if (m.kind === 'sent' && m.to === 'operator') refreshState(); const row = document.createElement('div'); diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 4d8e4b5..3036c91 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -24,12 +24,29 @@ body { line-height: 1.6; } .banner { - color: var(--purple); text-align: center; margin: 0 0 1em 0; font-size: 0.95em; - text-shadow: 0 0 6px rgba(203, 166, 247, 0.55), 0 0 14px rgba(203, 166, 247, 0.25); overflow-x: auto; + background: linear-gradient( + 90deg, + var(--purple-dim) 0%, + var(--purple) 50%, + var(--purple-dim) 100% + ); + background-size: 200% 100%; + background-position: 50% 0; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + filter: drop-shadow(0 0 6px rgba(203, 166, 247, 0.45)); +} +.banner.active { + animation: banner-shimmer 1.8s linear infinite; +} +@keyframes banner-shimmer { + from { background-position: 200% 0; } + to { background-position: -100% 0; } } h1, h2 { color: var(--purple);