diff --git a/cmd/odek/serve.go b/cmd/odek/serve.go
index 0cad764..2838308 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 c405af9..77a3c04 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/approvals.js b/cmd/odek/ui/js/approvals.js
index afbcef4..662b385 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 34934a5..9528ede 100644
--- a/cmd/odek/ui/js/commands.js
+++ b/cmd/odek/ui/js/commands.js
@@ -33,7 +33,32 @@ 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',
+]);
+
+// 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',
@@ -240,10 +265,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 b99c55a..0d1cde0 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, 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);
@@ -79,7 +81,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;
@@ -326,13 +331,17 @@ 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();
- completionEl.classList.remove('visible');
+ hideCompletion();
return;
}
}
@@ -392,16 +401,20 @@ promptEl.addEventListener('input', () => {
});
promptEl.addEventListener('keydown', (e) => {
- if (e.key === '@') {
+ if (e.key === '@' || e.key === '/') {
if (completionTimer) clearTimeout(completionTimer);
completionTimer = setTimeout(checkCompletion, 150);
}
// 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;
+ }
}
});
@@ -409,8 +422,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 +443,57 @@ completionEl.addEventListener('mousemove', (e) => {
});
});
+let slashRows = [];
+
+function hideCompletion() {
+ completionEl.classList.remove('visible');
+ S.compMode = '';
+ slashRows = [];
+}
+
+function trySlashCompletion(val, cursor) {
+ if (!isComposerSlashInput(val, cursor)) return false;
+ const q = val.slice(1, cursor);
+ 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) =>
+ `
';
- 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 40a81f4..c0b4292 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 cbff97f..26afd59 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 7edacd5..a7c192c 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,93 @@ 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();
+ // 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;
+ 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 +130,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) {
@@ -60,19 +144,19 @@ export function connect() {
auth_token: getSessionToken(S.sessionId) || undefined,
});
}
+ drainQueue();
}
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 +181,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 +233,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 +302,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 +354,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 +613,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 b916a19..9af29d2 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 b098085..6538500 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,36 @@ 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; }
+
+/* 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);
border: none;
@@ -1570,6 +1614,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 +2866,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 78f5dab..adfefc1 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 2a62c2f..f6b91aa 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