kilo-loop/public/app.js
Vitali sharp8n 1eb9ec22c2 kilo loop
2026-09-13 19:29:03 +03:00

591 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict'
const $ = (id) => document.getElementById(id)
const state = {
data: null,
logSeeded: false,
lastSeq: 0,
modalIteration: null,
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 ''
}
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
const verify = s.verify || { passed: 0, failed: 0 }
const statuses = s.iterations.map((i) => i.status)
const statusText = s.stale ? `${s.status} · stale` : s.status
const cards = [
{
k: 'Progress',
v: `${percent}<small>%</small>`,
bar: percent,
},
{ k: 'Iteration', v: `${done}<small> / ${s.configuredIterations ?? s.iterations.length}</small>` },
{ k: 'Status', v: `<span style="font-size:14px">${esc(statusText)}</span>` },
{ 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>`,
},
{ k: 'Verify', v: `${verify.passed || 0}<small> pass · ${verify.failed || 0} fail</small>` },
]
$('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
}
// ---------- 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`
}
// ---------- 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>
<span class="badge mode">${s.sharedContext ? 'shared ctx' : 'fresh ctx'}</span>
<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>` : ''}
${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>` : ''}
</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}
${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>` : ''}
<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
}
// ---------- 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 })
})
// ---------- controls ----------
async function control(action, extra) {
try {
await fetch('/api/control', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, ...(extra || {}) }),
})
} catch {
/* ignore */
}
}
// ---------- state ----------
function render(s) {
state.data = s
// 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
}
const pauseBtn = $('btn-pause')
const paused = s.status === 'paused' || s.pauseRequested
pauseBtn.textContent = paused ? 'Resume' : 'Pause'
pauseBtn.disabled = stopped
$('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 || '—'
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` : '#'
renderStats(s)
renderStages(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')
})
// 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 }))
$('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')
})
loadGoals()
connect()