From 2fb2ddeaaaa38f4f05a712f5dcaa0d174a784498 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Sat, 4 Apr 2026 02:04:41 +0000 Subject: [PATCH] feat: token usage toggle (setting + /usage command) + timestamp fixes Token usage display: - Add 'show_token_usage' boolean to settings (default: false, off by default) - Settings panel: checkbox 'Show token usage after responses' - /usage slash command: instant toggle with toast feedback, persists to server, updates checkbox if settings panel is open, re-renders messages - Boot: load show_token_usage alongside send_key on startup - ui.js: gate usage badge on window._showTokenUsage flag Timestamps: - streaming.py: stamp 'timestamp' on every message that lacks one at conversation completion; old messages (no timestamp field) now get a wall-clock time the first time they're touched by a new turn - messages.js: stamp _ts on the last assistant message at done-event time so the time shows immediately on the current turn before next reload - Timestamps already render in the UI (Sprint 14): faint time on each role header line, full opacity on hover, full date in title tooltip --- api/config.py | 5 +++++ api/streaming.py | 5 +++++ static/boot.js | 2 +- static/commands.js | 14 ++++++++++++++ static/index.html | 7 +++++++ static/messages.js | 3 +++ static/panels.js | 7 +++++++ static/ui.js | 4 ++-- 8 files changed, 44 insertions(+), 3 deletions(-) diff --git a/api/config.py b/api/config.py index dc399b1..8ef8bbf 100644 --- a/api/config.py +++ b/api/config.py @@ -632,6 +632,7 @@ _SETTINGS_DEFAULTS = { 'default_model': DEFAULT_MODEL, 'default_workspace': str(DEFAULT_WORKSPACE), 'send_key': 'enter', # 'enter' or 'ctrl+enter' + 'show_token_usage': False, # show input/output token badge below assistant messages 'password_hash': None, # SHA-256 hash; None = auth disabled } @@ -651,6 +652,7 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'} _SETTINGS_ENUM_VALUES = { 'send_key': {'enter', 'ctrl+enter'}, } +_SETTINGS_BOOL_KEYS = {'show_token_usage'} def save_settings(settings: dict) -> dict: """Save settings to disk. Returns the merged settings. Ignores unknown keys.""" @@ -669,6 +671,9 @@ def save_settings(settings: dict) -> dict: # Validate enum-constrained keys if k in _SETTINGS_ENUM_VALUES and v not in _SETTINGS_ENUM_VALUES[k]: continue + # Coerce bool keys + if k in _SETTINGS_BOOL_KEYS: + v = bool(v) current[k] = v SETTINGS_FILE.write_text( json.dumps(current, ensure_ascii=False, indent=2), diff --git a/api/streaming.py b/api/streaming.py index 29e4f48..c22aea4 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -170,6 +170,11 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta persist_user_message=msg_text, ) s.messages = result.get('messages') or s.messages + # Stamp 'timestamp' on any messages that don't have one yet + _now = time.time() + for _m in s.messages: + if isinstance(_m, dict) and not _m.get('timestamp') and not _m.get('_ts'): + _m['timestamp'] = int(_now) s.title = title_from(s.messages, s.title) # Read token/cost usage from the agent object (if available) input_tokens = getattr(agent, 'session_prompt_tokens', 0) or 0 diff --git a/static/boot.js b/static/boot.js index cff23c6..9aab685 100644 --- a/static/boot.js +++ b/static/boot.js @@ -308,7 +308,7 @@ document.querySelectorAll('.suggestion').forEach(btn=>{ (async()=>{ // Load send key preference - try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';}catch(e){window._sendKey='enter';} + try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;}catch(e){window._sendKey='enter';window._showTokenUsage=false;} // Fetch active profile try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';} // Update profile chip label immediately diff --git a/static/commands.js b/static/commands.js index 9ab2d7c..33c24b7 100644 --- a/static/commands.js +++ b/static/commands.js @@ -8,6 +8,7 @@ const COMMANDS=[ {name:'model', desc:'Switch model (e.g. /model gpt-4o)', fn:cmdModel, arg:'model_name'}, {name:'workspace', desc:'Switch workspace by name', fn:cmdWorkspace, arg:'name'}, {name:'new', desc:'Start a new chat session', fn:cmdNew}, + {name:'usage', desc:'Toggle token usage display on/off', fn:cmdUsage}, ]; function parseCommand(text){ @@ -98,6 +99,19 @@ async function cmdNew(){ showToast('New session created'); } +async function cmdUsage(){ + const next=!window._showTokenUsage; + window._showTokenUsage=next; + try{ + await api('/api/settings',{method:'POST',body:JSON.stringify({show_token_usage:next})}); + }catch(e){} + // Update the settings checkbox if the panel is open + const cb=$('settingsShowTokenUsage'); + if(cb) cb.checked=next; + renderMessages(); + showToast('Token usage '+(next?'on':'off')); +} + // ── Autocomplete dropdown ─────────────────────────────────────────────────── let _cmdSelectedIdx=-1; diff --git a/static/index.html b/static/index.html index b501569..be31f31 100644 --- a/static/index.html +++ b/static/index.html @@ -324,6 +324,13 @@ +
+ +
Displays input/output token count below each assistant reply. Also toggled with /usage.
+
Enter a new password to set or change it. Leave blank to keep current setting.
diff --git a/static/messages.js b/static/messages.js index 82ac5f4..f1b68dd 100644 --- a/static/messages.js +++ b/static/messages.js @@ -146,6 +146,9 @@ async function send(){ } if(S.session&&S.session.session_id===activeSid){ S.session=d.session;S.messages=d.session.messages||[]; + // Stamp _ts on the last assistant message if it has no timestamp + const lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant'); + if(lastAsst&&!lastAsst._ts&&!lastAsst.timestamp) lastAsst._ts=Date.now()/1000; if(d.usage) S.lastUsage=d.usage; if(d.session.tool_calls&&d.session.tool_calls.length){ S.toolCalls=d.session.tool_calls.map(tc=>({...tc,done:true})); diff --git a/static/panels.js b/static/panels.js index a0552d2..eab2d5d 100644 --- a/static/panels.js +++ b/static/panels.js @@ -958,6 +958,8 @@ async function loadSettingsPanel(){ // Send key preference const sendKeySel=$('settingsSendKey'); if(sendKeySel) sendKeySel.value=settings.send_key||'enter'; + const showUsageCb=$('settingsShowTokenUsage'); + if(showUsageCb) showUsageCb.checked=!!settings.show_token_usage; // Password field: always blank (we don't send hash back) const pwField=$('settingsPassword'); if(pwField) pwField.value=''; @@ -979,16 +981,19 @@ async function saveSettings(){ const model=($('settingsModel')||{}).value; const workspace=($('settingsWorkspace')||{}).value; const sendKey=($('settingsSendKey')||{}).value; + const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked; const pw=($('settingsPassword')||{}).value; const body={}; if(model) body.default_model=model; if(workspace) body.default_workspace=workspace; if(sendKey) body.send_key=sendKey; + body.show_token_usage=showTokenUsage; // Password: only act if the field has content; blank = leave auth unchanged if(pw && pw.trim()){ try{ await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})}); window._sendKey=sendKey||'enter'; + window._showTokenUsage=showTokenUsage; showToast('Settings saved (password set — login now required)'); toggleSettings(); return; @@ -997,6 +1002,8 @@ async function saveSettings(){ try{ await api('/api/settings',{method:'POST',body:JSON.stringify(body)}); window._sendKey=sendKey||'enter'; + window._showTokenUsage=showTokenUsage; + renderMessages(); showToast('Settings saved'); toggleSettings(); }catch(e){ diff --git a/static/ui.js b/static/ui.js index 7c7c8fd..c409b8f 100644 --- a/static/ui.js +++ b/static/ui.js @@ -488,8 +488,8 @@ function renderMessages(){ else inner.appendChild(frag); } } - // Render usage badge on the last assistant message row (if usage data exists) - if(S.session&&(S.session.input_tokens||S.session.output_tokens)){ + // Render usage badge on the last assistant message row (if enabled and usage data exists) + if(window._showTokenUsage&&S.session&&(S.session.input_tokens||S.session.output_tokens)){ const rows=inner.querySelectorAll('.msg-row'); let lastAssist=null; for(let i=rows.length-1;i>=0;i--){if(rows[i].dataset.role==='assistant'){lastAssist=rows[i];break;}}