agent: route terminal scroll+backfill+SSE through hive-fr0nt::TERMINAL_JS
This commit is contained in:
parent
0b9e7cbcf6
commit
f27108aecf
5 changed files with 308 additions and 215 deletions
|
|
@ -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 <details>, 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 <details>, 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
|
||||
// <details> 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.
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
<div id="term-input" class="term-input"></div>
|
||||
</div>
|
||||
|
||||
<script src="/static/hive-fr0nt.js" defer></script>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue