'use strict' const $ = (id) => document.getElementById(id) const state = { data: null, logSeeded: false, lastSeq: 0, modalIteration: null, } const fmt = (n) => Number(n || 0).toLocaleString('en-US') const fmtCost = (n) => { const v = Number(n || 0) if (!v) return '$0.0000' return v < 0.01 ? `$${v.toFixed(5)}` : `$${v.toFixed(4)}` } const fmtDur = (ms) => { const s = Math.max(0, Math.round((ms || 0) / 1000)) const m = Math.floor(s / 60) const h = Math.floor(m / 60) if (h) return `${h}h ${String(m % 60).padStart(2, '0')}m` return `${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` } const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]) // ---------- stats ---------- function renderStats(s) { const totals = s.totals || { tokens: {}, cost: 0, durationMs: 0 } const done = s.iterations.length const last = s.iterations[s.iterations.length - 1] const percent = last?.percent ?? 0 const context = last?.contextSize ?? 0 const statuses = s.iterations.map((i) => i.status) const cards = [ { k: 'Progress', v: `${percent}%`, bar: percent, }, { k: 'Iteration', v: `${done} / ${s.configuredIterations ?? s.iterations.length}` }, { k: 'Status', v: `${esc(s.status)}` }, { k: 'Tokens', v: `${fmt(totals.tokens?.total)} in ${fmt(totals.tokens?.input)} · out ${fmt(totals.tokens?.output)}` }, { k: 'Cost', v: fmtCost(totals.cost) }, { k: 'Context', v: `${fmt(context)} tokens`, }, ] $('stats').innerHTML = cards .map( (c) => `
${c.k}
${c.v}
${ c.bar !== undefined ? `
` : '' }
`, ) .join('') void statuses } // ---------- iterations ---------- function renderIterations(s) { const box = $('iterations') if (!s.iterations.length) { box.innerHTML = '
No iterations yet.
' return } box.innerHTML = s.iterations .map((it) => { const pct = it.percent ?? 0 const status = it.status || 'pending' const questions = (it.questions || []).length ? `` : '' const files = (it.filesChanged || []).length ? `
${it.filesChanged.slice(0, 8).map((f) => `${esc(f)}`).join('')}${ it.filesChanged.length > 8 ? `+${it.filesChanged.length - 8}` : '' }
` : '' return `
#${it.index} ${esc(it.topic || 'running…')} ${esc(status)}
${fmt(it.tokens?.total)} tok ${fmtCost(it.cost)} ctx ${fmt(it.contextSize)} ${fmtDur(it.durationMs)} ${it.toolCount ? `${it.toolCount} tools` : ''}
Stage: ${esc(it.stageTitle || '—')} [${esc(it.stageStatus || 'n/a')}]
Next: ${esc(it.nextStage || '—')}
${it.summary ? `
${esc(it.summary)}
` : ''} ${files} ${questions}
` }) .join('') void box } // ---------- log ---------- function logNode(entry) { const div = document.createElement('div') div.className = `line ${entry.level || 'info'}` const time = new Date(entry.t).toLocaleTimeString('en-US', { hour12: false }) const prefix = entry.level === 'section' ? '' : `${time}` div.innerHTML = prefix + esc(entry.text) return div } function appendLog(entry) { if (!entry || entry.seq <= state.lastSeq) return state.lastSeq = entry.seq const log = $('log') const nearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60 log.appendChild(logNode(entry)) while (log.childElementCount > 2000) log.removeChild(log.firstChild) if (nearBottom || entry.level === 'section') log.scrollTop = log.scrollHeight } function seedLog(entries) { const log = $('log') log.innerHTML = '' state.lastSeq = 0 for (const e of entries || []) { state.lastSeq = Math.max(state.lastSeq, e.seq || 0) log.appendChild(logNode(e)) } log.scrollTop = log.scrollHeight } // ---------- question modal ---------- function renderModal(s) { const modal = $('modal') const pq = s.pendingQuestion if (!pq) { modal.classList.add('hidden') state.modalIteration = null return } if (state.modalIteration === pq.iteration) return state.modalIteration = pq.iteration const questions = pq.questions.length ? pq.questions : [{ id: 'q1', text: 'Any corrections before the next iteration?', options: [] }] $('modal-sub').textContent = `Iteration ${pq.iteration} finished. Answers are injected into the next session.` $('modal-questions').innerHTML = questions .map( (q, i) => `
${ q.options?.length ? `
${q.options .map((o) => ``) .join('')}
` : '' }
`, ) .join('') modal.classList.remove('hidden') const first = $('modal-questions').querySelector('input') if (first) setTimeout(() => first.focus(), 30) $('modal-questions') .querySelectorAll('.opt') .forEach((btn) => { btn.addEventListener('click', () => { const input = btn.closest('.q').querySelector('input') input.value = btn.dataset.val input.focus() }) }) } async function submitModal(skip) { const questions = [...$('modal-questions').querySelectorAll('.q')] const answers = questions.map((q) => ({ question: q.querySelector('label')?.textContent || '', answer: skip ? '' : q.querySelector('input')?.value?.trim() || '', })) await fetch('/api/answer', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ answers }), }) $('modal').classList.add('hidden') state.modalIteration = null } // ---------- controls ---------- async function control(action) { try { await fetch('/api/control', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }), }) } catch { /* ignore */ } } // ---------- state ---------- function render(s) { state.data = s const pauseBtn = $('btn-pause') const paused = s.status === 'paused' || s.pauseRequested pauseBtn.textContent = paused ? 'Resume' : 'Pause' pauseBtn.disabled = ['done', 'done-with-errors', 'aborted', 'error'].includes(s.status) $('btn-abort').textContent = s.abortRequested ? 'Abort armed ✓' : 'Abort after session' $('btn-abort').classList.toggle('primary', Boolean(s.abortRequested)) const pill = $('status-pill') pill.textContent = s.status pill.className = `status ${s.status}` const goal = s.goal?.title || '—' $('run-meta').textContent = `${s.runId} · ${goal} · agent ${s.agent}${s.dryRun ? ' · DRY-RUN' : ''}` renderStats(s) renderIterations(s) renderModal(s) } function connect() { const es = new EventSource('/api/events') es.onmessage = (ev) => { let msg try { msg = JSON.parse(ev.data) } catch { return } if (msg.type === 'state') { if (!state.logSeeded) { state.logSeeded = true seedLog(msg.state.logs) } render(msg.state) } else if (msg.type === 'log') { appendLog(msg.entry) } } es.onerror = () => { // EventSource reconnects automatically; surface it in the status pill. const pill = $('status-pill') if (pill) pill.textContent = 'reconnecting…' } } $('btn-pause').addEventListener('click', () => { const paused = state.data?.status === 'paused' || state.data?.pauseRequested control(paused ? 'resume' : 'pause') }) $('btn-abort').addEventListener('click', () => control('abort-after-current')) $('btn-stop').addEventListener('click', () => { if (confirm('Stop the loop now and kill the current session?')) control('stop') }) $('btn-submit').addEventListener('click', () => submitModal(false)) $('btn-skip').addEventListener('click', () => submitModal(true)) // Enter submits inside the modal. $('modal').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault() submitModal(false) } }) // Terminal-style shortcuts mirror the CLI controls. document.addEventListener('keydown', (e) => { if (!$('modal').classList.contains('hidden')) return if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return const k = e.key.toLowerCase() if (k === 'x') control('abort-after-current') else if (k === 'p') { const paused = state.data?.status === 'paused' || state.data?.pauseRequested control(paused ? 'resume' : 'pause') } else if (k === 'q' && e.shiftKey) control('stop') }) connect()