From 939d8c2f5f32db0ba94755d5d3aeb5ef951545d0 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:20:39 +0200 Subject: [PATCH 1/3] fix(webui): sequential turn timeline, live metrics, and disconnect banner Partial assistant replies stay in arrival order with tools instead of concatenating into one parked bubble; reasoning is collapsed behind thinking. Usage frames carry inputTokens so ctx/cost update mid-turn. Composer / matches the palette. A dropped socket shows a sticky banner and one transcript notice instead of a silent lamp. Co-authored-by: Cursor --- cmd/odek/serve.go | 12 +- cmd/odek/ui/index.html | 7 +- cmd/odek/ui/js/commands.js | 15 +- cmd/odek/ui/js/input.js | 88 ++++++-- cmd/odek/ui/js/lifecycle.test.js | 201 ++++++++++++++++-- cmd/odek/ui/js/metrics.js | 77 ++++++- cmd/odek/ui/js/metrics.test.js | 38 +++- cmd/odek/ui/js/render.js | 341 +++++++++++++++++++++---------- cmd/odek/ui/js/state.js | 9 +- cmd/odek/ui/js/utils.js | 2 +- cmd/odek/ui/js/ws.js | 111 ++++++++-- cmd/odek/ui/js/ws.test.js | 16 +- cmd/odek/ui/style.css | 49 ++++- cmd/odek/usage_frame_test.go | 4 + docs/WEBUI.md | 30 +-- 15 files changed, 804 insertions(+), 196 deletions(-) diff --git a/cmd/odek/serve.go b/cmd/odek/serve.go index 0cad7641..28383081 100644 --- a/cmd/odek/serve.go +++ b/cmd/odek/serve.go @@ -1109,6 +1109,9 @@ func usageFrame(info loop.IterationInfo) (map[string]any, bool) { "type": "usage", "outputTokens": info.OutputTokens, } + if info.InputTokens > 0 { + frame["inputTokens"] = info.InputTokens + } if info.WindowTokens > 0 { frame["windowTokens"] = info.WindowTokens } @@ -2165,11 +2168,10 @@ func handlePrompt( // Tool events (tool_call / tool_result) already fired live during // RunWithMessages via ToolEventHandler — skip them here. // - // Assistant messages with ToolCalls are intermediate "thinking + act" - // turns. Their Content (e.g. "Let me check that file…") was narrated - // live via the IterationCallback progress bubble; re-sending it here - // would make it appear *after* all tool blocks in the response bubble, - // which is confusing. Skip their Content. + // Assistant messages with ToolCalls are intermediate "think + act" + // turns. Their Content (e.g. "Let me check that file…") was streamed + // live as token_delta / token timeline rows; re-sending it here would + // duplicate those partials after the tools. Skip their Content. // // The final assistant message (no ToolCalls) carries: // • ReasoningContent — the model's private reasoning for this turn. diff --git a/cmd/odek/ui/index.html b/cmd/odek/ui/index.html index c405af9b..77a3c045 100644 --- a/cmd/odek/ui/index.html +++ b/cmd/odek/ui/index.html @@ -158,6 +158,7 @@

config

+
@@ -175,7 +176,7 @@

config

- +
@@ -201,7 +202,7 @@

config

- @ mention a file or session · /new /clear /retry · ⌘K commands + @ mention a file or session · / commands · ⌘K palette @@ -224,7 +225,7 @@

Keyboard shortcuts

Command palette⌘K
Send / queue while busyEnter
New lineShift+Enter
-
Slash verbs/new /clear /retry /cancel /stop
+
Slash commands/ (same as ⌘K)
File/session reference@
Copy last reply⌘⇧C
Retry last promptAlt+R
diff --git a/cmd/odek/ui/js/commands.js b/cmd/odek/ui/js/commands.js index 34934a59..8effcd84 100644 --- a/cmd/odek/ui/js/commands.js +++ b/cmd/odek/ui/js/commands.js @@ -33,7 +33,12 @@ const COMMANDS = [ { id: 'tab-ops', title: 'Inspector · ops', hint: '⌘.', run: () => openTab('ops') }, ]; -const COMPOSER_SLASH = new Set(['new', 'clear', 'retry', 'cancel', 'stop']); +const SLASH_VERBS = new Set([ + 'help', 'new', 'clear', 'copy', 'export', 'retry', 'queue', 'theme', + 'stats', 'cancel', 'notify', 'shutdown', 'model', 'thinking', + 'now', 'memory', 'ops', 'plan', 'jobs', 'agents', 'skills', 'tools', + 'runs', 'events', 'config', 'sessions', 'session', 'stop', +]); const TAB_WS = { sessions: 'sessions', session: 'sessions', plan: 'now', jobs: 'now', agents: 'now', now: 'now', @@ -240,10 +245,14 @@ export function dispatchSlash(raw) { return true; } +export function paletteItems(q) { + return collectItems(q); +} + export function maybeHandleComposerEnter(text) { if (!text.startsWith('/') || text.includes('\n')) return false; - const cmd = text.slice(1).split(/\s+/)[0]; - if (!COMPOSER_SLASH.has(cmd)) return false; + const cmd = text.slice(1).split(/\s+/)[0].toLowerCase(); + if (!cmd || !SLASH_VERBS.has(cmd)) return false; return dispatchSlash(text); } diff --git a/cmd/odek/ui/js/input.js b/cmd/odek/ui/js/input.js index b99c55ae..59d1ad60 100644 --- a/cmd/odek/ui/js/input.js +++ b/cmd/odek/ui/js/input.js @@ -8,10 +8,10 @@ import { } from './dom.js'; import { escapeHtml, escapeAttr, formatFileSize, scrollToBottom, - showCancel, toggleShortcuts, SCROLL_THRESHOLD, teach, + showCancel, toggleShortcuts, SCROLL_THRESHOLD, teach, showToast, } from './utils.js'; import { addMessage, resetTurnState, showLoading, paintIntent } from './render.js'; -import { maybeHandleComposerEnter } from './commands.js'; +import { maybeHandleComposerEnter, paletteItems } from './commands.js'; function queueId() { return 'q' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); @@ -79,7 +79,10 @@ S.drainQueue = drainQueue; // ── Send ── export function send() { // F-B2: dead socket still rejects BEFORE touching attachments. - if (!S.ws || S.ws.readyState !== WebSocket.OPEN) return; + if (!S.ws || S.ws.readyState !== WebSocket.OPEN) { + showToast('connection lost — reconnecting'); + return; + } const text = promptEl.value.trim(); if (!text && S.attachedFiles.length === 0) return; @@ -332,7 +335,7 @@ promptEl.addEventListener('keydown', (e) => { } if (e.key === 'Escape') { e.preventDefault(); - completionEl.classList.remove('visible'); + hideCompletion(); return; } } @@ -392,7 +395,7 @@ promptEl.addEventListener('input', () => { }); promptEl.addEventListener('keydown', (e) => { - if (e.key === '@') { + if (e.key === '@' || e.key === '/') { if (completionTimer) clearTimeout(completionTimer); completionTimer = setTimeout(checkCompletion, 150); } @@ -409,8 +412,16 @@ promptEl.addEventListener('keydown', (e) => { completionEl.addEventListener('click', (e) => { const item = e.target.closest('.comp-item'); if (!item) return; + if (S.compMode === 'slash') { + completionEl.querySelectorAll('.comp-item').forEach(el => { + el.classList.toggle('selected', el === item); + el.setAttribute('aria-selected', el === item); + }); + selectCompletion(); + return; + } replaceCompletion(item.dataset.id); - completionEl.classList.remove('visible'); + hideCompletion(); }); completionEl.addEventListener('mousemove', (e) => { @@ -422,23 +433,63 @@ completionEl.addEventListener('mousemove', (e) => { }); }); +let slashRows = []; + +function hideCompletion() { + completionEl.classList.remove('visible'); + S.compMode = ''; + slashRows = []; +} + +function trySlashCompletion(val, cursor) { + if (!val.startsWith('/') || val.includes('\n')) return false; + const before = val.slice(0, cursor); + if (!before.startsWith('/')) return false; + if (/\s/.test(before.slice(1))) { + hideCompletion(); + return true; + } + const q = before.slice(1); + slashRows = paletteItems(q); + S.compMode = 'slash'; + S.lastAtIdx = 0; + S.lastCursor = cursor; + S.compQuery = q; + if (!slashRows.length) { + hideCompletion(); + return true; + } + completionEl.innerHTML = slashRows.map((r, i) => + `
+ ${escapeAttr(r.kind)} + ${escapeHtml(r.title)} + ${escapeHtml(r.hint || '')} +
` + ).join(''); + completionEl.classList.add('visible'); + return true; +} + async function checkCompletion() { const val = promptEl.value; const cursor = promptEl.selectionStart; + if (trySlashCompletion(val, cursor)) return; + const before = val.slice(0, cursor); const atIdx = before.lastIndexOf('@'); if (atIdx < 0) { - completionEl.classList.remove('visible'); + hideCompletion(); return; } const query = before.slice(atIdx + 1).split(/\s/)[0]; if (!query) { - completionEl.classList.remove('visible'); + hideCompletion(); return; } + S.compMode = 'at'; S.lastAtIdx = atIdx; S.lastCursor = cursor; S.compQuery = query; @@ -451,12 +502,12 @@ async function checkCompletion() { headers: apiHeaders() }); if (!resp.ok) { - completionEl.classList.remove('visible'); + hideCompletion(); return; } const results = await resp.json(); if (!Array.isArray(results) || results.length === 0) { - completionEl.classList.remove('visible'); + hideCompletion(); return; } if (promptEl.value + '\u0000' + promptEl.selectionStart !== reqToken) { @@ -473,7 +524,7 @@ async function checkCompletion() { completionEl.classList.add('visible'); } catch { - completionEl.classList.remove('visible'); + hideCompletion(); } } @@ -491,10 +542,23 @@ function moveCompletionSelection(delta) { } function selectCompletion() { + if (S.compMode === 'slash') { + const selected = completionEl.querySelector('.selected'); + const idx = selected ? parseInt(selected.dataset.idx, 10) : 0; + const row = slashRows[idx]; + hideCompletion(); + promptEl.value = ''; + promptEl.style.height = 'auto'; + if (row && row.run) { + try { row.run(); } catch (err) { showToast(err.message || 'command failed'); } + } + promptEl.focus(); + return; + } const selected = completionEl.querySelector('.selected'); if (!selected) return; replaceCompletion(selected.dataset.id); - completionEl.classList.remove('visible'); + hideCompletion(); } function replaceCompletion(id) { diff --git a/cmd/odek/ui/js/lifecycle.test.js b/cmd/odek/ui/js/lifecycle.test.js index 154ef972..00439e2f 100644 --- a/cmd/odek/ui/js/lifecycle.test.js +++ b/cmd/odek/ui/js/lifecycle.test.js @@ -115,6 +115,7 @@ class FakeEl { this.children.push(c); return c; } + get parentElement() { return this.parentNode; } append(...cs) { cs.forEach(c => this.appendChild(c)); } // Mirrors ws.test.js: position ignored, node lands as a child — enough // for sessions.js's top-level search-clear bootstrap to survive import. @@ -172,7 +173,7 @@ const ids = ['messages', 'prompt', 'send-btn', 'completion', 'ws-status', 'ws-do 'attach-btn', 'file-chips', 'toast', 'announcer', 'model-picker', 'thinking-picker', 'custom-model-input', 'theme-btn', 'panels-btn', 'shortcuts-overlay', 'status-group', 'ping-latency', 'stream-badge', 'sessions-more', 'sidebar-count', - 'sandbox-badge', 'plan-panel']; + 'sandbox-badge', 'plan-panel', 'conn-banner', 'sr-status']; ids.forEach(id => { byId[id] = new FakeEl('div'); byId[id].id = id; }); globalThis.document = { @@ -219,6 +220,7 @@ const ws = await import('./ws.js'); const approvals = await import('./approvals.js'); const health = await import('./health.js'); const plan = await import('./plan.js'); +const commands = await import('./commands.js'); function deliver(event) { S.ws.onmessage({ data: JSON.stringify(event) }); @@ -359,6 +361,42 @@ test('reconnect resets busy, re-enables the prompt, and tells the user', () => { health.stopHeartbeat(); // don't leak the heartbeat interval into the suite }); +test('disconnect shows a banner and one transcript notice until restored', () => { + const sock = S.ws; + sock.onopen(); + assert.equal(byId['conn-banner'].hidden, true, 'banner hidden while connected'); + + sock.onclose(); + const banner = byId['conn-banner']; + assert.equal(banner.hidden, false, 'banner visible after drop'); + assert.match(banner.textContent, /connection lost/i); + assert.equal(byId['ws-status'].textContent, 'reconnecting'); + assert.ok(byId['ws-dot'].className.includes('disconnected')); + const lost = systemMessages(); + assert.ok(lost.length >= 1, 'outage is narrated in the transcript'); + assert.match(collectText(lost[lost.length - 1]).join(' '), /Connection lost/); + const afterDrop = lost.length; + + sock.onclose(); // backoff retry — same outage + assert.equal(systemMessages().length, afterDrop, 'retries do not spam the transcript'); + + sock.onopen(); + assert.equal(byId['conn-banner'].hidden, true, 'banner clears on restore'); + const restored = systemMessages(); + assert.ok(restored.length > afterDrop, 'restore is narrated after the drop'); + assert.match(collectText(restored[restored.length - 1]).join(' '), /Connection restored/); + health.stopHeartbeat(); +}); + +test('send while disconnected toasts instead of failing silently', () => { + S.ws.readyState = 3; + byId.prompt.value = 'hello'; + input.send(); + assert.equal(S.ws.sent.length, 0, 'no prompt frame on a dead socket'); + assert.match(byId.toast.textContent, /connection lost/); + assert.ok(byId.toast.classList.contains('show')); +}); + // ── F-B1: delegate_tasks tool_result must not route into other tool blocks. ── test('delegate_tasks tool_result completes the group without touching other blocks', () => { deliver({ type: 'tool_call', name: 'shell', data: '"ls"' }); @@ -378,42 +416,51 @@ test('delegate_tasks tool_result completes the group without touching other bloc }); function spine() { - return byId.messages.children.map((c) => { - if (c.classList.contains('thinking-block')) return 'thinking'; - if (c.classList.contains('tool-block')) return 'tool'; - if (c.classList.contains('subagent-group')) return 'subagent'; - if (c.classList.contains('approval-card')) return 'approval'; - if (c.classList.contains('msg') && c.classList.contains('assistant')) return 'answer'; - if (c.classList.contains('msg') && c.classList.contains('user')) return 'user'; - return c.className || c.tagName; - }); + const out = []; + const walk = (nodes) => { + (nodes || []).forEach((c) => { + if (c.classList.contains('turn-stream')) { + walk(c.children); + return; + } + if (c.classList.contains('thinking-block') || c.classList.contains('thinking-line')) out.push('thinking'); + else if (c.classList.contains('tool-block')) out.push('tool'); + else if (c.classList.contains('subagent-group')) out.push('subagent'); + else if (c.classList.contains('approval-card')) out.push('approval'); + else if (c.classList.contains('msg') && c.classList.contains('assistant')) out.push('answer'); + else if (c.classList.contains('msg') && c.classList.contains('user')) out.push('user'); + else out.push(c.className || c.tagName); + }); + }; + walk(byId.messages.children); + return out; } -// The model emits answer tokens in the same LLM message as tool_calls. -// token_delta must not win the append race — live spine is thinking → tools → answer. -test('token then tool_call paints tools before the answer', () => { +// token / token_delta is a visible assistant reply. It stays in the +// timeline in arrival order — a following tool_call does not park it last. +test('token then tool_call paints the reply then the tool', () => { deliver({ type: 'turn_started', turn_id: 't-order-1' }); deliver({ type: 'token_delta', turn_id: 't-order-1', content: 'Running.' }); deliver({ type: 'tool_call', turn_id: 't-order-1', name: 'shell', data: '{"command":"echo hi"}' }); - assert.deepEqual(spine(), ['tool', 'answer']); + assert.deepEqual(spine(), ['answer', 'tool']); }); -test('thinking, token, tool_call stays thinking → tools → answer', () => { +test('thinking, token, tool_call stays thinking → reply → tool', () => { deliver({ type: 'turn_started', turn_id: 't-order-2' }); deliver({ type: 'thinking_delta', turn_id: 't-order-2', content: 'plan' }); deliver({ type: 'token_delta', turn_id: 't-order-2', content: 'Running.' }); deliver({ type: 'tool_call', turn_id: 't-order-2', name: 'read_file', data: '{"path":"a.go"}' }); - assert.deepEqual(spine(), ['thinking', 'tool', 'answer']); + assert.deepEqual(spine(), ['thinking', 'answer', 'tool']); }); -test('late first thinking slides in front of tools that raced ahead', () => { +test('late first thinking stays after tools that already rendered', () => { deliver({ type: 'turn_started', turn_id: 't-order-3' }); deliver({ type: 'tool_call', turn_id: 't-order-3', name: 'shell', data: '{}' }); deliver({ type: 'thinking_delta', turn_id: 't-order-3', content: 'late' }); - assert.deepEqual(spine(), ['thinking', 'tool']); + assert.deepEqual(spine(), ['tool', 'thinking']); }); -test('a second iteration keeps answer last: think → tool → think → tool → answer', () => { +test('a second iteration keeps arrival order: think → reply → tool → think → reply → tool', () => { deliver({ type: 'turn_started', turn_id: 't-order-4' }); deliver({ type: 'thinking_delta', turn_id: 't-order-4', content: 'one' }); deliver({ type: 'token_delta', turn_id: 't-order-4', content: 'Running 1.' }); @@ -422,7 +469,83 @@ test('a second iteration keeps answer last: think → tool → think → tool deliver({ type: 'thinking_delta', turn_id: 't-order-4', content: 'two' }); deliver({ type: 'token_delta', turn_id: 't-order-4', content: 'Running 2.' }); deliver({ type: 'tool_call', turn_id: 't-order-4', name: 'read_file', data: '{}' }); - assert.deepEqual(spine(), ['thinking', 'tool', 'thinking', 'tool', 'answer']); + assert.deepEqual(spine(), ['thinking', 'answer', 'tool', 'thinking', 'answer', 'tool']); + const streams = byId.messages.children.filter((c) => c.classList.contains('turn-stream')); + assert.equal(streams.length, 1, 'thinking, partial replies, and tools share one sequential stream'); + const kinds = streams[0].children.map((c) => { + if (c.classList.contains('thinking-line')) return 'thinking'; + if (c.classList.contains('thinking-block')) return 'thinking'; + if (c.classList.contains('tool-block')) return 'tool'; + if (c.classList.contains('msg')) return 'answer'; + return c.className; + }); + assert.deepEqual(kinds, ['thinking', 'answer', 'tool', 'thinking', 'answer', 'tool']); +}); + +test('partial assistant replies stay as separate timeline rows around tools', () => { + deliver({ type: 'turn_started', turn_id: 't-partials' }); + deliver({ type: 'token_delta', turn_id: 't-partials', content: 'Let me look at src/.' }); + deliver({ type: 'tool_call', turn_id: 't-partials', name: 'shell', data: '{"command":"ls src"}' }); + deliver({ type: 'token_delta', turn_id: 't-partials', content: 'Found 3 files.' }); + render.streamFlush(); + assert.deepEqual(spine(), ['answer', 'tool', 'answer']); + const answers = byId.messages.querySelectorAll('.msg.assistant'); + assert.equal(answers.length, 2, 'each token burst is its own assistant row'); + const first = collectText(answers[0]).join(' '); + const second = collectText(answers[1]).join(' '); + assert.match(first, /Let me look/); + assert.match(second, /Found 3 files/); + assert.equal(first.includes('Found 3 files'), false, 'later text must not concatenate into the first row'); + assert.ok(answers[0].classList.contains('partial'), 'the sealed mid-turn reply is marked partial'); + assert.equal(answers[1].classList.contains('partial'), false, 'the live row stays open'); +}); + +test('session history paints assistant content before that message\'s tools', () => { + render.renderSessionHistory([ + { role: 'user', content: 'look' }, + { + role: 'assistant', + content: 'Let me look.', + tool_calls: [{ id: 'c1', function: { name: 'shell', arguments: '{}' } }], + }, + { role: 'assistant', content: 'Found 3 files.' }, + ]); + assert.deepEqual(spine(), ['user', 'answer', 'tool', 'answer']); + const answers = byId.messages.querySelectorAll('.msg.assistant'); + assert.equal(answers.length, 2); + assert.match(collectText(answers[0]).join(' '), /Let me look/); + assert.match(collectText(answers[1]).join(' '), /Found 3 files/); +}); + +test('reasoning stays collapsed until the toggle is opened', () => { + deliver({ type: 'turn_started', turn_id: 't-think-hide' }); + deliver({ type: 'thinking_delta', turn_id: 't-think-hide', content: 'secret plan' }); + const content = byId.messages.querySelector('.thinking-content'); + assert.ok(content, 'collapsed thinking block rendered'); + assert.equal(content.classList.contains('open'), false, 'reasoning hidden by default'); + const toggle = byId.messages.querySelector('.thinking-toggle'); + toggle.dispatch('click'); + assert.equal(content.classList.contains('open'), true, 'click reveals reasoning'); +}); + +test('reasoning fragments render as separate rows, not one glued paragraph', () => { + deliver({ type: 'turn_started', turn_id: 't-think-rows' }); + deliver({ type: 'thinking_delta', turn_id: 't-think-rows', content: 'I should read the file.' }); + deliver({ type: 'thinking_delta', turn_id: 't-think-rows', content: 'Then I will search.' }); + const lines = byId.messages.querySelectorAll('.thinking-line'); + assert.equal(lines.length, 2, 'each complete fragment is its own row'); + assert.equal(lines[0].textContent, 'I should read the file.'); + assert.equal(lines[1].textContent, 'Then I will search.'); +}); + +test('token-sized reasoning pieces stay on the same row', () => { + deliver({ type: 'turn_started', turn_id: 't-think-join' }); + deliver({ type: 'thinking_delta', turn_id: 't-think-join', content: 'Hel' }); + deliver({ type: 'thinking_delta', turn_id: 't-think-join', content: 'lo' }); + deliver({ type: 'thinking_delta', turn_id: 't-think-join', content: ' there' }); + const lines = byId.messages.querySelectorAll('.thinking-line'); + assert.equal(lines.length, 1, 'SSE token pieces join the current row'); + assert.equal(lines[0].textContent, 'Hello there'); }); test('latest assistant reply is never folded; the previous long one is', () => { @@ -621,3 +744,41 @@ test('non-2xx @-completion response hides the popup instead of throwing', async prompt.value = ''; } }); + +test('slash completion lists palette items for a leading /', async () => { + const prompt = byId.prompt; + prompt.value = '/new'; + prompt.selectionStart = 4; + try { + byId.prompt.dispatch('input'); + await new Promise((r) => setTimeout(r, 200)); + assert.equal(byId.completion.classList.contains('visible'), true, 'slash popup shown'); + const items = byId.completion.querySelectorAll('.comp-item'); + assert.ok(items.length >= 1, 'palette rows rendered'); + assert.equal(S.compMode, 'slash'); + } finally { + prompt.value = ''; + } +}); + +test('composer Enter dispatches palette slash verbs, not filesystem paths', () => { + const calls = []; + commands.setCommandHandlers({ + help: () => calls.push('help'), + clear: () => calls.push('clear'), + retry: () => {}, + cancel: () => {}, + copyLast: () => {}, + exportSession: () => {}, + cycleTheme: () => {}, + stats: () => {}, + toggleNotify: () => {}, + shutdown: () => {}, + switchModel: () => {}, + switchThinking: () => {}, + }); + assert.equal(commands.maybeHandleComposerEnter('/help'), true); + assert.deepEqual(calls, ['help']); + assert.equal(commands.maybeHandleComposerEnter('/Users/src/main.go'), false); + assert.ok(commands.paletteItems('new').some((i) => i.id === 'new')); +}); diff --git a/cmd/odek/ui/js/metrics.js b/cmd/odek/ui/js/metrics.js index 1e7c38e9..58d90c8a 100644 --- a/cmd/odek/ui/js/metrics.js +++ b/cmd/odek/ui/js/metrics.js @@ -29,6 +29,9 @@ S.metrics = { tokPerSec: 0, // last think-step rate; 0 = unknown / omit tokPerSecKind: '', // 'generation' | 'e2e' | '' model: '', + turnBaseIn: 0, // sessIn at turn start — live cost overlays run totals + turnBaseOut: 0, + streamedOutChars: 0, // chars streamed since last usage/done (live cost/gauge) }; // init loads prices and context sizes once, then resolves for the current @@ -89,6 +92,14 @@ function resolvePrices() { // ── Event entry points ── +// metricsBeginTurn snapshots session totals so usage frames can overlay +// this-run spend without waiting for done. +export function metricsBeginTurn() { + S.metrics.turnBaseIn = S.metrics.sessIn || 0; + S.metrics.turnBaseOut = S.metrics.sessOut || 0; + S.metrics.streamedOutChars = 0; +} + // liveContext: a per-iteration usage event — the freshest parent window // size plus the server-resolved model limit (when reported). export function metricsLiveContext(windowTokens, maxContextTokens) { @@ -98,6 +109,52 @@ export function metricsLiveContext(windowTokens, maxContextTokens) { renderMetrics(); } +// metricsLiveUsage overlays run-cumulative tokens onto the session +// totals captured at turn start so the cost chip and ctx gauge move +// after every LLM iteration, not only on done. +export function metricsLiveUsage(evt) { + if (!evt) { renderMetrics(); return; } + S.metrics.streamedOutChars = 0; + const inTok = Number(evt.inputTokens); + const callIn = Number(evt.callInputTokens); + if (inTok > 0) { + S.metrics.sessIn = S.metrics.turnBaseIn + inTok; + } else if (callIn > 0) { + S.metrics.sessIn = (S.metrics.sessIn || S.metrics.turnBaseIn) + callIn; + } + const outTok = Number(evt.outputTokens); + const callOut = Number(evt.callOutputTokens); + if (outTok > 0) { + S.metrics.sessOut = S.metrics.turnBaseOut + outTok; + } else if (callOut > 0) { + S.metrics.sessOut = (S.metrics.sessOut || S.metrics.turnBaseOut) + callOut; + } + renderMetrics(); +} + +let streamMetricsRAF = null; + +// metricsNoteOutput counts streamed reasoning/answer characters so the +// cost chip and ctx fill keep moving between usage frames. +export function metricsNoteOutput(chars) { + const n = Number(chars); + if (!(n > 0)) return; + S.metrics.streamedOutChars = (S.metrics.streamedOutChars || 0) + n; + if (typeof requestAnimationFrame !== 'function') { + renderMetrics(); + return; + } + if (streamMetricsRAF) return; + streamMetricsRAF = requestAnimationFrame(() => { + streamMetricsRAF = null; + renderMetrics(); + }); +} + +function streamedOutTokens() { + return Math.round((S.metrics.streamedOutChars || 0) / 4); +} + // pickTokPerSec prefers decode-ish generation rate when the stream measured // TTFT separately; otherwise end-to-end. 0 / absent means unknown — callers // must not invent a rate from cumulative outputTokens / wall latency. @@ -157,6 +214,7 @@ export function metricsDone(evt) { // is the run-cumulative billing total incl. sub-agent spend. 0/absent = // "not reported": hold the last known value instead of zeroing. if (evt && evt.windowTokens > 0) S.metrics.ctxTokens = evt.windowTokens; + S.metrics.streamedOutChars = 0; metricsApplySpeed(evt); } @@ -168,6 +226,7 @@ export function metricsFromSession(sess) { S.metrics.ctxTokens = 0; // unknown until the next run reports it S.metrics.tokPerSec = 0; S.metrics.tokPerSecKind = ''; + S.metrics.streamedOutChars = 0; if (sess.model) setMetricsModel(sess.model); renderMetrics(); } @@ -178,6 +237,9 @@ export function resetMetrics() { S.metrics.sessOut = 0; S.metrics.tokPerSec = 0; S.metrics.tokPerSecKind = ''; + S.metrics.turnBaseIn = 0; + S.metrics.turnBaseOut = 0; + S.metrics.streamedOutChars = 0; renderMetrics(); } @@ -207,7 +269,8 @@ export function turnStatsHTML(event) { // sessionCostUSD estimates the current session's spend from its totals. export function sessionCostUSD() { if (!S.metrics.pricesConfigured) return null; - return S.metrics.sessIn / 1e6 * S.metrics.inPrice + S.metrics.sessOut / 1e6 * S.metrics.outPrice; + const extraOut = streamedOutTokens(); + return S.metrics.sessIn / 1e6 * S.metrics.inPrice + (S.metrics.sessOut + extraOut) / 1e6 * S.metrics.outPrice; } // turnCostUSD prices one turn's usage (for per-message stats). @@ -234,7 +297,9 @@ export function renderMetrics() { const cluster = document.getElementById('metrics'); if (!cluster) return; const m = S.metrics; - const hasAny = m.ctxTokens > 0 || m.sessIn > 0 || m.sessOut > 0 || m.tokPerSec > 0; + const extraOut = streamedOutTokens(); + const shownCtx = m.ctxTokens + extraOut; + const hasAny = shownCtx > 0 || m.sessIn > 0 || m.sessOut > 0 || extraOut > 0 || m.tokPerSec > 0; cluster.classList.toggle('visible', hasAny); // Context gauge: percentage against the model's window when known, @@ -243,20 +308,20 @@ export function renderMetrics() { const pct = document.getElementById('ctx-pct'); const gauge = document.getElementById('ctx-gauge'); if (gauge) { - const showGauge = m.ctxTokens > 0; + const showGauge = shownCtx > 0; gauge.classList.toggle('on', showGauge); if (showGauge) { let ratio = 0; if (m.maxContext > 0) { - ratio = Math.min(1, m.ctxTokens / m.maxContext); + ratio = Math.min(1, shownCtx / m.maxContext); if (pct) pct.textContent = Math.round(ratio * 100) + '%'; } else if (pct) { - pct.textContent = formatNum(m.ctxTokens); + pct.textContent = formatNum(shownCtx); } if (fill) fill.style.width = (ratio * 100).toFixed(1) + '%'; gauge.classList.toggle('warn', m.maxContext > 0 && ratio > 0.6); gauge.classList.toggle('hot', m.maxContext > 0 && ratio > 0.85); - gauge.title = 'Context: ' + formatNum(m.ctxTokens) + ' tokens' + + gauge.title = 'Context: ' + formatNum(shownCtx) + ' tokens' + (m.maxContext > 0 ? ' of ~' + formatNum(m.maxContext) + ' (' + Math.round(ratio * 100) + '%)' : '') + ' — the engine trims history automatically near the limit'; } diff --git a/cmd/odek/ui/js/metrics.test.js b/cmd/odek/ui/js/metrics.test.js index a9d688c9..17a44923 100644 --- a/cmd/odek/ui/js/metrics.test.js +++ b/cmd/odek/ui/js/metrics.test.js @@ -53,7 +53,7 @@ globalThis.localStorage = (() => { })(); const { S } = await import('./state.js'); -const { formatUSD, sessionCostUSD, renderMetrics, metricsDone, resetMetrics, pickTokPerSec, formatTokPerSec, metricsApplySpeed, metricsResetSpeed, turnStatsHTML } = await import('./metrics.js'); +const { formatUSD, sessionCostUSD, renderMetrics, metricsDone, resetMetrics, pickTokPerSec, formatTokPerSec, metricsApplySpeed, metricsResetSpeed, turnStatsHTML, metricsBeginTurn, metricsLiveUsage, metricsLiveContext, metricsNoteOutput } = await import('./metrics.js'); beforeEach(() => { S.metrics.pricesConfigured = false; @@ -64,6 +64,9 @@ beforeEach(() => { S.metrics.ctxTokens = 0; S.metrics.tokPerSec = 0; S.metrics.tokPerSecKind = ''; + S.metrics.turnBaseIn = 0; + S.metrics.turnBaseOut = 0; + S.metrics.streamedOutChars = 0; byId['cost-chip'].hidden = true; byId['cost-chip'].textContent = ''; byId['speed-chip'].hidden = true; @@ -177,3 +180,36 @@ test('turnStatsHTML includes tok/s from this-call fields, never invented from to const none = turnStatsHTML({ latency: 0.5, inputTokens: 10, outputTokens: 800 }); assert.equal(none.includes('tok/s'), false); }); + +test('usage overlays run-cumulative tokens onto session totals mid-turn', () => { + S.metrics.pricesConfigured = true; + S.metrics.inPrice = 1; + S.metrics.outPrice = 3; + S.metrics.sessIn = 10000; + S.metrics.sessOut = 2000; + metricsBeginTurn(); + metricsLiveContext(5000, 128000); + metricsLiveUsage({ inputTokens: 400, outputTokens: 50, windowTokens: 5000 }); + assert.equal(S.metrics.ctxTokens, 5000); + assert.equal(S.metrics.sessIn, 10400); + assert.equal(S.metrics.sessOut, 2050); + assert.equal(sessionCostUSD().toFixed(5), '0.01655'); + assert.equal(byId['cost-chip'].hidden, false); + assert.equal(byId['ctx-gauge'].classList.contains('on'), true); + assert.equal(byId['ctx-fill'].style.width, '3.9%'); +}); + +test('streamed output chars bump the live cost until the next usage frame', () => { + S.metrics.pricesConfigured = true; + S.metrics.inPrice = 0; + S.metrics.outPrice = 4; + S.metrics.sessIn = 0; + S.metrics.sessOut = 0; + metricsBeginTurn(); + metricsNoteOutput(400); // ~100 tokens + assert.ok(sessionCostUSD() > 0); + metricsLiveUsage({ outputTokens: 20 }); + assert.equal(S.metrics.streamedOutChars, 0); + assert.equal(S.metrics.sessOut, 20); +}); + diff --git a/cmd/odek/ui/js/render.js b/cmd/odek/ui/js/render.js index 2e9c0ed5..21ff0f50 100644 --- a/cmd/odek/ui/js/render.js +++ b/cmd/odek/ui/js/render.js @@ -11,21 +11,14 @@ import { import { markdownToHtml } from './markdown.js'; import { parseUntrusted } from './untrusted.js'; import { classifyToolResult, chipsHtml, prettyToolBody, collectReceipt, formatReceipt } from './tools.js'; +import { metricsNoteOutput } from './metrics.js'; // ── Turn state ── // resetTurnState clears all per-turn streaming/tool/sub-agent state. Called // before a new turn (send), on new session, and when loading a session. export function resetTurnState() { - // Finished-turn reasoning stays collapsed (Bodek calm default). - // Only blocks this renderer marked .live are touched. - messagesEl.querySelectorAll('.thinking-block.live').forEach(block => { - const content = block.querySelector('.thinking-content'); - const toggle = block.querySelector('.thinking-toggle'); - const arrow = toggle ? toggle.querySelector('.arrow') : null; - if (content) content.classList.remove('open'); - if (arrow) arrow.classList.remove('open'); - if (toggle) toggle.setAttribute('aria-expanded', 'false'); - block.classList.remove('live'); + messagesEl.querySelectorAll('.turn-stream.live').forEach((el) => { + el.classList.remove('live'); }); S.streamBuffer = ''; @@ -40,6 +33,8 @@ export function resetTurnState() { S.currentToolBlock = null; S.subagentGroup = null; S.thinkingContentEl = null; + S.thinkingLineEl = null; + S.turnStreamEl = null; S.toolBlockQueues.clear(); S.toolStartQueues.clear(); S.inToolGroup = false; @@ -96,6 +91,10 @@ function paintSpin() { function startSpin() { paintSpin(); + if (!S.loadingTimer) { + S.loadingTimer = setInterval(paintIntent, 1000); + if (S.loadingTimer && typeof S.loadingTimer.unref === 'function') S.loadingTimer.unref(); + } if (spinTimer || reduceMotion()) return; spinTimer = setInterval(() => { if (!S.busy) { stopSpin(); return; } @@ -108,6 +107,10 @@ function startSpin() { function stopSpin() { if (spinTimer) { clearInterval(spinTimer); spinTimer = null; } spinIdx = 0; + if (S.loadingTimer) { + clearInterval(S.loadingTimer); + S.loadingTimer = null; + } } export function paintIntent() { @@ -232,8 +235,14 @@ function attachWakeChip(wrapper) { export function sealTurn(event) { const tid = event && event.turn_id; - const last = (tid && messagesEl.querySelector('.msg.assistant[data-turn-id="' + tid + '"] .bubble')) - || messagesEl.querySelector('.msg.assistant:last-child .bubble'); + let msg = null; + const all = messagesEl.querySelectorAll('.msg.assistant'); + for (let i = all.length - 1; i >= 0; i--) { + if (tid && all[i].dataset && all[i].dataset.turnId && all[i].dataset.turnId !== tid) continue; + msg = all[i]; + break; + } + const last = msg && msg.querySelector ? msg.querySelector('.bubble') : null; if (!last || !S.turnReceipt) return; const line = formatReceipt(S.turnReceipt); if (!line) return; @@ -259,7 +268,6 @@ export function showLoading() { messagesEl.appendChild(el); S.loadingEl = el; setIntent('reasoning'); - S.loadingTimer = setInterval(paintIntent, 1000); pruneMessages(); forceScrollBottom(); } @@ -269,19 +277,20 @@ export function hideLoading() { S.loadingEl.remove(); S.loadingEl = null; } - if (S.loadingTimer) { - clearInterval(S.loadingTimer); - S.loadingTimer = null; - } - stopSpin(); + // The transcript placeholder is gone, but the turn may still be running + // (thinking, tools, approvals). Keep the header/rail spinner moving. + if (S.busy) paintIntent(); + else stopSpin(); } // ── Live turn spine ── -// The model often emits answer tokens in the same LLM message as tool_calls -// (serve E2E: token → tool_call). token_delta would otherwise create the -// answer bubble first and a naive append would paint answer → tools. -// History already uses thinking → tools → answer; insertTurnWork keeps -// the live path on that same spine even when events race. +// One sequential log per turn: collapsed reasoning toggles, partial +// assistant replies (token / token_delta), and tool heads in arrival +// order. Models such as DeepSeek and GLM emit visible assistant text +// before/between tool calls ("Let me look at that file…") — those are +// replies, not reasoning, and each burst is sealed when a tool starts +// so the next tokens open a new row instead of concatenating the whole +// turn into one bubble. Reasoning stays behind ▶ thinking until opened. function hasClass(el, name) { if (!el) return false; if (el.classList && el.classList.contains) return el.classList.contains(name); @@ -293,99 +302,137 @@ function liveAnswerEl() { return (el && el.parentNode === messagesEl) ? el : null; } -function firstTurnOfKind(kindClass) { - const kids = messagesEl.children || []; - for (let i = 0; i < kids.length; i++) { - const el = kids[i]; - if (!hasClass(el, kindClass)) continue; - if (S.currentTurnId) { - if (el.dataset && el.dataset.turnId === S.currentTurnId) return el; - continue; - } - if (hasClass(el, 'live') || el === S.streamBubbleEl) return el; - } - return null; +export function lastAssistantBubble() { + const all = messagesEl.querySelectorAll('.msg.assistant'); + if (!all || !all.length) return null; + const msg = all[all.length - 1]; + return msg.querySelector ? msg.querySelector('.bubble') : null; } -function turnHasThinking(except) { +function streamIsMounted() { + const el = S.turnStreamEl; + if (!el || !messagesEl) return false; const kids = messagesEl.children || []; for (let i = 0; i < kids.length; i++) { - const el = kids[i]; - if (el === except) continue; - if (!hasClass(el, 'thinking-block')) continue; - if (S.currentTurnId) { - if (el.dataset && el.dataset.turnId === S.currentTurnId) return true; - continue; - } - if (hasClass(el, 'live')) return true; + if (kids[i] === el) return true; } return false; } -function parkAnswerLast() { - const answer = liveAnswerEl(); - if (answer) messagesEl.appendChild(answer); +function ensureTurnStream() { + if (streamIsMounted()) return S.turnStreamEl; + hideLoading(); + hideEmptyState(); + const stream = document.createElement('div'); + stream.className = 'turn-stream live'; + if (S.currentTurnId && stream.dataset) stream.dataset.turnId = S.currentTurnId; + messagesEl.appendChild(stream); + S.turnStreamEl = stream; + return stream; } export function insertTurnWork(el, kind) { if (S.currentTurnId && el.dataset) el.dataset.turnId = S.currentTurnId; hideLoading(); - if (kind === 'thinking' && !turnHasThinking(el)) { - const firstTool = firstTurnOfKind('tool-block') || firstTurnOfKind('subagent-group'); - if (firstTool) { - messagesEl.insertBefore(el, firstTool); - parkAnswerLast(); - return; - } - } - const answer = liveAnswerEl(); - if (answer) { - messagesEl.insertBefore(el, answer); + // Tool steps share the sequential log with reasoning and partial + // assistant replies. Approval/clarify cards stay on the transcript + // itself — they are operator chrome. + if (hasClass(el, 'tool-block') || hasClass(el, 'subagent-group') || kind === 'thinking') { + ensureTurnStream().appendChild(el); return; } - messagesEl.appendChild(el); + const answer = liveAnswerEl(); + if (answer) messagesEl.insertBefore(el, answer); + else messagesEl.appendChild(el); } // ── Thinking ── +// Reasoning stays behind a collapsed "▶ thinking" toggle (hidden by +// default). Rows still accumulate inside the block so an expand shows +// fragments in order. Token-sized SSE pieces join the current row; a +// new fragment (or an explicit newline) opens a new row. A tool_call +// seals the block so later reasoning is a new toggle after that tool. +// Visible assistant text is token_delta — it is never parked here. export function streamThinking(content) { - if (!S.thinkingContentEl) { - // Remove cursor from any active stream - removeStreamCursor(); - - // Live reasoning stays collapsed (Bodek calm default). .live marks - // this turn's block so the next prompt can leave history alone. - const block = document.createElement('div'); - block.className = 'thinking-block live'; - block.innerHTML = - '' + - '
' + escapeHtml(content) + '
'; - insertTurnWork(block, 'thinking'); - - S.thinkingContentEl = block.querySelector('.thinking-content'); - hideEmptyState(); - pruneMessages(); - scrollBottom(); - } else { - S.thinkingContentEl.textContent += content; - // Auto-follow the newest line while the block is open — but only when - // it is open, so a user who collapses mid-turn isn't fought. - if (S.thinkingContentEl.classList.contains('open')) { - S.thinkingContentEl.scrollTop = S.thinkingContentEl.scrollHeight; - } - scrollBottom(); + if (!content) return; + removeStreamCursor(); + ensureThinkingBlock(); + appendThinkingFragment(content); + metricsNoteOutput(String(content).length); + scrollBottom(); +} + +function thinkingBlockMounted() { + const el = S.thinkingContentEl; + return !!(el && el.parentNode); +} + +function ensureThinkingBlock() { + if (thinkingBlockMounted()) return; + const block = document.createElement('div'); + block.className = 'thinking-block live'; + block.innerHTML = + '' + + '
'; + ensureTurnStream().appendChild(block); + S.thinkingContentEl = block.querySelector('.thinking-content'); + S.thinkingLineEl = null; + pruneMessages(); +} + +function thinkingContinuation(prev, next) { + if (!prev || next == null) return false; + if (next.startsWith('\n') || prev.endsWith('\n')) return false; + if (/^\s/.test(next)) return true; + const t = next.trimStart(); + if (!t) return true; + if (t.length <= 6 && /^[,.;:!?…'"”)\]}]/.test(t)) return true; + if (/^[a-z0-9]/.test(t) && next.length < 32) return true; + return false; +} + +function fragmentRows(content) { + const raw = String(content); + if (raw.length > 80 && /[.!?][\s]/.test(raw)) { + return raw.split(/\n|(?<=[.!?])\s+/); } + return raw.split('\n'); +} + +function newThinkingLine(text) { + if (!S.thinkingContentEl) return; + const line = document.createElement('div'); + line.className = 'thinking-line'; + line.textContent = text || ''; + S.thinkingContentEl.appendChild(line); + S.thinkingLineEl = line; +} + +function appendThinkingFragment(content) { + fragmentRows(content).forEach((chunk, i) => { + if (i > 0) { + if (chunk) newThinkingLine(chunk); + return; + } + const prev = S.thinkingLineEl ? S.thinkingLineEl.textContent : ''; + if (!S.thinkingLineEl || !thinkingContinuation(prev, chunk)) { + newThinkingLine(chunk); + } else { + S.thinkingLineEl.textContent += chunk; + } + }); } function toggleThinking(el) { + const parent = el.parentElement || el.parentNode; const arrow = el.querySelector('.arrow'); - const content = el.parentElement.querySelector('.thinking-content'); + const content = parent && parent.querySelector ? parent.querySelector('.thinking-content') : null; if (content) { content.classList.toggle('open'); - arrow.classList.toggle('open'); + if (arrow && arrow.classList) arrow.classList.toggle('open'); el.setAttribute('aria-expanded', content.classList.contains('open')); - // Auto-open on first click if (content.classList.contains('open')) { scrollBottom(); } @@ -393,7 +440,12 @@ function toggleThinking(el) { } export function endThinking() { + if (S.thinkingContentEl) { + const block = S.thinkingContentEl.parentElement || S.thinkingContentEl.parentNode; + if (block && block.classList) block.classList.remove('live'); + } S.thinkingContentEl = null; + S.thinkingLineEl = null; } // ── Streaming ── @@ -428,6 +480,29 @@ function ensureStreamBubble() { } } +// sealPartialResponse parks the current assistant bubble in the timeline +// so later token_delta / token fragments open a new row after whatever +// comes next (usually a tool head). Without this, DeepSeek/GLM-style +// mid-turn replies concatenate into one aggregated bubble. +function sealPartialResponse() { + streamFlush(); + if (!S.streamBubbleEl) return; + removeStreamCursor(); + const content = S.streamContentEl; + if (content) { + if (content.classList && content.classList.remove) content.classList.remove('stream-content'); + if (content.removeAttribute) content.removeAttribute('id'); + else content.id = ''; + } + if (S.streamBubbleEl.classList && S.streamBubbleEl.classList.add) { + S.streamBubbleEl.classList.add('partial'); + } + S.streamBubbleEl = null; + S.streamContentEl = null; + S.streamText = ''; + S.streamCursorEl = null; +} + function appendStreamText(text) { ensureStreamBubble(); // Accumulate and re-render the WHOLE answer so far. Fragments must never @@ -437,6 +512,7 @@ function appendStreamText(text) { // (fences, lists) correct while the answer streams. S.streamText += text; S.streamContentEl.innerHTML = markdownToHtml(S.streamText); + metricsNoteOutput(text.length); if (S.streamCursorEl) { const host = streamCursorHost(); if (S.streamCursorEl.parentNode !== host) host.appendChild(S.streamCursorEl); @@ -477,17 +553,17 @@ function startStream() { wrapper.innerHTML = '
' + '
' + assistantSender() + '
' + - '
' + + '
' + '
'; attachWakeChip(wrapper); - messagesEl.appendChild(wrapper); + ensureTurnStream().appendChild(wrapper); S.streamText = ''; S.streamCursorEl = document.createElement('span'); S.streamCursorEl.className = 'stream-cursor'; S.streamBubbleEl = wrapper; - S.streamContentEl = wrapper.querySelector('#stream-content'); - S.streamContentEl.appendChild(S.streamCursorEl); + S.streamContentEl = wrapper.querySelector('.stream-content'); + if (S.streamContentEl && S.streamCursorEl) S.streamContentEl.appendChild(S.streamCursorEl); compactOlderAnswers(wrapper); const bubble = wrapper.querySelector('.bubble'); if (bubble) addCopyButton(bubble); @@ -497,6 +573,7 @@ function startStream() { export function endStream() { removeStreamCursor(); + sealTurnStream(); // The live answer is the latest — never fold it. Older long replies // were already compacted when this bubble opened. S.streamBubbleEl = null; @@ -505,6 +582,9 @@ export function endStream() { S.streamCursorEl = null; S.currentToolBlock = null; S.subagentGroup = null; + S.thinkingContentEl = null; + S.thinkingLineEl = null; + S.turnStreamEl = null; S.toolBlockQueues.clear(); S.toolStartQueues.clear(); S.inToolGroup = false; @@ -516,6 +596,13 @@ export function endStream() { promptEl.focus(); } +function sealTurnStream() { + if (S.turnStreamEl) S.turnStreamEl.classList.remove('live'); + messagesEl.querySelectorAll('.turn-stream.live').forEach((el) => { + el.classList.remove('live'); + }); +} + // ── Message rendering ── export function addMessage(role, content) { hideEmptyState(); @@ -651,6 +738,7 @@ function formatToolArgs(data) { // ── Tool Calls ── export function addToolCall(name, data) { + sealPartialResponse(); removeStreamCursor(); S.inToolGroup = true; @@ -672,7 +760,7 @@ export function addToolCall(name, data) { insertTurnWork(el, 'tool'); S.currentToolBlock = el; - teach('steps', 'tip: click a tool head to expand its output · thinking stays folded'); + teach('steps', 'tip: click a tool head to expand its output'); // Push into per-name FIFO queues so parallel results route correctly. if (!S.toolBlockQueues.has(name)) S.toolBlockQueues.set(name, []); @@ -832,8 +920,9 @@ function subagentCardHTML(i, goal, withStop) { } export function addSubagentGroup(command) { - removeStreamCursor(); if (S.subagentGroup) return; // only one group at a time + sealPartialResponse(); + removeStreamCursor(); let tasks = []; try { @@ -1200,6 +1289,10 @@ export function renderSessionHistory(messages) { if (msg.reasoning_content) renderHistoricalThinking(msg.reasoning_content); + // Content before tools: the model streams the visible reply first, + // then acts. Putting tools first parked every partial after the log. + if (msg.content) renderHistoricalAssistant(msg.content); + const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : []; if (toolCalls.length > 0) { toolCalls.forEach(tc => { @@ -1213,22 +1306,53 @@ export function renderSessionHistory(messages) { } }); } - if (msg.content) { - renderAssistantMessage(msg.content); - } }); } -// renderHistoricalThinking renders a completed reasoning block (collapsed). +function renderHistoricalAssistant(content) { + const wrapper = document.createElement('div'); + wrapper.className = 'msg assistant'; + wrapper.innerHTML = + '
' + + '
' + assistantSender() + '
' + + '
' + markdownToHtml(content) + '
' + + '
'; + attachWakeChip(wrapper); + ensureHistoryStream().appendChild(wrapper); + const bubble = wrapper.querySelector('.bubble'); + if (bubble) addCopyButton(bubble); + compactOlderAnswers(wrapper); +} + +// renderHistoricalThinking appends reasoning rows into the current turn +// stream so history matches the live interleaved log. function renderHistoricalThinking(content) { const block = document.createElement('div'); block.className = 'thinking-block'; block.innerHTML = '' + - '
' + escapeHtml(content) + '
'; - messagesEl.appendChild(block); + '
'; + ensureHistoryStream().appendChild(block); + const host = block.querySelector('.thinking-content'); + String(content || '').split('\n').forEach((line) => { + if (!line) return; + const row = document.createElement('div'); + row.className = 'thinking-line'; + row.textContent = line; + host.appendChild(row); + }); +} + +function ensureHistoryStream() { + const kids = messagesEl.children || []; + const last = kids.length ? kids[kids.length - 1] : null; + if (last && hasClass(last, 'turn-stream')) return last; + const stream = document.createElement('div'); + stream.className = 'turn-stream'; + messagesEl.appendChild(stream); + return stream; } // renderHistoricalToolBlock renders a completed tool call with its result @@ -1246,7 +1370,7 @@ function renderHistoricalToolBlock(name, args, result) { (preview ? ' ' + escapeHtml(preview) + '' : '') + '' + '
' + escapeHtml(formatToolArgs(args)) + '
'; - messagesEl.appendChild(el); + ensureHistoryStream().appendChild(el); if (result) appendToolResultContent(el, result); } @@ -1260,7 +1384,7 @@ function renderHistoricalSubagents(args, output) { const group = document.createElement('div'); group.className = 'subagent-group'; group.innerHTML = subagentHeadHTML(tasks.length) + '
'; - messagesEl.appendChild(group); + ensureHistoryStream().appendChild(group); const grid = group.querySelector('.subagent-grid'); tasks.forEach((task, i) => { @@ -1297,13 +1421,8 @@ function nodeHolds(parent, node) { } function assistantMessages() { - const out = []; - const kids = messagesEl.children || []; - for (let i = 0; i < kids.length; i++) { - const el = kids[i]; - if (hasClass(el, 'msg') && hasClass(el, 'assistant')) out.push(el); - } - return out; + const found = messagesEl.querySelectorAll('.msg.assistant'); + return found ? Array.from(found) : []; } function releaseCollapse(bubble) { diff --git a/cmd/odek/ui/js/state.js b/cmd/odek/ui/js/state.js index 40a81f43..c0b42929 100644 --- a/cmd/odek/ui/js/state.js +++ b/cmd/odek/ui/js/state.js @@ -62,10 +62,12 @@ export const S = { streamBubbleEl: null, streamContentEl: null, streamBuffer: '', // unflushed fragments awaiting the next rAF - streamText: '', // full accumulated answer text for this turn + streamText: '', // text of the current (unsealed) assistant row streamCursorEl: null, streamRAF: null, - thinkingContentEl: null, // current thinking block if any + thinkingContentEl: null, // .thinking-content of the live collapsed reasoning block + thinkingLineEl: null, // current reasoning row inside that block + turnStreamEl: null, // sequential thinking + tool log for this turn // ── Tool call state ── currentToolBlock: null, @@ -96,10 +98,11 @@ export const S = { pendingDeleteId: null, sessionsSig: '', - // ── @-completion ── + // ── @ / slash completion ── lastAtIdx: -1, lastCursor: -1, compQuery: '', + compMode: '', // 'at' | 'slash' // ── Saved nodes for restoring the empty state after clearing ── savedEmptyStateNode: null, diff --git a/cmd/odek/ui/js/utils.js b/cmd/odek/ui/js/utils.js index cbff97f0..26afd598 100644 --- a/cmd/odek/ui/js/utils.js +++ b/cmd/odek/ui/js/utils.js @@ -120,7 +120,7 @@ export function scrollToBottom() { // ── Message cap ── const MAX_MESSAGES = 80; export function pruneMessages() { - const items = messagesEl.querySelectorAll(':scope > .msg, :scope > .tool-block, :scope > .subagent-group, :scope > .thinking-block'); + const items = messagesEl.querySelectorAll(':scope > .msg, :scope > .turn-stream, :scope > .tool-block, :scope > .subagent-group, :scope > .thinking-block'); if (items.length > MAX_MESSAGES) { for (let i = 0, n = items.length - MAX_MESSAGES; i < n; i++) { items[i].remove(); diff --git a/cmd/odek/ui/js/ws.js b/cmd/odek/ui/js/ws.js index 7edacd52..4e5215c5 100644 --- a/cmd/odek/ui/js/ws.js +++ b/cmd/odek/ui/js/ws.js @@ -4,18 +4,19 @@ // hello pushed on connect. import { S, setSessionToken, getSessionToken } from './state.js'; import { getWsToken } from './net.js'; -import { dotEl, statusEl, sendBtn, skeletonEl, messagesEl, modelLabel, promptEl } from './dom.js'; +import { dotEl, statusEl, sendBtn, skeletonEl, modelLabel, promptEl } from './dom.js'; import { formatErrorMessage, showToast, announce, showCancel } from './utils.js'; import { streamToken, streamThinking, streamFlush, endThinking, endStream, addToolCall, addToolResult, addSubagentGroup, completeSubagents, appendSubagentLog, addSystemMessage, updateSubagentState, + lastAssistantBubble, } from './render.js'; import { queueApproval, dismissApproval, clearApprovals, expireApproval } from './approvals.js'; import { queueClarify, dismissClarify, clearClarify, expireClarify } from './clarify.js'; import { loadSessions } from './sessions.js'; import { onPong, onServerInfo, startHeartbeat, stopHeartbeat, notifyUser } from './health.js'; -import { metricsLiveContext, metricsDone, metricsApplySpeed, metricsResetSpeed, turnStatsHTML, setMetricsModel } from './metrics.js'; +import { metricsLiveContext, metricsDone, metricsApplySpeed, metricsResetSpeed, metricsBeginTurn, metricsLiveUsage, turnStatsHTML, setMetricsModel } from './metrics.js'; import { drainQueue } from './input.js'; import { setIntent, openTurn, markWakeTurn, sealTurn, paintIntent } from './render.js'; import { badgeNow } from './panels.js'; @@ -29,14 +30,88 @@ let reconnectDelay = 1000; // reconnect — the previous turn died with the socket, and the input must be // unbricked instead of waiting for a 'done' that never comes. let wasConnected = false; +// One transcript notice per outage (not every backoff retry). +let lostNotified = false; +let droppedBusy = false; +let retryTimer = null; +let retryDeadline = 0; +let reconnectTimer = null; + +function connBannerEl() { + return document.getElementById('conn-banner'); +} + +function stopRetryTick() { + if (retryTimer) { + clearInterval(retryTimer); + retryTimer = null; + } +} + +function paintConnBanner(phase) { + const el = connBannerEl(); + if (!el) return; + el.hidden = false; + if (phase === 'wait') { + const left = Math.max(0, Math.ceil((retryDeadline - Date.now()) / 1000)); + el.textContent = left > 0 + ? '⚠ connection lost · retrying in ' + left + 's' + : '⚠ connection lost · reconnecting…'; + return; + } + el.textContent = '⚠ connection lost · reconnecting…'; +} + +function hideConnBanner() { + stopRetryTick(); + const el = connBannerEl(); + if (!el) return; + el.hidden = true; + el.textContent = ''; +} + +function noteDisconnect() { + stopHeartbeat(); + if (dotEl) dotEl.className = 'dot disconnected'; + if (statusEl) statusEl.textContent = 'reconnecting'; + sendBtn.disabled = true; + + droppedBusy = !!S.busy; + streamFlush(); + endThinking(); + endStream(); + + stopRetryTick(); + retryDeadline = Date.now() + reconnectDelay; + paintConnBanner('wait'); + retryTimer = setInterval(() => paintConnBanner('wait'), 250); + if (retryTimer && typeof retryTimer.unref === 'function') retryTimer.unref(); + + // Lamp is easy to miss (the connected word is hidden). Banner + one + // transcript line fire only after we had a live socket, and only once + // per outage so retries do not spam the log. + if (wasConnected && !lostNotified) { + lostNotified = true; + addSystemMessage('⚠ Connection lost — reconnecting…'); + announce('Connection lost. Reconnecting.'); + } +} export function connect() { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + stopRetryTick(); + if (connBannerEl() && !connBannerEl().hidden) paintConnBanner('try'); const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const token = getWsToken(); const protocols = token ? ['odek.' + token] : []; S.ws = new WebSocket(proto + '//' + location.host + '/ws', protocols); S.ws.onopen = () => { + hideConnBanner(); + lostNotified = false; dotEl.className = 'dot connected'; statusEl.textContent = 'connected'; sendBtn.disabled = false; @@ -50,7 +125,11 @@ export function connect() { // transcript; only the completion is missing. S.busy = false; promptEl.disabled = false; - addSystemMessage('Connection restored — the previous turn ended before completion.'); + addSystemMessage(droppedBusy + ? 'Connection restored — the previous turn ended before completion.' + : 'Connection restored.'); + announce('Connection restored.'); + droppedBusy = false; // Re-adopt the session so the new connection's agent gets the memory // buffer (bodek does this; the old WebUI did not). if (S.sessionId) { @@ -62,17 +141,16 @@ export function connect() { } } wasConnected = true; - // Connection state is visual (status lamp); #sr-status is turn lifecycle only. startHeartbeat(); }; S.ws.onclose = () => { - stopHeartbeat(); - dotEl.className = 'dot disconnected'; - statusEl.textContent = 'reconnecting...'; - sendBtn.disabled = true; - // Reconnect is visual; do not narrate the status lamp. - setTimeout(connect, reconnectDelay); + noteDisconnect(); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, reconnectDelay); + if (reconnectTimer && typeof reconnectTimer.unref === 'function') reconnectTimer.unref(); reconnectDelay = Math.min(reconnectDelay * 2, 30000); }; @@ -97,6 +175,7 @@ export function connect() { S.currentTurnId = event.turn_id || null; S.currentTurnInitiated = event.initiated || 'operator'; metricsResetSpeed(); + metricsBeginTurn(); openTurn(event); if (event.initiated === 'system') markWakeTurn(event); // Wake/remote turns never go through sendPayload — arm busy so @@ -148,9 +227,9 @@ export function connect() { } // ── Live streaming fragments (protocol v2) ── - // token_delta appends to the streaming answer bubble through the same - // rAF-batched pipeline the bulk token event used; thinking_delta - // appends to the collapsible reasoning block. + // token_delta is visible assistant text (not reasoning). Each burst + // is a timeline row; a following tool_call seals that row so the next + // tokens open a new one. thinking_delta is the italic reasoning log. case 'token_delta': if (!sameTurn) break; setIntent('composing'); @@ -217,6 +296,7 @@ export function connect() { // server-resolved model limit — beats the /api/models table. S.runIterations = (S.runIterations || 0) + 1; metricsLiveContext(event.windowTokens, event.maxContextTokens); + metricsLiveUsage(event); metricsApplySpeed(event); break; @@ -268,7 +348,7 @@ export function connect() { // Append per-message stats to the last assistant bubble const statsHTML = turnStatsHTML(event); if (statsHTML) { - const lastAssistant = messagesEl.querySelector('.msg.assistant:last-child .bubble'); + const lastAssistant = lastAssistantBubble(); if (lastAssistant) { const stats = document.createElement('div'); stats.className = 'msg-stats'; @@ -527,5 +607,8 @@ function handleAgentSignal(event) { case 'tool_recovery': showToast('🔁 Tool recovery: ' + (event.tool || '')); break; + case 'tool_running': + setIntent((event.tool ? event.tool + ' · ' : '') + (event.detail || 'running')); + break; } } diff --git a/cmd/odek/ui/js/ws.test.js b/cmd/odek/ui/js/ws.test.js index b916a198..9af29d22 100644 --- a/cmd/odek/ui/js/ws.test.js +++ b/cmd/odek/ui/js/ws.test.js @@ -232,10 +232,18 @@ test('stream-idle errors render a stall hint', () => { // ── usage/done feed the ctx gauge with the parent window (wire v3) ── -test('usage event seeds the gauge with the parent window and server model limit', () => { - deliver({ type: 'usage', windowTokens: 38412, maxContextTokens: 200000, outputTokens: 512 }); - assert.equal(S.metrics.ctxTokens, 38412, 'gauge must show the parent window, not a cumulative'); - assert.equal(S.metrics.maxContext, 200000, 'server-reported model limit must override the models table'); +test('turn_started then usage grows session cost from the pre-turn baseline', () => { + S.metrics.pricesConfigured = true; + S.metrics.inPrice = 1; + S.metrics.outPrice = 3; + S.metrics.sessIn = 10000; + S.metrics.sessOut = 2000; + deliver({ type: 'turn_started', turn_id: 't-cost' }); + deliver({ type: 'usage', windowTokens: 5000, maxContextTokens: 128000, inputTokens: 400, outputTokens: 50 }); + assert.equal(S.metrics.ctxTokens, 5000); + assert.equal(S.metrics.maxContext, 128000); + assert.equal(S.metrics.sessIn, 10400); + assert.equal(S.metrics.sessOut, 2050); }); test('usage without windowTokens never moves the gauge', () => { diff --git a/cmd/odek/ui/style.css b/cmd/odek/ui/style.css index b098085d..bce73013 100644 --- a/cmd/odek/ui/style.css +++ b/cmd/odek/ui/style.css @@ -245,6 +245,9 @@ button:disabled { cursor: not-allowed; } background: var(--red); animation: pulse-red 1.1s var(--ease) infinite; } +.dot.disconnected + #ws-status { + color: var(--amber); +} @keyframes pulse-green { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--green) 45%, transparent); } 70% { box-shadow: 0 0 0 6px transparent; } @@ -1194,6 +1197,18 @@ button:disabled { cursor: not-allowed; } /* ═══════════════════════════════════════════════════════════════════════ MAIN — chat column ═══════════════════════════════════════════════════════════════════════ */ +#conn-banner { + flex-shrink: 0; + padding: 7px 16px; + text-align: center; + font-size: var(--fs-xs); + letter-spacing: .06em; + color: var(--amber); + background: color-mix(in srgb, var(--amber) 10%, var(--bg-0)); + border-bottom: 1px solid var(--line); +} +#conn-banner[hidden] { display: none; } + #main { display: flex; flex-direction: column; @@ -1527,7 +1542,28 @@ body.light .msg.user .sender { color: var(--amber-hi); } letter-spacing: .06em; } -/* ── Thinking / reasoning ──────────────────────────────────────────── */ +/* ── Turn stream — sequential reasoning + tool log ─────────────────── */ +.turn-stream { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: var(--space-4); + width: 100%; +} +.turn-stream .thinking-block, +.turn-stream .tool-block, +.turn-stream .subagent-group, +.turn-stream .approval-card, +.turn-stream .clarify-card { + margin-bottom: 0; +} +.turn-stream > .msg { + margin-bottom: var(--space-2); + animation: none; +} +.turn-stream > .msg:last-child { margin-bottom: 0; } + +/* ── Thinking / reasoning (legacy collapsed block + stream rows) ────── */ .thinking-block { margin-bottom: var(--space-3); border: none; @@ -1570,6 +1606,12 @@ body.light .msg.user .sender { color: var(--amber-hi); } overflow-y: auto; } .thinking-content.open { display: block; animation: fade-slide var(--dur-slow) var(--ease) both; } +.thinking-line { + display: block; + margin: 0 0 0.4em; +} +.thinking-line:last-child { margin-bottom: 0; } +.thinking-content .thinking-line { margin: 0 0 0.4em; } /* ── Streaming cursor ──────────────────────────────────────────────── */ .stream-cursor { @@ -2816,4 +2858,9 @@ body.theme-classic {} transition-duration: .01ms !important; scroll-behavior: auto !important; } + /* Wait-state spinners are status, not decoration — keep them moving. */ + .tb-spinner.running { + animation-duration: .8s !important; + animation-iteration-count: infinite !important; + } } diff --git a/cmd/odek/usage_frame_test.go b/cmd/odek/usage_frame_test.go index 78f5dabb..adfefc17 100644 --- a/cmd/odek/usage_frame_test.go +++ b/cmd/odek/usage_frame_test.go @@ -16,6 +16,7 @@ func TestUsageFrame_IncludesWindow(t *testing.T) { frame, ok := usageFrame(loop.IterationInfo{ WindowTokens: 38412, MaxContextTokens: 200000, + InputTokens: 18432, OutputTokens: 512, CallDurationMs: 8100, CallInputTokens: 18432, @@ -37,6 +38,9 @@ func TestUsageFrame_IncludesWindow(t *testing.T) { if frame["outputTokens"] != 512 { t.Errorf("outputTokens = %v, want 512 (run-cumulative)", frame["outputTokens"]) } + if frame["inputTokens"] != 18432 { + t.Errorf("inputTokens = %v, want 18432 (run-cumulative)", frame["inputTokens"]) + } if frame["callDurationMs"] != int64(8100) || frame["callOutputTokens"] != 78 || frame["tokensPerSecond"] != 9.6 { t.Errorf("this-call fields missing: %v", frame) } diff --git a/docs/WEBUI.md b/docs/WEBUI.md index 2a62c2f4..f6b91aa4 100644 --- a/docs/WEBUI.md +++ b/docs/WEBUI.md @@ -126,7 +126,7 @@ The bundled client is a **zero-framework command center** — same EMBER languag - **Model + thinking pickers** — top-bar selects for the active model and reasoning depth (`disabled` / `low` / `medium` / `high`). Thinking is persisted as `odek_thinking` and sent on every prompt. - **Command palette (`⌘K` / `Ctrl+K`)** — fuzzy jump to commands, sessions, models, and inspector workspaces -- **Slash verbs** — composer handles only `/new` `/clear` `/retry` `/cancel` `/stop`; everything else lives in the palette (typed `shutdown` death-gate stays a modal) +- **Slash commands** — typing `/` in the composer opens the same completions as the palette (commands, sessions, models). `Enter`/`Tab` runs the selected item; `/new` `/clear` `/retry` `/cancel` `/stop` `/help` and the other palette verbs also dispatch on Enter. Typed `shutdown` death-gate stays a modal - **Prompt queue** — `Enter` while a turn is running holds the next prompt (reorder / delete in the strip above the composer); the queue drains automatically on `done` - **Three themes** — `ember-dark` · `ember-light` · `high-contrast` (health popover or palette) - **Desktop notifications** — opt-in; titles/bodies are truncated and never include raw tool arguments @@ -135,17 +135,16 @@ The bundled client is a **zero-framework command center** — same EMBER languag ### Chat interface - **Plain text input** — type your prompt, press `Enter` to send (or queue), `Shift+Enter` for a newline -- **Slash / palette** — composer `/` verbs are `/new` `/clear` `/retry` `/cancel` `/stop`; other commands live in `⌘K`; printable keys always type in the composer. Bodek-style JIT tips (`💡 tip: …`) dwell 8s the first time a queue, tool step, or swarm appears. +- **Slash / palette** — typing `/` in the composer autocompletes the same items as `⌘K` (commands, sessions, models). Palette verbs also dispatch from the composer on Enter. Printable keys always type in the composer. Bodek-style JIT tips (`💡 tip: …`) dwell 8s the first time a queue, tool step, or swarm appears. - **Long replies** — the latest assistant answer is never folded. Older overflows get a sticky `Show more ↓` / `Show less ↑` fold under the content (not a floating pill). History reload keeps the last reply open. - **Multi-turn sessions** — each prompt continues the same conversation (the inspector **Sessions** tab lists history) - **Turn receipts** — Bodek-style coding receipt on the `⬡ odek` head (`touched N · +A −D · tests`), not a tool count - **Wake turns** — `turn_started.initiated=system` renders as `⬡ odek · wake` on the assistant head, never as a user message -- **Busy spinner** — Bodek braille spinner in the top bar, composer rail, and transcript while a turn runs (`reasoning · 4s`), so the default view always shows the agent is working +- **Busy spinner** — Bodek braille spinner in the top bar, composer rail, and transcript while a turn runs (`reasoning · 4s`). The spinner keeps moving across thinking, tools, and approval waits — removing the transcript placeholder does not freeze it. - **Live plan & jobs** — Bodek header chips (`plan 1/4`, `● 2 jobs` / `✗ job`) stay visible when idle; click opens the inspector Now tab. While a turn runs the status rail appends `▸ plan 2/5 · · ⛔N`. A `plan` tool_call patches the snapshot on that frame; REST confirms after `tool_result`. - **Markdown** — hand-written tokenizer (zero deps, no CDN): headings, lists, task lists, quotes, GFM tables, fenced code with copy, emphasis, strikethrough, allowlisted links/autolinks. Images are caption links, never `` (CSP + no remote fetch). Streaming-safe: an open fence still renders; an open `**` stays literal. - **Live streaming** *(on by default; `--no-stream` / `stream: false` / `ODEK_STREAM=false`)* — answer and reasoning fragments arrive as they are generated (`token_delta` / `thinking_delta`) and render through the same rAF-batched pipeline; streaming state is in the health popover. Providers that reject SSE fall back silently to the bulk path. -- **Reasoning blocks** — calm default: collapsed `thinking` toggle (Bodek `^E` model). Opened blocks stay open; history starts collapsed -- **Tool call blocks** — Bodek heads: `▶` + `▸/✓/✗` + monochrome glyph + steel name + faint args. Live and history share one spine: thinking → tools → answer (token_delta cannot race ahead of `tool_call`). Args and results stay collapsed until the head is opened; long results truncate behind “show all” +- **Reasoning, partial replies, and tools** — one sequential log per turn. Reasoning is collapsed behind a **▶ thinking** toggle (hidden by default; click to expand). Visible assistant text (`token_delta` / `token`, including DeepSeek/GLM mid-turn “Let me look…” replies) is a timeline row sealed when a tool starts so the next tokens open a new row instead of concatenating the turn. Tool heads sit in that same stream in arrival order. Tool args and results stay collapsed until the head is opened; long results truncate behind “show all”. History replays the same interleaved log. - **Sub-agent swarm** — `delegate_tasks` uses the same spine as a tool step (`▶ ▸ ⑂ delegate_tasks · 1/2 agents`) plus an always-on chip strip (`⟳ SA1 `). Click a chip (or the head) for the `⎿` log and summary; the inspector Now tab still lists every agent. - **Inline approvals** — dangerous operations block the run and show a decision card (risk class, plain-language explanation, verbatim command). Friction mode (after 3 same-class approvals in 60s) requires typing the literal word `approve`; `trust session` is hidden for destructive/blocked/unknown classes. Keyboard: `A` approve, `D` deny, `T` trust - **Clarify** — when the agent needs a decision, a question card waits for a typed answer (5 minute wait). Bound to that WebSocket session; headless REST runs do not register the tool. @@ -157,7 +156,9 @@ The bundled client is a **zero-framework command center** — same EMBER languag ### Server status & heartbeat -The top-bar status group (`connected / reconnecting…`) doubles as a **health popover** — click it for version, uptime, model, sandbox/streaming state, live connection count, WebSocket round-trip latency, session tokens/cost, theme, notifications, and lifetime usage. An application-level heartbeat (`ping`/`pong` every 20s) measures RTT and detects dead links early; the server also pushes `keepalive` every 20s so idle proxies do not drop a silent thinking turn. +The top-bar status group (`connected / reconnecting`) doubles as a **health popover** — click it for version, uptime, model, sandbox/streaming state, live connection count, WebSocket round-trip latency, session tokens/cost, theme, notifications, and lifetime usage. An application-level heartbeat (`ping`/`pong` every 20s) measures RTT and detects dead links early; the server also pushes `keepalive` every 20s so idle proxies do not drop a silent thinking turn. + +A dropped socket is **not** lamp-only. The top-bar word turns amber (`reconnecting`), a sticky `#conn-banner` sits above the transcript (`connection lost · retrying in Ns`) until the socket is open again, and one system line is written per outage (`⚠ Connection lost — reconnecting…`). Restore writes `Connection restored` (or notes that an in-flight turn ended). Sending while down toasts instead of failing silently. Retries do not spam the log. ### Inspector (`⌘.`) @@ -183,15 +184,15 @@ Each response shows **per-message token stats** appended to the assistant bubble The **status strip** shows a live context-window gauge once a run reports data, plus a session-cost chip when prices are configured: -- **Context gauge** — a hairline bar and tabular `%` from per-iteration `usage` events, against the model's window size from `/api/models`. Amber above 60%, red above 85%; a `context_trimmed` signal flashes the gauge. Without a known window size it shows raw tokens. Hover for exact numbers and the trimming note. -- **Session tokens** — `⇥ in ↦ out`, cumulative session totals from `done` events (health popover). -- **Session cost** — Bodek header chip `$0.201` (`#cost-chip`), estimated from the session's token totals and the resolved prices (`/api/limits`: `model_prices` per-model override, flat pair fallback — the client-side twin of `limits.ResolvePrices`). Hidden entirely when no prices are configured. Click opens the health popover for the token breakdown. +- **Context gauge** — a hairline bar and tabular `%` from per-iteration `usage` events (and a streamed-token estimate between them), against the model's window size from `/api/models` or `usage.maxContextTokens`. Amber above 60%, red above 85%; a `context_trimmed` signal flashes the gauge. Without a known window size it shows raw tokens. Hover for exact numbers and the trimming note. +- **Session tokens** — `⇥ in ↦ out`, cumulative session totals. `usage.inputTokens` / `usage.outputTokens` overlay this-run spend on the pre-turn baseline so the numbers move mid-turn; `done` replaces them with the persisted session totals (health popover). +- **Session cost** — Bodek header chip `$0.201` (`#cost-chip`), estimated from those live session totals and the resolved prices (`/api/limits`: `model_prices` per-model override, flat pair fallback — the client-side twin of `limits.ResolvePrices`). Hidden entirely when no prices are configured. Click opens the health popover for the token breakdown. Each assistant message's stats footer also gains a per-turn cost (`◈`) when prices are configured, and the inline loading indicator shows **live elapsed time and iteration count** (`thinking · 7s · iter 2`) while the run is in flight. `/api/usage` aggregates server-lifetime totals with cost. ### Inline loading indicator -While a turn is running, Bodek's braille spinner (`⠋⠙⠹…`, 12 fps) appears in the top bar (`#busy-spin`) and the composer status rail (`#intent-rail`); operator sends also get a compact `.loading-indicator` under the last message. The label stays stable (`reasoning` → tool progress → `composing`) with elapsed time and the live plan strip — it does not cycle verbs. `prefers-reduced-motion` freezes the glyph at `⠿`. Wake and remote turns arm the top-bar and rail. The chrome clears on `done` / cancel / error. +While a turn is running, Bodek's braille spinner (`⠋⠙⠹…`, 12 fps) appears in the top bar (`#busy-spin`) and the composer status rail (`#intent-rail`); operator sends also get a compact `.loading-indicator` under the last message until the first thinking/tool/answer lands. The spinner keeps moving while tools, approvals, or long LLM calls are in flight — dropping the transcript placeholder does not freeze it. The label stays stable (`reasoning` → tool progress → `composing`) with elapsed time and the live plan strip — it does not cycle verbs. `prefers-reduced-motion` freezes the braille glyph at `⠿`; tool-head CSS spinners still rotate (they are wait-state status). Wake and remote turns arm the top-bar and rail. The chrome clears on `done` / cancel / error. ### Smart autoscroll @@ -662,7 +663,7 @@ The UI communicates entirely over a single WebSocket at `/ws`. Messages are newl | `subagent_log` | Sub-agent progress within `delegate_tasks` | `task_idx`, `task_id`, `name`, `event`, `data` (redacted, capped 8 KiB) | | `subagent_state` | Per-task sub-agent lifecycle transition (`started`/`active`/`finished`); child emits `subagent_started`/`subagent_progress`/`subagent_finished` records over the same protocol. A sub-agent killed without reporting (user stop, turn cancel, timeout, flood-kill, crash) gets its terminal `finished` transition emitted by the parent instead, so cards never stay `running` | `task_idx`, `task_id`, `run_key`, `phase`, `status`, `step`, `iterations`, `tool`, `duration_seconds`, `tokens_used` | | `done` | Agent finishes — **emitted only after the session is persisted**, so refreshing session state on `done` is race-free | `latency` (seconds), `windowTokens` (final parent conversation window), `maxContextTokens` (resolved model limit; omitted when unknown), `inputTokens` (run-cumulative input across all calls, incl. sub-agent spend — billing), `outputTokens`, `cacheCreationTokens`, `cacheReadTokens`, `cachedTokens`, `sessionContextTokens`, `sessionOutputTokens`, plus optional last-call speed fields (see [Generation speed](#generation-speed-external-clients)) and `llmDurationMs` (sum of main think-step LLM calls this run) | -| `usage` | After each LLM iteration of a running turn | `windowTokens`, `maxContextTokens` (omitted when the model limit is unknown), `outputTokens` (run-cumulative) (camelCase — `windowTokens` is the parent-only window size that drives the metrics gauge; child rounds and side-call summaries never move it), plus optional this-call speed fields (see [Generation speed](#generation-speed-external-clients)) | +| `usage` | After each LLM iteration of a running turn | `windowTokens`, `maxContextTokens` (omitted when the model limit is unknown), `inputTokens` (run-cumulative billing input), `outputTokens` (run-cumulative) (camelCase — `windowTokens` is the parent-only window size that drives the metrics gauge; child rounds and side-call summaries never move it), plus optional this-call speed fields (see [Generation speed](#generation-speed-external-clients)) | | `error` | Agent or server error | `message` | | `approval_request` | Agent needs user approval for dangerous operation; blocks the run up to `timeout_seconds` (60s default) | `id`, `risk` (class name), `command` (or resource), `description`, `is_operation`, `allow_trust`, `friction`, `friction_approvals`, `timeout_seconds` (the effective server-enforced wait in seconds — render the card's countdown from it) | | `approval_ack` | Server confirms an approval response | `id`, `action` | @@ -702,6 +703,11 @@ Example event sequence: {"type":"done","latency":4.2,"turn_id":"t_9f86d081884c7d65"} ``` +Each `token` / `token_delta` burst is its own assistant row in the turn +log. A `tool_call` seals the current row so the next tokens open a new +one — DeepSeek/GLM-style “Let me look…” then a tool then more text is a +timeline, not one concatenated bubble. + With streaming enabled (`--stream` / `stream: true` / `ODEK_STREAM=true`) the answer arrives as `token_delta` / `thinking_delta` fragments as the provider generates them, and the bulk `token` / final-answer `thinking` re-sends are @@ -810,7 +816,7 @@ match as plain text. The bundled WebUI implements this in - **Design**: self-contained "EMBER" theme — electric amber on a near-void page, type instead of cards, a 36px status-strip topbar, inspector workspaces (sessions / now / memory / ops), and ≤200ms color/opacity answers. Design tokens are CSS custom properties in `style.css` (`--bg-0…4`, `--amber`, `--line`, spacing/radius/motion scales) with a full light-mode variant and `prefers-reduced-motion` support; the Azeret Mono variable font is self-hosted from `ui/fonts/` so the UI works offline. Reading text caps at 13px (`--fs-base`) — user and assistant share it; markdown headings stay at that size; chrome is 11–12px. Display sizes (`--fs-lg` / `--fs-xl`) are wordmarks only. Inputs use 16px on coarse pointers so iOS Safari does not zoom. - **Streaming**: fragments (`token_delta`/`thinking_delta`) and bulk `token` events share one rAF-batched render pipeline - **DOM budget**: the message list is capped at 80 elements (`MAX_MESSAGES`); older messages are pruned -- **Resilience**: auto-reconnect with exponential backoff (1s doubling to a 30s cap, reset after a stable connection) plus the 20s application heartbeat and the server's 20s `keepalive` +- **Resilience**: auto-reconnect with exponential backoff (1s doubling to a 30s cap, reset after a stable connection) plus the 20s application heartbeat and the server's 20s `keepalive`. A drop is visible: amber top-bar word, sticky `#conn-banner` with retry countdown, one transcript line per outage, and a composer toast if you send while down. - **Tests**: `node --test cmd/odek/ui/js/` (markdown + untrusted-envelope goldens, and api.js request-shape E2E against a mocked fetch) plus Go-side WebUI E2E (`cmd/odek/webui_e2e_test.go`): asset/header/CSP contract, token injection, JS↔HTML id and JS↔CSS class contracts, and full client journeys (streamed WS run, headless run with the remote-approval bridge, kick, pin/export) through the production mux (`newServeMux` — the same constructor `serveCmd` uses, so tests cannot drift from the real mounting) ## Tips From e29a4a230d3047c66b05f819590418be4faa67ee Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:28:45 +0200 Subject: [PATCH 2/3] fix(webui): do not treat paths as slash commands; drop cards on disconnect Slash complete only matches known command prefixes, so /Users/... is sent as a prompt instead of running the first palette row. A dropped socket clears approval and clarify cards without draining the prompt queue. Co-authored-by: Cursor --- cmd/odek/ui/js/approvals.js | 7 +++++-- cmd/odek/ui/js/commands.js | 20 +++++++++++++++++++ cmd/odek/ui/js/input.js | 34 ++++++++++++++++++-------------- cmd/odek/ui/js/lifecycle.test.js | 32 ++++++++++++++++++++++++++++++ cmd/odek/ui/js/ws.js | 6 ++++++ 5 files changed, 82 insertions(+), 17 deletions(-) diff --git a/cmd/odek/ui/js/approvals.js b/cmd/odek/ui/js/approvals.js index afbcef4c..662b3850 100644 --- a/cmd/odek/ui/js/approvals.js +++ b/cmd/odek/ui/js/approvals.js @@ -172,12 +172,15 @@ export function removeActiveApprovalCard() { // clearApprovals drops every pending request and the rendered card — used on // session switch / new session, where pending approvals belong to the // previous run. This is the only teardown that resets all three pieces of -// approval state together (queue + card + active id). -export function clearApprovals() { +// approval state together (queue + card + active id). Pass { drain: false } +// when the socket is already dead so a queued prompt is not sent into the +// void; reconnect calls drainQueue once the link is up. +export function clearApprovals(opts) { S.approvalQueue.length = 0; removeActiveApprovalCard(); S.activeApprovalId = null; syncSweep(); + if (opts && opts.drain === false) return; if (S.drainQueue) S.drainQueue(); } diff --git a/cmd/odek/ui/js/commands.js b/cmd/odek/ui/js/commands.js index 8effcd84..9528ede2 100644 --- a/cmd/odek/ui/js/commands.js +++ b/cmd/odek/ui/js/commands.js @@ -39,6 +39,26 @@ const SLASH_VERBS = new Set([ 'now', 'memory', 'ops', 'plan', 'jobs', 'agents', 'skills', 'tools', 'runs', 'events', 'config', 'sessions', 'session', 'stop', ]); + +// isComposerSlashInput is true for a leading command token (`/` or `/ne`) +// and false for filesystem paths (`/Users/...`, `\Windows\...`). Extra +// slashes or a token that is not a prefix of a known verb are paths, so +// Enter/Tab send the line instead of running the first palette row. +export function isComposerSlashInput(val, cursor) { + if (!val || !val.startsWith('/') || val.includes('\n')) return false; + const end = cursor == null ? val.length : cursor; + const before = val.slice(0, end); + if (!before.startsWith('/')) return false; + const rest = before.slice(1); + if (/\s/.test(rest)) return false; + if (rest.includes('/') || rest.includes('\\')) return false; + if (!rest) return true; + const token = rest.toLowerCase(); + for (const v of SLASH_VERBS) { + if (v.startsWith(token)) return true; + } + return false; +} const TAB_WS = { sessions: 'sessions', session: 'sessions', plan: 'now', jobs: 'now', agents: 'now', now: 'now', diff --git a/cmd/odek/ui/js/input.js b/cmd/odek/ui/js/input.js index 59d1ad60..0d1cde09 100644 --- a/cmd/odek/ui/js/input.js +++ b/cmd/odek/ui/js/input.js @@ -11,7 +11,7 @@ import { showCancel, toggleShortcuts, SCROLL_THRESHOLD, teach, showToast, } from './utils.js'; import { addMessage, resetTurnState, showLoading, paintIntent } from './render.js'; -import { maybeHandleComposerEnter, paletteItems } from './commands.js'; +import { maybeHandleComposerEnter, paletteItems, isComposerSlashInput } from './commands.js'; function queueId() { return 'q' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); @@ -69,7 +69,9 @@ function moveQueue(i, delta) { export function drainQueue() { // Do not auto-send through a live approval — the operator is still deciding. + // A dead socket must not consume the queue either; reconnect drains it. if (S.busy || S.activeApprovalId || !S.promptQueue.length) return; + if (!S.ws || S.ws.readyState !== WebSocket.OPEN) return; const next = S.promptQueue.shift(); renderQueueStrip(); sendPayload(next.text, next.attachments, next.display, next.model, next.thinking); @@ -329,9 +331,13 @@ promptEl.addEventListener('keydown', (e) => { return; } if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - selectCompletion(); - return; + if (S.compMode === 'slash' && !isComposerSlashInput(promptEl.value, promptEl.selectionStart)) { + hideCompletion(); + } else { + e.preventDefault(); + selectCompletion(); + return; + } } if (e.key === 'Escape') { e.preventDefault(); @@ -402,9 +408,13 @@ promptEl.addEventListener('keydown', (e) => { // Tab for completion selection if (e.key === 'Tab' && completionEl.classList.contains('visible')) { - e.preventDefault(); - selectCompletion(); - return; + if (S.compMode === 'slash' && !isComposerSlashInput(promptEl.value, promptEl.selectionStart)) { + hideCompletion(); + } else { + e.preventDefault(); + selectCompletion(); + return; + } } }); @@ -442,14 +452,8 @@ function hideCompletion() { } function trySlashCompletion(val, cursor) { - if (!val.startsWith('/') || val.includes('\n')) return false; - const before = val.slice(0, cursor); - if (!before.startsWith('/')) return false; - if (/\s/.test(before.slice(1))) { - hideCompletion(); - return true; - } - const q = before.slice(1); + if (!isComposerSlashInput(val, cursor)) return false; + const q = val.slice(1, cursor); slashRows = paletteItems(q); S.compMode = 'slash'; S.lastAtIdx = 0; diff --git a/cmd/odek/ui/js/lifecycle.test.js b/cmd/odek/ui/js/lifecycle.test.js index 00439e2f..4011e990 100644 --- a/cmd/odek/ui/js/lifecycle.test.js +++ b/cmd/odek/ui/js/lifecycle.test.js @@ -397,6 +397,20 @@ test('send while disconnected toasts instead of failing silently', () => { assert.ok(byId.toast.classList.contains('show')); }); +test('disconnect drops pending approval and clarify cards', () => { + const sock = S.ws; + sock.onopen(); + deliver({ type: 'approval_request', id: 'apr-drop', risk: 'local_write', command: 'echo hi', allow_trust: true }); + assert.equal(S.activeApprovalId, 'apr-drop'); + deliver({ type: 'clarify_request', id: 'cl-drop', question: 'which one?', timeout_seconds: 30 }); + sock.onclose(); + assert.equal(S.activeApprovalId, null, 'approval card must not outlive the socket'); + assert.equal(S.approvalQueue.length, 0); + assert.equal(S.activeApprovalCard, null); + assert.equal(byId.messages.querySelectorAll('.approval-card').length, 0, 'clarify card gone too'); + health.stopHeartbeat(); +}); + // ── F-B1: delegate_tasks tool_result must not route into other tool blocks. ── test('delegate_tasks tool_result completes the group without touching other blocks', () => { deliver({ type: 'tool_call', name: 'shell', data: '"ls"' }); @@ -781,4 +795,22 @@ test('composer Enter dispatches palette slash verbs, not filesystem paths', () = assert.deepEqual(calls, ['help']); assert.equal(commands.maybeHandleComposerEnter('/Users/src/main.go'), false); assert.ok(commands.paletteItems('new').some((i) => i.id === 'new')); + assert.equal(commands.isComposerSlashInput('/new', 4), true); + assert.equal(commands.isComposerSlashInput('/Users/src/main.go', 18), false); + assert.equal(commands.isComposerSlashInput('/tmp', 4), false); + assert.equal(commands.isComposerSlashInput('/', 1), true); +}); + +test('slash completion does not open for a filesystem path', async () => { + const prompt = byId.prompt; + prompt.value = '/Users/src/main.go'; + prompt.selectionStart = prompt.value.length; + try { + prompt.dispatch('input'); + await new Promise((r) => setTimeout(r, 200)); + assert.equal(byId.completion.classList.contains('visible'), false, 'path must not open slash popup'); + assert.notEqual(S.compMode, 'slash'); + } finally { + prompt.value = ''; + } }); diff --git a/cmd/odek/ui/js/ws.js b/cmd/odek/ui/js/ws.js index 4e5215c5..a7c192cf 100644 --- a/cmd/odek/ui/js/ws.js +++ b/cmd/odek/ui/js/ws.js @@ -80,6 +80,11 @@ function noteDisconnect() { streamFlush(); endThinking(); endStream(); + // Same teardown as cancelled/error: the approval/clarify wait died with + // the socket. Leave the prompt queue; drainQueue no-ops until restore. + clearApprovals({ drain: false }); + clearClarify(); + stopPlanLiveIfIdle(); stopRetryTick(); retryDeadline = Date.now() + reconnectDelay; @@ -139,6 +144,7 @@ export function connect() { auth_token: getSessionToken(S.sessionId) || undefined, }); } + drainQueue(); } wasConnected = true; startHeartbeat(); From d07bb2e7345461aed1465e63bb41da181adee1d7 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:33:10 +0200 Subject: [PATCH 3/3] fix(webui): add CSS rules for partial replies and live stream content The CSS-contract test requires every class used in JS to have a rule; without .partial and .stream-content, CI's go test job failed. Co-authored-by: Cursor --- cmd/odek/ui/style.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cmd/odek/ui/style.css b/cmd/odek/ui/style.css index bce73013..65385008 100644 --- a/cmd/odek/ui/style.css +++ b/cmd/odek/ui/style.css @@ -1563,6 +1563,14 @@ body.light .msg.user .sender { color: var(--amber-hi); } } .turn-stream > .msg:last-child { margin-bottom: 0; } +/* Live token_delta host. min-height keeps the empty bubble from collapsing + before the first fragment paints. */ +.stream-content { min-height: 1.65em; } + +/* Mid-turn reply sealed when a tool starts so the next tokens open a new + row. Slightly quieter than the live/final answer. */ +.msg.partial { opacity: .88; } + /* ── Thinking / reasoning (legacy collapsed block + stream rows) ────── */ .thinking-block { margin-bottom: var(--space-3);