feat: context usage indicator in composer footer

Shows a compact bar + label in the composer footer after the first
response, displaying input/output token counts, context window fill
percentage, and estimated cost. Bar turns yellow >50% and red >75%.

Updates on every response completion via the existing usage data from
the done SSE event. Hidden until first response (no usage data yet).

Inspired by PR #75 (@MartinNielsenDev).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Esquenazi
2026-04-04 14:11:28 -07:00
parent e2d24f57ac
commit 516062bd41
4 changed files with 38 additions and 1 deletions

View File

@@ -264,6 +264,10 @@
</svg> </svg>
</button> </button>
</div> </div>
<div class="ctx-indicator" id="ctxIndicator" style="display:none" title="Context window usage">
<span class="ctx-bar-wrap"><span class="ctx-bar" id="ctxBar"></span></span>
<span class="ctx-label" id="ctxLabel"></span>
</div>
<div class="composer-right"> <div class="composer-right">
<button class="send-btn" id="btnSend" title="Send message" style="display:none"> <button class="send-btn" id="btnSend" title="Send message" style="display:none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>

View File

@@ -149,7 +149,7 @@ async function send(){
// Stamp _ts on the last assistant message if it has no timestamp // Stamp _ts on the last assistant message if it has no timestamp
const lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant'); const lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant');
if(lastAsst&&!lastAsst._ts&&!lastAsst.timestamp) lastAsst._ts=Date.now()/1000; 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;_syncCtxIndicator(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}));
} else { } else {

View File

@@ -183,6 +183,13 @@
textarea#msg::placeholder{color:var(--muted);} textarea#msg::placeholder{color:var(--muted);}
.composer-footer{display:flex;align-items:center;justify-content:space-between;padding:6px 10px 10px;} .composer-footer{display:flex;align-items:center;justify-content:space-between;padding:6px 10px 10px;}
.composer-left{display:flex;gap:2px;align-items:center;} .composer-left{display:flex;gap:2px;align-items:center;}
/* Context usage indicator */
.ctx-indicator{display:flex;align-items:center;gap:6px;padding:2px 4px;flex-shrink:1;min-width:0;}
.ctx-bar-wrap{width:70px;height:5px;border-radius:3px;background:rgba(255,255,255,.08);overflow:hidden;flex-shrink:0;}
.ctx-bar{display:block;height:100%;border-radius:3px;transition:width .4s ease,background .4s ease;min-width:2px;background:var(--teal);}
.ctx-bar.ctx-mid{background:#e6a817;}
.ctx-bar.ctx-high{background:#e05252;}
.ctx-label{font-size:9px;color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums;}
.composer-right{display:flex;gap:6px;align-items:center;} .composer-right{display:flex;gap:6px;align-items:center;}
.icon-btn{width:34px;height:34px;border-radius:8px;background:none;border:none;color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;transition:all .15s;} .icon-btn{width:34px;height:34px;border-radius:8px;background:none;border:none;color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;transition:all .15s;}
.icon-btn{opacity:.75;} .icon-btn{opacity:.75;}

View File

@@ -84,6 +84,32 @@ let _scrollPinned=true;
})(); })();
function _fmtTokens(n){if(!n||n<0)return'0';if(n>=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'k';return String(n);} function _fmtTokens(n){if(!n||n<0)return'0';if(n>=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'k';return String(n);}
// Context usage indicator in composer footer
function _syncCtxIndicator(usage){
const el=$('ctxIndicator');
if(!el)return;
const inTok=usage.input_tokens||0;
const outTok=usage.output_tokens||0;
const total=inTok+outTok;
if(!total){el.style.display='none';return;}
el.style.display='';
// Estimate context window fill (assume 128k default, common for most models)
const ctxWindow=128000;
const pct=Math.min(100,Math.round((inTok/ctxWindow)*100));
const bar=$('ctxBar');
const label=$('ctxLabel');
if(bar){
bar.style.width=pct+'%';
bar.className='ctx-bar'+(pct>75?' ctx-high':pct>50?' ctx-mid':'');
}
if(label){
const cost=usage.estimated_cost;
let text=`${_fmtTokens(inTok)} in \u00b7 ${_fmtTokens(outTok)} out`;
if(cost) text+=` \u00b7 $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
label.textContent=text;
}
}
function scrollIfPinned(){ function scrollIfPinned(){
if(!_scrollPinned) return; if(!_scrollPinned) return;
const el=$('messages'); const el=$('messages');