Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions cmd/odek/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions cmd/odek/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ <h4 class="ws-head">config</h4>
<div id="panels-overlay"></div>

<main id="main">
<div id="conn-banner" hidden role="status" aria-live="polite"></div>
<div id="loading-skeleton">
<div class="skeleton-bar"></div>
<div class="skeleton-bar"></div>
Expand All @@ -175,7 +176,7 @@ <h4 class="ws-head">config</h4>
<div class="es-hints">
<button type="button" class="es-tip" data-hint="palette"><kbd>⌘K</kbd> commands, sessions, models</button>
<button type="button" class="es-tip" data-hint="at"><kbd>@</kbd> mention a file or past session</button>
<button type="button" class="es-tip" data-hint="slash"><kbd>/</kbd> new · clear · retry · cancel</button>
<button type="button" class="es-tip" data-hint="slash"><kbd>/</kbd> commands (same as ⌘K)</button>
<button type="button" class="es-tip" data-hint="inspector"><kbd>⌘.</kbd> inspector · <kbd>?</kbd> shortcuts</button>
</div>
</div>
Expand All @@ -201,7 +202,7 @@ <h4 class="ws-head">config</h4>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</button>
<input type="file" id="file-input" multiple style="display:none"/>
<span class="composer-hint">@ mention a file or session · /new /clear /retry · ⌘K commands</span>
<span class="composer-hint">@ mention a file or session · / commands · ⌘K palette</span>
<button id="send-btn" type="button" disabled>↵</button>
</div>
</div>
Expand All @@ -224,7 +225,7 @@ <h3 id="shortcuts-title">Keyboard shortcuts</h3>
<div class="shortcut-row"><span class="sc-label">Command palette</span><span class="sc-key">⌘K</span></div>
<div class="shortcut-row"><span class="sc-label">Send / queue while busy</span><span class="sc-key">Enter</span></div>
<div class="shortcut-row"><span class="sc-label">New line</span><span class="sc-key">Shift+Enter</span></div>
<div class="shortcut-row"><span class="sc-label">Slash verbs</span><span class="sc-key">/new /clear /retry /cancel /stop</span></div>
<div class="shortcut-row"><span class="sc-label">Slash commands</span><span class="sc-key">/ (same as ⌘K)</span></div>
<div class="shortcut-row"><span class="sc-label">File/session reference</span><span class="sc-key">@</span></div>
<div class="shortcut-row"><span class="sc-label">Copy last reply</span><span class="sc-key">⌘⇧C</span></div>
<div class="shortcut-row"><span class="sc-label">Retry last prompt</span><span class="sc-key">Alt+R</span></div>
Expand Down
7 changes: 5 additions & 2 deletions cmd/odek/ui/js/approvals.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
35 changes: 32 additions & 3 deletions cmd/odek/ui/js/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
}

Expand Down
104 changes: 86 additions & 18 deletions cmd/odek/ui/js/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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;

Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -392,25 +401,37 @@ 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;
}
}
});

// ── @ Completion ──
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) => {
Expand All @@ -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) =>
`<div class="comp-item${i === 0 ? ' selected' : ''}" role="option" aria-selected="${i === 0}" data-idx="${i}">
<span class="comp-type">${escapeAttr(r.kind)}</span>
<span class="comp-label">${escapeHtml(r.title)}</span>
<span class="comp-detail">${escapeHtml(r.hint || '')}</span>
</div>`
).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;
Expand All @@ -451,12 +506,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) {
Expand All @@ -473,7 +528,7 @@ async function checkCompletion() {

completionEl.classList.add('visible');
} catch {
completionEl.classList.remove('visible');
hideCompletion();
}
}

Expand All @@ -491,10 +546,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) {
Expand Down
Loading
Loading