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
This commit is contained in:
Nathan Esquenazi
2026-04-04 02:04:41 +00:00
parent b1d687ba22
commit 2fb2ddeaaa
8 changed files with 44 additions and 3 deletions

View File

@@ -632,6 +632,7 @@ _SETTINGS_DEFAULTS = {
'default_model': DEFAULT_MODEL, 'default_model': DEFAULT_MODEL,
'default_workspace': str(DEFAULT_WORKSPACE), 'default_workspace': str(DEFAULT_WORKSPACE),
'send_key': 'enter', # 'enter' or 'ctrl+enter' '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 '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 = { _SETTINGS_ENUM_VALUES = {
'send_key': {'enter', 'ctrl+enter'}, 'send_key': {'enter', 'ctrl+enter'},
} }
_SETTINGS_BOOL_KEYS = {'show_token_usage'}
def save_settings(settings: dict) -> dict: def save_settings(settings: dict) -> dict:
"""Save settings to disk. Returns the merged settings. Ignores unknown keys.""" """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 # Validate enum-constrained keys
if k in _SETTINGS_ENUM_VALUES and v not in _SETTINGS_ENUM_VALUES[k]: if k in _SETTINGS_ENUM_VALUES and v not in _SETTINGS_ENUM_VALUES[k]:
continue continue
# Coerce bool keys
if k in _SETTINGS_BOOL_KEYS:
v = bool(v)
current[k] = v current[k] = v
SETTINGS_FILE.write_text( SETTINGS_FILE.write_text(
json.dumps(current, ensure_ascii=False, indent=2), json.dumps(current, ensure_ascii=False, indent=2),

View File

@@ -170,6 +170,11 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
persist_user_message=msg_text, persist_user_message=msg_text,
) )
s.messages = result.get('messages') or s.messages 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) s.title = title_from(s.messages, s.title)
# Read token/cost usage from the agent object (if available) # Read token/cost usage from the agent object (if available)
input_tokens = getattr(agent, 'session_prompt_tokens', 0) or 0 input_tokens = getattr(agent, 'session_prompt_tokens', 0) or 0

View File

@@ -308,7 +308,7 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
(async()=>{ (async()=>{
// Load send key preference // 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 // Fetch active profile
try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';} try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';}
// Update profile chip label immediately // Update profile chip label immediately

View File

@@ -8,6 +8,7 @@ const COMMANDS=[
{name:'model', desc:'Switch model (e.g. /model gpt-4o)', fn:cmdModel, arg:'model_name'}, {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:'workspace', desc:'Switch workspace by name', fn:cmdWorkspace, arg:'name'},
{name:'new', desc:'Start a new chat session', fn:cmdNew}, {name:'new', desc:'Start a new chat session', fn:cmdNew},
{name:'usage', desc:'Toggle token usage display on/off', fn:cmdUsage},
]; ];
function parseCommand(text){ function parseCommand(text){
@@ -98,6 +99,19 @@ async function cmdNew(){
showToast('New session created'); 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 ─────────────────────────────────────────────────── // ── Autocomplete dropdown ───────────────────────────────────────────────────
let _cmdSelectedIdx=-1; let _cmdSelectedIdx=-1;

View File

@@ -324,6 +324,13 @@
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option> <option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
</select> </select>
</div> </div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowTokenUsage" style="width:15px;height:15px;accent-color:var(--accent)">
Show token usage after responses
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Displays input/output token count below each assistant reply. Also toggled with <code>/usage</code>.</div>
</div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px"> <div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword">Access Password</label> <label for="settingsPassword">Access Password</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Enter a new password to set or change it. Leave blank to keep current setting.</div> <div style="font-size:11px;color:var(--muted);margin-bottom:6px">Enter a new password to set or change it. Leave blank to keep current setting.</div>

View File

@@ -146,6 +146,9 @@ async function send(){
} }
if(S.session&&S.session.session_id===activeSid){ if(S.session&&S.session.session_id===activeSid){
S.session=d.session;S.messages=d.session.messages||[]; 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.usage) S.lastUsage=d.usage;
if(d.session.tool_calls&&d.session.tool_calls.length){ if(d.session.tool_calls&&d.session.tool_calls.length){
S.toolCalls=d.session.tool_calls.map(tc=>({...tc,done:true})); S.toolCalls=d.session.tool_calls.map(tc=>({...tc,done:true}));

View File

@@ -958,6 +958,8 @@ async function loadSettingsPanel(){
// Send key preference // Send key preference
const sendKeySel=$('settingsSendKey'); const sendKeySel=$('settingsSendKey');
if(sendKeySel) sendKeySel.value=settings.send_key||'enter'; 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) // Password field: always blank (we don't send hash back)
const pwField=$('settingsPassword'); const pwField=$('settingsPassword');
if(pwField) pwField.value=''; if(pwField) pwField.value='';
@@ -979,16 +981,19 @@ async function saveSettings(){
const model=($('settingsModel')||{}).value; const model=($('settingsModel')||{}).value;
const workspace=($('settingsWorkspace')||{}).value; const workspace=($('settingsWorkspace')||{}).value;
const sendKey=($('settingsSendKey')||{}).value; const sendKey=($('settingsSendKey')||{}).value;
const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked;
const pw=($('settingsPassword')||{}).value; const pw=($('settingsPassword')||{}).value;
const body={}; const body={};
if(model) body.default_model=model; if(model) body.default_model=model;
if(workspace) body.default_workspace=workspace; if(workspace) body.default_workspace=workspace;
if(sendKey) body.send_key=sendKey; if(sendKey) body.send_key=sendKey;
body.show_token_usage=showTokenUsage;
// Password: only act if the field has content; blank = leave auth unchanged // Password: only act if the field has content; blank = leave auth unchanged
if(pw && pw.trim()){ if(pw && pw.trim()){
try{ try{
await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})}); await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})});
window._sendKey=sendKey||'enter'; window._sendKey=sendKey||'enter';
window._showTokenUsage=showTokenUsage;
showToast('Settings saved (password set — login now required)'); showToast('Settings saved (password set — login now required)');
toggleSettings(); toggleSettings();
return; return;
@@ -997,6 +1002,8 @@ async function saveSettings(){
try{ try{
await api('/api/settings',{method:'POST',body:JSON.stringify(body)}); await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
window._sendKey=sendKey||'enter'; window._sendKey=sendKey||'enter';
window._showTokenUsage=showTokenUsage;
renderMessages();
showToast('Settings saved'); showToast('Settings saved');
toggleSettings(); toggleSettings();
}catch(e){ }catch(e){

View File

@@ -488,8 +488,8 @@ function renderMessages(){
else inner.appendChild(frag); else inner.appendChild(frag);
} }
} }
// Render usage badge on the last assistant message row (if usage data exists) // Render usage badge on the last assistant message row (if enabled and usage data exists)
if(S.session&&(S.session.input_tokens||S.session.output_tokens)){ if(window._showTokenUsage&&S.session&&(S.session.input_tokens||S.session.output_tokens)){
const rows=inner.querySelectorAll('.msg-row'); const rows=inner.querySelectorAll('.msg-row');
let lastAssist=null; let lastAssist=null;
for(let i=rows.length-1;i>=0;i--){if(rows[i].dataset.role==='assistant'){lastAssist=rows[i];break;}} for(let i=rows.length-1;i>=0;i--){if(rows[i].dataset.role==='assistant'){lastAssist=rows[i];break;}}