1048 lines
44 KiB
JavaScript
1048 lines
44 KiB
JavaScript
// 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 = '<span class="spinner">◐</span>'; }
|
|
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 <code>~/.claude/</code>. 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 <code>claude auth login</code> 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;
|
|
// Label captured from the first /api/state cold load — used by the
|
|
// bus-driven `status_changed` handler so it can re-enable the
|
|
// composer without waiting for the next snapshot fetch.
|
|
let currentLabel = '';
|
|
|
|
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 <name> — 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 : ''));
|
|
}
|
|
// No refreshState — the harness emits `model_changed` on the
|
|
// SSE bus and the chip handler picks it up live.
|
|
} 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();
|
|
}
|
|
// Loose-ends section: same data the get_loose_ends MCP tool
|
|
// returns. Best-effort fetch on cold load + after every turn_end
|
|
// (a turn likely answered or asked something). Silent failure
|
|
// keeps the section hidden rather than surfacing an empty banner.
|
|
let lastLooseEndsCount = 0;
|
|
async function refreshLooseEnds() {
|
|
try {
|
|
const resp = await fetch('/api/loose-ends');
|
|
if (!resp.ok) {
|
|
renderLooseEnds([]);
|
|
return;
|
|
}
|
|
const data = await resp.json();
|
|
renderLooseEnds(data.loose_ends || []);
|
|
} catch (err) {
|
|
console.warn('loose-ends fetch failed', err);
|
|
renderLooseEnds([]);
|
|
}
|
|
}
|
|
function renderLooseEnds(threads) {
|
|
const root = $('loose-ends-section');
|
|
const list = $('loose-ends-list');
|
|
const summary = $('loose-ends-summary');
|
|
if (!root || !list || !summary) return;
|
|
if (!threads.length) {
|
|
root.hidden = true;
|
|
lastLooseEndsCount = 0;
|
|
return;
|
|
}
|
|
root.hidden = false;
|
|
summary.textContent = 'loose ends · ' + threads.length;
|
|
list.innerHTML = '';
|
|
// Auto-expand on first appearance of any open thread so the
|
|
// operator notices new loose ends; collapse only on operator
|
|
// click (sticky after that).
|
|
if (lastLooseEndsCount === 0) root.open = true;
|
|
lastLooseEndsCount = threads.length;
|
|
const fmtAge = (s) => {
|
|
if (s < 60) return s + 's';
|
|
if (s < 3600) return Math.floor(s / 60) + 'm';
|
|
if (s < 86400) return Math.floor(s / 3600) + 'h';
|
|
return Math.floor(s / 86400) + 'd';
|
|
};
|
|
for (const t of threads) {
|
|
const li = el('li');
|
|
if (t.kind === 'approval') {
|
|
li.append(
|
|
el('span', { class: 'inbox-from' }, '◇ approval #' + t.id), ' ',
|
|
el('span', { class: 'inbox-sep' }, t.agent + ' @ ' + (t.commit_ref || '').slice(0, 12)), ' ',
|
|
el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'),
|
|
);
|
|
if (t.description) {
|
|
li.append(el('div', { class: 'inbox-body' }, t.description));
|
|
}
|
|
} else if (t.kind === 'question') {
|
|
const target = t.target || 'operator';
|
|
li.append(
|
|
el('span', { class: 'inbox-from' }, '? #' + t.id), ' ',
|
|
el('span', { class: 'inbox-sep' }, t.asker + ' → ' + target), ' ',
|
|
el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'),
|
|
el('div', { class: 'inbox-body' }, t.question || ''),
|
|
);
|
|
} else if (t.kind === 'reminder') {
|
|
// due_at is an absolute unix-seconds value; show time-until-fire
|
|
// (negative when overdue, fmtAge handles 0/positive case here).
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const dueIn = (t.due_at || 0) - now;
|
|
const dueLabel = dueIn >= 0 ? 'in ' + fmtAge(dueIn) : fmtAge(-dueIn) + ' overdue';
|
|
li.append(
|
|
el('span', { class: 'inbox-from' }, '⏰ reminder #' + t.id), ' ',
|
|
el('span', { class: 'inbox-sep' }, t.owner + ' · due ' + dueLabel), ' ',
|
|
el('span', { class: 'inbox-ts' }, 'scheduled ' + fmtAge(t.age_seconds || 0) + ' ago'),
|
|
el('div', { class: 'inbox-body' }, t.message || ''),
|
|
);
|
|
} else {
|
|
li.append(el('span', { class: 'inbox-body' }, JSON.stringify(t)));
|
|
}
|
|
list.append(li);
|
|
}
|
|
}
|
|
|
|
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; }
|
|
currentLabel = s.label;
|
|
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);
|
|
// Open-threads aren't part of /api/state (kept on the broker
|
|
// db, fetched via the per-agent socket). Cold-load fetches
|
|
// it here; turn_end refreshes it via the renderer below.
|
|
refreshLooseEnds();
|
|
// Skip the re-render if nothing structurally changed. The most
|
|
// common case is `online` polling itself — without this guard, the
|
|
// operator's <input value> 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; }
|
|
// Render a message body as markdown into a new <div class="md">.
|
|
// Wraps `marked.parse` so the per-row body element carries the
|
|
// `.md` class (CSS in TERMINAL_CSS scopes paragraph/code/list
|
|
// styles to it). Falls back to a plain text node if marked isn't
|
|
// loaded (network glitch, asset 404) so the body still renders.
|
|
function mdNode(text) {
|
|
const div = document.createElement('div');
|
|
div.className = 'md';
|
|
const src = String(text || '');
|
|
if (window.marked && typeof window.marked.parse === 'function') {
|
|
try {
|
|
marked.setOptions({ breaks: true, gfm: true });
|
|
div.innerHTML = marked.parse(src);
|
|
} catch (err) {
|
|
console.warn('marked failed', err);
|
|
div.textContent = src;
|
|
}
|
|
} else {
|
|
div.textContent = src;
|
|
}
|
|
return div;
|
|
}
|
|
// Build a default-open details row whose body is markdown-rendered.
|
|
// Used by send / ask / answer tool_use renderers and by `recv`
|
|
// tool_result so message-bearing rows show their content inline
|
|
// without an extra click.
|
|
function detailsOpenMd(api, cls, summary, body) {
|
|
const d = api.details(cls, summary, '');
|
|
d.open = true;
|
|
const pre = d.querySelector('pre.tool-body');
|
|
if (pre) {
|
|
pre.replaceWith(mdNode(body));
|
|
} else {
|
|
d.appendChild(mdNode(body));
|
|
}
|
|
return d;
|
|
}
|
|
// Generic args-pretty-printer for unknown / extra-MCP tools. The
|
|
// built-in switch handles the common claude/hyperhive tools; this
|
|
// is the fallback so an `mcp__matrix__send_message` or similar
|
|
// doesn't dump raw JSON. Heuristics: single string-valued field →
|
|
// `Name field: "value"`; single dict-valued field → `Name field
|
|
// {…}`; otherwise compact JSON. Always trimmed to fit a row.
|
|
function fmtArgsGeneric(name, input) {
|
|
const keys = Object.keys(input || {});
|
|
if (keys.length === 0) return name + '()';
|
|
if (keys.length === 1) {
|
|
const k = keys[0];
|
|
const v = input[k];
|
|
if (typeof v === 'string') {
|
|
const oneline = v.replace(/\s+/g, ' ').trim();
|
|
return name + ' ' + k + ': ' + JSON.stringify(trim(oneline, 100));
|
|
}
|
|
if (typeof v === 'number' || typeof v === 'boolean') {
|
|
return name + ' ' + k + ': ' + JSON.stringify(v);
|
|
}
|
|
}
|
|
// Multi-field: render `k: v` pairs with strings/numbers inlined and
|
|
// anything else summarised by type so the row stays readable.
|
|
const pretty = keys.slice(0, 4).map((k) => {
|
|
const v = input[k];
|
|
if (v == null) return k + ': null';
|
|
if (typeof v === 'string') {
|
|
const oneline = v.replace(/\s+/g, ' ').trim();
|
|
return k + ': ' + JSON.stringify(trim(oneline, 40));
|
|
}
|
|
if (typeof v === 'number' || typeof v === 'boolean') return k + ': ' + v;
|
|
if (Array.isArray(v)) return k + `: [${v.length}]`;
|
|
return k + ': {…}';
|
|
});
|
|
const tail = keys.length > 4 ? ' …+' + (keys.length - 4) : '';
|
|
return name + ' ' + pretty.join(' · ') + tail;
|
|
}
|
|
// 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 fmtArgsGeneric(short, input);
|
|
}
|
|
}
|
|
// 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.
|
|
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');
|
|
}
|
|
// Summaries on expandable rows omit the row's directional glyph
|
|
// (`→`) — the disclosure marker (`▸/▾`) from CSS sits in the
|
|
// prefix column for every row kind, and the row's cyan colour
|
|
// already signals "outbound tool".
|
|
const summary = name + ' ' + path + ' · '
|
|
+ (minus ? '-' + minus + ' ' : '') + '+' + plus;
|
|
return api.detailsDiff('tool-use', summary, body);
|
|
}
|
|
// Message-bearing tools render default-open with a markdown body so
|
|
// the operator sees the content without an extra click. send / ask
|
|
// address a target; answer attaches to an existing question id.
|
|
if (name === 'mcp__hyperhive__send') {
|
|
const to = input.to || '?';
|
|
const body = String(input.body || '');
|
|
const lines = body.split('\n').length;
|
|
return detailsOpenMd(api, 'tool-use',
|
|
'send → ' + to + (lines > 1 ? ` · ${lines}L` : ''),
|
|
body);
|
|
}
|
|
if (name === 'mcp__hyperhive__ask') {
|
|
const to = input.to || 'operator';
|
|
const q = String(input.question || '');
|
|
const lines = q.split('\n').length;
|
|
return detailsOpenMd(api, 'tool-use',
|
|
'ask → ' + to + (lines > 1 ? ` · ${lines}L` : ''),
|
|
q);
|
|
}
|
|
if (name === 'mcp__hyperhive__answer') {
|
|
const id = input.id != null ? String(input.id) : '?';
|
|
const a = String(input.answer || '');
|
|
const lines = a.split('\n').length;
|
|
return detailsOpenMd(api, 'tool-use',
|
|
'answer #' + id + (lines > 1 ? ` · ${lines}L` : ''),
|
|
a);
|
|
}
|
|
return null;
|
|
}
|
|
// Track tool_use_id → tool name so we can decide on rendering when the
|
|
// matching tool_result lands later. Lets us default-open the body for
|
|
// message-bearing tools (`recv`) while keeping shell/file tool output
|
|
// collapsed unless the operator clicks. Cleared on /clear; otherwise
|
|
// grows with the session — entries are tiny strings.
|
|
const toolNameById = new Map();
|
|
function renderToolResult(c, api) {
|
|
const txt = Array.isArray(c.content)
|
|
? c.content.map(p => p.text || '').join('')
|
|
: (c.content || '');
|
|
const sourceName = c.tool_use_id ? toolNameById.get(c.tool_use_id) : null;
|
|
const isMessageBearing = sourceName === 'mcp__hyperhive__recv';
|
|
const trimmed = txt.replace(/\s+/g, ' ').trim();
|
|
const summaryBody = (() => {
|
|
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}`;
|
|
})();
|
|
// Flat row: keep the `←` glyph in the prefix column. Details rows
|
|
// drop it — the `▸/▾` disclosure marker sits in that column via CSS.
|
|
if (isMessageBearing && txt.trim()) {
|
|
return detailsOpenMd(api, 'tool-result-block',
|
|
'recv ← ' + summaryBody, txt);
|
|
}
|
|
if (!txt.trim() || txt.length <= 120) {
|
|
api.row('tool-result', '← ' + summaryBody);
|
|
} else {
|
|
api.details('tool-result-block', summaryBody, txt);
|
|
}
|
|
}
|
|
// Pretty-render claude's background-task subagent events
|
|
// (`task_started`, `task_notification`). They share the same
|
|
// task_id so the operator can correlate start ↔ result; render
|
|
// each as a peer of tool_use / tool_result with a `⌁` glyph to
|
|
// mark "this happened in a subagent" rather than the main
|
|
// session.
|
|
function renderTaskEvent(v, api) {
|
|
const id = (v.task_id || '').slice(0, 8);
|
|
const kind = v.task_type ? ` [${v.task_type}]` : '';
|
|
const desc = v.description || v.summary || '(no description)';
|
|
if (v.subtype === 'task_started') {
|
|
api.row('tool-use', `⌁ task ${id} started · ${desc}${kind}`);
|
|
return true;
|
|
}
|
|
if (v.subtype === 'task_notification') {
|
|
const status = v.status || 'unknown';
|
|
const glyph = status === 'completed' ? '✓' : status === 'failed' ? '✗' : '◌';
|
|
const cls = status === 'completed' ? 'turn-end-ok'
|
|
: status === 'failed' ? 'turn-end-fail'
|
|
: 'tool-result';
|
|
const out = v.output_file ? ` · → ${v.output_file}` : '';
|
|
api.row(cls, `⌁ task ${id} ${glyph} ${status} · ${desc}${out}`);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
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;
|
|
// Background-task subagent events (claude's `Task` tool spawns
|
|
// a separate session whose progress lands here as `task_*`
|
|
// subtypes). Match by subtype so we don't have to track which
|
|
// top-level `type` claude wraps them under across versions.
|
|
if (v.subtype === 'task_started' || v.subtype === 'task_notification') {
|
|
if (renderTaskEvent(v, api)) 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()) {
|
|
// Assistant prose renders with markdown — claude often
|
|
// emits bullets / fenced code / inline code; raw text
|
|
// loses the structure.
|
|
const row = api.row('text', '');
|
|
row.appendChild(mdNode(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 (c.id && c.name) toolNameById.set(c.id, c.name);
|
|
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;
|
|
}
|
|
// Catch-all for unrecognised stream-json shapes. Loud (orange) so
|
|
// silently-dropped event types surface in the scrollback for
|
|
// follow-up classification.
|
|
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');
|
|
// Likely answered/asked/scheduled something — refresh.
|
|
refreshLooseEnds();
|
|
}
|
|
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) {
|
|
const t = String(ev.text || '');
|
|
// stderr lines coming off the claude pump get an orange `!`
|
|
// glyph so they're not visually fused with ambient harness
|
|
// chatter. Operator-initiated notes (/cancel, /compact,
|
|
// /model, new-session) get a mauve italic affordance so the
|
|
// scrollback distinguishes "the operator did this" from
|
|
// "the harness did this on its own."
|
|
if (t.startsWith('stderr:')) {
|
|
api.row('note stderr', '! ' + t);
|
|
} else if (t.startsWith('operator:')) {
|
|
api.row('note op', '· ' + t);
|
|
} else {
|
|
api.row('note', '· ' + t);
|
|
}
|
|
},
|
|
stream(ev, api) {
|
|
const v = Object.assign({}, ev); delete v.kind;
|
|
renderStream(v, api);
|
|
},
|
|
// Bus-driven state/badges. `status_changed` may also need a
|
|
// /api/state refresh to render the login `#status` block
|
|
// (which carries the OAuth URL + form), so we kick the
|
|
// existing refresh path on that transition. Online → only
|
|
// the badge updates; no /api/state fetch needed.
|
|
status_changed(ev, api) {
|
|
if (api.fromHistory) return;
|
|
renderAliveBadge(ev.status);
|
|
renderTermInput(currentLabel, ev.status === 'online');
|
|
// Login-flow transitions need the #status block rebuilt
|
|
// (it carries the OAuth URL + form). The existing
|
|
// refreshState path also re-arms the in-progress poll for
|
|
// session output streaming. Online → only the badge moves;
|
|
// no /api/state fetch is necessary.
|
|
if (ev.status !== 'online' && ev.status !== lastStatus) {
|
|
refreshState();
|
|
} else if (ev.status === 'online' && lastStatus !== 'online') {
|
|
// Status block stays as-is or shows the previous
|
|
// login UI; clear it so the operator sees a clean
|
|
// online state without a separate refetch.
|
|
const root = $('status');
|
|
if (root) root.innerHTML = '';
|
|
lastStatus = 'online';
|
|
}
|
|
},
|
|
model_changed(ev, api) { if (!api.fromHistory) renderModelChip(ev.model); },
|
|
token_usage_changed(ev, api) {
|
|
if (!api.fromHistory) renderTokenUsage(ev.usage);
|
|
},
|
|
turn_state_changed(ev, api) {
|
|
if (!api.fromHistory) setStateAbs(ev.state, ev.since_unix);
|
|
},
|
|
},
|
|
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;
|
|
})();
|