kilo-loop/public/app.js

591 lines
21 KiB
JavaScript
Raw Normal View History

2026-09-13 10:46:28 +00:00
'use strict'
const $ = (id) => document.getElementById(id)
const state = {
data: null,
logSeeded: false,
lastSeq: 0,
modalIteration: null,
2026-09-13 16:29:03 +00:00
goals: [],
selectedFile: '',
forceLauncher: false,
prevStatus: null,
}
const ACTIVE_STATUSES = new Set(['starting', 'running', 'paused', 'waiting-answer'])
// Top-bar activity indicator: error/warn/problem states tint the bar, active
// states animate it, terminal states settle to a static line.
const ERROR_STATUSES = new Set(['error', 'done-with-errors'])
const WARN_STATUSES = new Set(['aborted', 'stopped-budget', 'stalled', 'stopped-guard'])
const TOPBAR_CLASSES = ['topbar--running', 'topbar--paused', 'topbar--waiting', 'topbar--done', 'topbar--warn', 'topbar--error']
function topbarState(s) {
if (s.idle === true) return ''
if (s.stale || ERROR_STATUSES.has(s.status)) return 'error'
if (WARN_STATUSES.has(s.status)) return 'warn'
if (s.status === 'starting' || s.status === 'running') return 'running'
if (s.status === 'waiting-answer') return 'waiting'
if (s.status === 'paused') return 'paused'
if (s.status === 'done') return 'done'
return ''
2026-09-13 10:46:28 +00:00
}
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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
2026-09-13 16:29:03 +00:00
const verify = s.verify || { passed: 0, failed: 0 }
2026-09-13 10:46:28 +00:00
const statuses = s.iterations.map((i) => i.status)
2026-09-13 16:29:03 +00:00
const statusText = s.stale ? `${s.status} · stale` : s.status
2026-09-13 10:46:28 +00:00
const cards = [
{
k: 'Progress',
v: `${percent}<small>%</small>`,
bar: percent,
},
{ k: 'Iteration', v: `${done}<small> / ${s.configuredIterations ?? s.iterations.length}</small>` },
2026-09-13 16:29:03 +00:00
{ k: 'Status', v: `<span style="font-size:14px">${esc(statusText)}</span>` },
2026-09-13 10:46:28 +00:00
{ k: 'Tokens', v: `${fmt(totals.tokens?.total)}<small> in ${fmt(totals.tokens?.input)} · out ${fmt(totals.tokens?.output)}</small>` },
{ k: 'Cost', v: fmtCost(totals.cost) },
{
k: 'Context',
v: `${fmt(context)}<small> tokens</small>`,
},
2026-09-13 16:29:03 +00:00
{ k: 'Verify', v: `${verify.passed || 0}<small> pass · ${verify.failed || 0} fail</small>` },
2026-09-13 10:46:28 +00:00
]
$('stats').innerHTML = cards
.map(
(c) => `<div class="stat"><div class="k">${c.k}</div><div class="v">${c.v}</div>${
c.bar !== undefined ? `<div class="bar"><i style="width:${Math.max(0, Math.min(100, c.bar))}%"></i></div>` : ''
}</div>`,
)
.join('')
void statuses
}
2026-09-13 16:29:03 +00:00
// ---------- stages (kanban strip) ----------
function renderStages(s) {
const box = $('stages')
const stages = s.stages || []
if (!stages.length) {
box.innerHTML = '<div class="empty">No stages in the goal.</div>'
return
}
const last = s.iterations[s.iterations.length - 1]
const currentId = last?.stageId || null
box.innerHTML = stages
.map((st, i) => {
const isCurrent = st.id === currentId
const cls = st.done ? 'done' : isCurrent ? 'current' : 'pending'
const verify = st.verify ? `<span class="stage-verify" title="verify: ${esc(st.verify)}">verify</span>` : ''
return `<div class="stage-chip ${cls}" title="${esc(st.details || st.title)}">
<span class="n">${i + 1}</span><span class="t">${esc(st.title)}</span>${verify}
</div>`
})
.join('')
const v = s.verify || {}
const hint = $('stages-hint')
if (hint) hint.textContent = `verification gate · ${v.passed || 0} passed · ${v.failed || 0} failed`
}
2026-09-13 10:46:28 +00:00
// ---------- iterations ----------
function renderIterations(s) {
const box = $('iterations')
if (!s.iterations.length) {
box.innerHTML = '<div class="empty">No iterations yet.</div>'
return
}
box.innerHTML = s.iterations
.map((it) => {
const pct = it.percent ?? 0
const status = it.status || 'pending'
const questions = (it.questions || []).length
? `<ul class="qlist">${it.questions.map((q) => `<li>${esc(q.text)}</li>`).join('')}</ul>`
: ''
const files = (it.filesChanged || []).length
? `<div class="files">${it.filesChanged.slice(0, 8).map((f) => `<span class="chip">${esc(f)}</span>`).join('')}${
it.filesChanged.length > 8 ? `<span class="chip">+${it.filesChanged.length - 8}</span>` : ''
}</div>`
: ''
return `<div class="iter ${esc(status)}">
<div class="head">
<span class="num">#${it.index}</span>
<span class="topic">${esc(it.topic || 'running…')}</span>
2026-09-13 16:29:03 +00:00
<span class="badge mode">${s.sharedContext ? 'shared ctx' : 'fresh ctx'}</span>
2026-09-13 10:46:28 +00:00
<span class="badge ${esc(status)}">${esc(status)}</span>
</div>
<div class="meta">
<span>${fmt(it.tokens?.total)} tok</span>
<span>${fmtCost(it.cost)}</span>
<span>ctx ${fmt(it.contextSize)}</span>
<span>${fmtDur(it.durationMs)}</span>
${it.toolCount ? `<span>${it.toolCount} tools</span>` : ''}
2026-09-13 16:29:03 +00:00
${it.verify ? `<span class="vb ${it.verify.ok ? 'ok' : 'bad'}">verify ${it.verify.ok ? 'pass' : it.verify.timedOut ? 'timeout' : 'FAIL'}</span>` : ''}
${it.attempts > 1 ? `<span class="vb warn" title="${esc(it.retry?.reason || 'model connection error')}">auto-retry ×${it.attempts}</span>` : ''}
${it.guardViolation ? `<span class="vb bad">guard blocked</span>` : ''}
${it.checkpointId ? `<span title="checkpoint before this iteration">cp ${esc(it.checkpointId)}</span>` : ''}
${it.sessionID ? `<span class="sess" title="${esc(it.sessionID)}">session ${esc(String(it.sessionID).slice(0, 12))}…</span>` : ''}
2026-09-13 10:46:28 +00:00
</div>
<div class="stage"><span class="lbl">Stage:</span> ${esc(it.stageTitle || '')} <span class="lbl">[${esc(it.stageStatus || 'n/a')}]</span></div>
<div class="stage"><span class="lbl">Next:</span> ${esc(it.nextStage || '')}</div>
${it.summary ? `<div class="summary">${esc(it.summary)}</div>` : ''}
${files}
${questions}
2026-09-13 16:29:03 +00:00
${it.checkpointId ? `<div class="actions"><button class="revert" data-iter="${it.index}" title="Restore the working tree to the checkpoint taken before this iteration">Revert before #${it.index}</button></div>` : ''}
2026-09-13 10:46:28 +00:00
<div class="bar"><i style="width:${Math.max(0, Math.min(100, pct))}%"></i></div>
</div>`
})
.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' ? '' : `<span class="t">${time}</span>`
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) => `<div class="q" data-i="${i}">
<label>${esc(q.text)}</label>
${
q.options?.length
? `<div class="opts">${q.options
.map((o) => `<button type="button" class="opt" data-val="${esc(o)}">${esc(o)}</button>`)
.join('')}</div>`
: ''
}
<input type="text" placeholder="Type your answer (Enter to submit)" />
</div>`,
)
.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
}
2026-09-13 16:29:03 +00:00
// ---------- launcher (plan picker) ----------
async function loadGoals() {
try {
const res = await fetch('/api/goals')
state.goals = await res.json()
} catch {
state.goals = []
}
const sel = $('goal-select')
if (!state.goals.length) {
sel.innerHTML = '<option value="">No plans found</option>'
selectGoal('')
return
}
sel.innerHTML = state.goals
.map((g) => `<option value="${esc(g.file)}">${esc(g.title)}${g.done}/${g.total} (${g.percent}%)</option>`)
.join('')
const keep = state.goals.some((g) => g.file === state.selectedFile) ? state.selectedFile : state.goals[0].file
sel.value = keep
selectGoal(keep)
}
function selectGoal(file) {
state.selectedFile = file
const g = state.goals.find((x) => x.file === file)
const meta = $('goal-meta')
const list = $('goal-stages')
for (const id of ['btn-mark-all', 'btn-clear-all', 'btn-delete-goal']) {
const btn = $(id)
if (btn) btn.disabled = !g
}
if (!g) {
meta.textContent = ''
list.innerHTML = ''
return
}
const last = g.lastRun
? `last run ${g.lastRun.status} · ${g.lastRun.iterations} it · $${(g.lastRun.cost || 0).toFixed(4)}`
: 'never run'
meta.textContent = `${g.file}${g.done}/${g.total} done (${g.percent}%) · ${g.type} · ${last}`
$('global-verify').value = g.globalVerify || ''
list.innerHTML =
g.stages
.map(
(st, i) => `<div class="goal-stage ${st.done ? 'done' : ''}">
<span class="st-state">${st.done ? 'done' : 'todo'}</span>
<span class="st-title" title="${esc(st.title)}">${i + 1}. ${esc(st.title)}</span>
<input type="text" data-stage="${i}" placeholder="verify command" value="${esc(st.verify || '')}" />
</div>`,
)
.join('') || '<div class="empty">This plan has no stages.</div>'
}
async function saveStageVerify(stageIndex, verify) {
if (!state.selectedFile) return
try {
const res = await fetch('/api/goals/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: state.selectedFile, stageIndex, verify }),
})
const data = await res.json()
if (!data.ok) {
$('launcher-error').textContent = data.error || 'save failed'
return
}
const i = state.goals.findIndex((x) => x.file === state.selectedFile)
if (i >= 0) state.goals[i] = data.goal
selectGoal(state.selectedFile)
} catch (err) {
$('launcher-error').textContent = err.message
}
}
async function saveGlobalVerify() {
try {
const res = await fetch('/api/goals/verify-global', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ verify: $('global-verify').value }),
})
const data = await res.json()
if (!data.ok) {
$('launcher-error').textContent = data.error || 'save failed'
return
}
await loadGoals()
} catch (err) {
$('launcher-error').textContent = err.message
}
}
// POST a plan action scoped to the currently selected file and surface errors.
async function postGoalAction(path, body, label) {
if (!state.selectedFile) return null
try {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: state.selectedFile, ...body }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok || data.ok === false) {
$('launcher-error').textContent = data.error || `${label} failed (${res.status})`
return null
}
return data
} catch (err) {
$('launcher-error').textContent = err.message
return null
}
}
async function setGoalDone(done) {
$('launcher-error').textContent = ''
const data = await postGoalAction('/api/goals/done', { done }, done ? 'mark' : 'clear')
if (data) await loadGoals()
}
async function deleteGoal() {
const g = state.goals.find((x) => x.file === state.selectedFile)
const label = g?.title || state.selectedFile
const ok = confirm(
`Delete the plan "${label}"?\n\nThe file ${state.selectedFile} is moved to .kilocode-loop/trash/ so it can be restored. This is not a commit.`,
)
if (!ok) return
$('launcher-error').textContent = ''
const data = await postGoalAction('/api/goals/delete', {}, 'delete')
if (data) {
state.selectedFile = ''
await loadGoals()
}
}
async function startRun() {
if (!state.selectedFile) {
$('launcher-error').textContent = 'Pick a plan first.'
return
}
$('launcher-error').textContent = ''
const body = {
file: state.selectedFile,
iterations: Number($('opt-iterations').value) || 5,
agent: $('opt-agent').value || undefined,
hitl: $('opt-hitl').value,
dryRun: $('opt-dryrun').checked,
promptExtra: $('opt-prompt-extra').value.trim() || undefined,
}
try {
const res = await fetch('/api/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const data = await res.json().catch(() => ({}))
if (!res.ok || !data.ok) {
$('launcher-error').textContent = data.error || `start failed (${res.status})`
return
}
state.forceLauncher = false
} catch (err) {
$('launcher-error').textContent = err.message
}
}
$('goal-select').addEventListener('change', (e) => selectGoal(e.target.value))
$('goal-stages').addEventListener('change', (e) => {
const input = e.target.closest('input[data-stage]')
if (input) saveStageVerify(Number(input.dataset.stage), input.value)
})
$('btn-save-global-verify').addEventListener('click', saveGlobalVerify)
$('btn-goals-refresh').addEventListener('click', loadGoals)
$('btn-mark-all').addEventListener('click', () => setGoalDone(true))
$('btn-clear-all').addEventListener('click', () => setGoalDone(false))
$('btn-delete-goal').addEventListener('click', deleteGoal)
$('btn-start').addEventListener('click', startRun)
$('btn-new-run').addEventListener('click', () => {
state.forceLauncher = true
loadGoals()
render(state.data || { status: 'idle', idle: true })
})
2026-09-13 10:46:28 +00:00
// ---------- controls ----------
2026-09-13 16:29:03 +00:00
async function control(action, extra) {
2026-09-13 10:46:28 +00:00
try {
await fetch('/api/control', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
2026-09-13 16:29:03 +00:00
body: JSON.stringify({ action, ...(extra || {}) }),
2026-09-13 10:46:28 +00:00
})
} catch {
/* ignore */
}
}
// ---------- state ----------
function render(s) {
state.data = s
2026-09-13 16:29:03 +00:00
// Top-bar activity indicator (CSS animation drives it; JS only sets state).
const bar = $('topbar')
if (bar) {
bar.classList.remove(...TOPBAR_CLASSES)
const barState = topbarState(s)
if (barState) bar.classList.add(`topbar--${barState}`)
}
const idle = s.idle === true
const stopped = ['done', 'done-with-errors', 'aborted', 'error', 'stopped-budget', 'stalled', 'stopped-guard'].includes(s.status)
const showLauncher = idle || state.forceLauncher
$('launcher').classList.toggle('hidden', !showLauncher)
$('run-view').classList.toggle('hidden', showLauncher)
$('toggle-shared').classList.toggle('hidden', idle)
$('btn-new-run').classList.toggle('hidden', idle || !stopped)
// When a run finishes, refresh the plan list so the launcher shows new state.
if (state.prevStatus && ACTIVE_STATUSES.has(state.prevStatus) && !ACTIVE_STATUSES.has(s.status)) loadGoals()
state.prevStatus = s.status
if (idle) {
$('run-meta').textContent = 'no active run — pick a plan'
const pillIdle = $('status-pill')
pillIdle.textContent = 'idle'
pillIdle.className = 'status'
return
}
2026-09-13 10:46:28 +00:00
const pauseBtn = $('btn-pause')
const paused = s.status === 'paused' || s.pauseRequested
pauseBtn.textContent = paused ? 'Resume' : 'Pause'
2026-09-13 16:29:03 +00:00
pauseBtn.disabled = stopped
2026-09-13 10:46:28 +00:00
$('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 || '—'
2026-09-13 16:29:03 +00:00
const mode = s.sharedContext ? 'shared context' : 'fresh sessions'
const custom = s.customInstruction ? ' · custom instruction' : ''
$('run-meta').textContent = `${s.runId} · ${goal} · agent ${s.agent} · ${mode}${custom}${s.dryRun ? ' · DRY-RUN' : ''}`
const sharedChk = $('chk-shared')
if (document.activeElement !== sharedChk) sharedChk.checked = Boolean(s.sharedContext)
sharedChk.disabled = stopped
$('toggle-shared').classList.toggle('on', Boolean(s.sharedContext))
const exportLink = $('btn-export')
if (exportLink) exportLink.href = s.runId ? `/api/runs/${encodeURIComponent(s.runId)}/export` : '#'
2026-09-13 10:46:28 +00:00
renderStats(s)
2026-09-13 16:29:03 +00:00
renderStages(s)
2026-09-13 10:46:28 +00:00
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')
})
2026-09-13 16:29:03 +00:00
// Revert the working tree to the checkpoint taken before an iteration.
$('iterations').addEventListener('click', async (e) => {
const btn = e.target.closest('.revert')
if (!btn) return
const iteration = Number(btn.dataset.iter)
if (!confirm(`Revert the working tree to the checkpoint taken before iteration ${iteration}?\n\nThis discards tracked and untracked changes made since then. It is not a commit and cannot be undone from here.`)) return
try {
const res = await fetch('/api/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ iteration }),
})
const data = await res.json()
if (!data.ok) alert(data.error || 'revert failed')
} catch (err) {
alert(`revert failed: ${err.message}`)
}
})
$('chk-shared').addEventListener('change', (e) => control('set-shared-context', { value: e.target.checked }))
2026-09-13 10:46:28 +00:00
$('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')
})
2026-09-13 16:29:03 +00:00
loadGoals()
2026-09-13 10:46:28 +00:00
connect()