From 905e295f5daf9e976d54f0f592c52078c2eec6eb Mon Sep 17 00:00:00 2001 From: Nikolas Syring Date: Thu, 18 Dec 2025 21:58:34 +0100 Subject: [PATCH] feat: Add live context window tracking with visual progress bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract token usage from message_delta events in backend - Calculate context usage percentage (input + cache tokens / 200k) - Add context_update, compacting_started/finished, compact_boundary events - Display progress bar that fills up as context is consumed - Color-coded warnings (green→yellow→red) based on usage - Show compacting status indicator when auto-compact runs - Display system messages for compact start/finish with usage stats 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/server.js | 50 +++++++++++++++++++ frontend/src/components/StatusBar.jsx | 61 ++++++++++++++++++------ frontend/src/contexts/SessionContext.jsx | 55 +++++++++++++++++++++ frontend/src/hooks/useClaudeSession.js | 43 +++++++++++++++++ 4 files changed, 195 insertions(+), 14 deletions(-) diff --git a/backend/server.js b/backend/server.js index 1fc8941..edec57b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -851,6 +851,56 @@ wss.on('connection', async (ws, req) => { // Ignore write errors } + // Extract context window metrics from stream_event message_delta + if (event.type === 'stream_event' && event.event?.type === 'message_delta') { + const usage = event.event.usage; + if (usage) { + // Total input tokens = input + cache_read + cache_creation + // This represents the full context sent to the model + const totalInputTokens = (usage.input_tokens || 0) + + (usage.cache_read_input_tokens || 0) + + (usage.cache_creation_input_tokens || 0); + // Context window is typically 200000 for Opus + const contextWindow = 200000; + const percentUsed = Math.min(100, Math.round((totalInputTokens / contextWindow) * 100)); + + if (DEBUG) { + console.log(`[${sessionId}] Context: ${totalInputTokens}/${contextWindow} tokens (${percentUsed}%)`); + } + + sendToClient('context_update', { + tokensUsed: totalInputTokens, + contextWindow, + percentUsed, + percentRemaining: 100 - percentUsed + }); + } + } + + // Detect compacting status from system events + if (event.type === 'system' && event.subtype === 'status') { + if (event.status === 'compacting') { + sendToClient('compacting_started', {}); + } else if (event.status === null) { + // Compacting finished + sendToClient('compacting_finished', {}); + } + } + + // Extract pre_tokens from compact_boundary event + if (event.type === 'system' && event.subtype === 'compact_boundary') { + const preTokens = event.compact_metadata?.pre_tokens; + const trigger = event.compact_metadata?.trigger; + if (preTokens) { + sendToClient('compact_boundary', { + preTokens, + trigger, + contextWindow: 200000, + percentUsedBeforeCompact: Math.round((preTokens / 200000) * 100) + }); + } + } + sendToClient('claude_event', { event }); } catch (e) { // Non-JSON output, send as raw diff --git a/frontend/src/components/StatusBar.jsx b/frontend/src/components/StatusBar.jsx index 9765531..327f3db 100644 --- a/frontend/src/components/StatusBar.jsx +++ b/frontend/src/components/StatusBar.jsx @@ -23,7 +23,8 @@ export function StatusBar({ sessionStats, isProcessing, connected, permissionMod cacheCreationTokens = 0, numTurns = 0, contextWindow = 200000, - contextLeftPercent = null, // From Claude's "Context left until auto-compact: X%" message + contextLeftPercent = null, // Live context tracking from message_delta events + tokensUsed = 0, isCompacting = false, } = sessionStats || {}; @@ -31,9 +32,27 @@ export function StatusBar({ sessionStats, isProcessing, connected, permissionMod const currentMode = PERMISSION_MODES.find(m => m.value === permissionMode) || PERMISSION_MODES[0]; const ModeIcon = currentMode.icon; - // Context remaining: Use Claude's reported value if available, otherwise don't show - // Claude only reports this when context is getting low (< ~15%) + // Context used percentage (0-100) - bar fills up as context is consumed const contextRemaining = contextLeftPercent; + const contextUsedPercent = contextRemaining !== null ? (100 - contextRemaining) : 0; + + // Determine color based on usage (higher usage = more warning) + const getContextColor = (usedPercent) => { + if (usedPercent === null) return 'bg-dark-600'; + if (usedPercent >= 95) return 'bg-red-500'; + if (usedPercent >= 85) return 'bg-red-400'; + if (usedPercent >= 70) return 'bg-yellow-400'; + if (usedPercent >= 50) return 'bg-yellow-300'; + return 'bg-green-400'; + }; + + const getContextTextColor = (usedPercent) => { + if (usedPercent === null) return 'text-dark-500'; + if (usedPercent >= 95) return 'text-red-400 font-medium animate-pulse'; + if (usedPercent >= 85) return 'text-red-400'; + if (usedPercent >= 70) return 'text-yellow-400'; + return 'text-green-400'; + }; // Format cost const formatCost = (cost) => { @@ -135,19 +154,33 @@ export function StatusBar({ sessionStats, isProcessing, connected, permissionMod )} - {/* Right side: Token usage */} -
- {/* Context status - only shown when Claude reports it (context getting low) */} + {/* Right side: Context usage with progress bar */} +
+ {/* Context progress bar - always visible when we have data */} {contextRemaining !== null && (
- Context: - - {contextRemaining}% left - + +
+ {/* Progress bar container - fills up as context is used */} +
+
+
+ {/* Percentage text - shows how full the context is */} + + {contextUsedPercent}% + +
+
+ )} + + {/* Show "waiting" state if no context data yet */} + {contextRemaining === null && connected && ( +
+ + Context: --
)}
diff --git a/frontend/src/contexts/SessionContext.jsx b/frontend/src/contexts/SessionContext.jsx index c7628a7..3680b82 100644 --- a/frontend/src/contexts/SessionContext.jsx +++ b/frontend/src/contexts/SessionContext.jsx @@ -590,6 +590,61 @@ export function SessionProvider({ children }) { } break; + case 'context_update': + // Live context window metrics from message_delta events + updateSession(sessionId, (session) => ({ + stats: { + ...(session.stats || {}), + tokensUsed: data.tokensUsed, + contextWindow: data.contextWindow, + contextLeftPercent: data.percentRemaining, + }, + })); + break; + + case 'compacting_started': + console.log(`[${sessionId}] Compacting started`); + updateSession(sessionId, (session) => ({ + stats: { + ...(session.stats || {}), + isCompacting: true, + }, + })); + addMessage(sessionId, { + type: 'system', + content: '⏳ Context compacting in progress...', + }); + break; + + case 'compacting_finished': + console.log(`[${sessionId}] Compacting finished`); + updateSession(sessionId, (session) => ({ + stats: { + ...(session.stats || {}), + isCompacting: false, + }, + })); + break; + + case 'compact_boundary': + console.log(`[${sessionId}] Compact boundary:`, data); + // Context was compacted - reset the metrics + updateSession(sessionId, (session) => ({ + stats: { + ...(session.stats || {}), + isCompacting: false, + tokensUsed: 0, + contextLeftPercent: 100, + lastCompactTrigger: data.trigger, + lastCompactPreTokens: data.preTokens, + }, + })); + addMessage(sessionId, { + type: 'system', + content: `✅ Context compacted (${data.trigger}). Was at ${data.percentUsedBeforeCompact}% usage.`, + }); + break; + default: console.log(`[${sessionId}] Unhandled message type:`, type, data); } diff --git a/frontend/src/hooks/useClaudeSession.js b/frontend/src/hooks/useClaudeSession.js index 8121bd5..c4538d6 100644 --- a/frontend/src/hooks/useClaudeSession.js +++ b/frontend/src/hooks/useClaudeSession.js @@ -229,6 +229,49 @@ export function useClaudeSession() { blockedPath: data.blockedPath }); break; + + case 'context_update': + // Live context window metrics from message_delta events + setSessionStats(prev => ({ + ...prev, + tokensUsed: data.tokensUsed, + contextWindow: data.contextWindow, + contextLeftPercent: data.percentRemaining + })); + break; + + case 'compacting_started': + console.log('Compacting started'); + setSessionStats(prev => ({ ...prev, isCompacting: true })); + setMessages(prev => [...prev, { + type: 'system', + content: '⏳ Context compacting in progress...', + timestamp: Date.now() + }]); + break; + + case 'compacting_finished': + console.log('Compacting finished'); + setSessionStats(prev => ({ ...prev, isCompacting: false })); + break; + + case 'compact_boundary': + console.log('Compact boundary:', data); + // Context was compacted - reset the metrics + setSessionStats(prev => ({ + ...prev, + isCompacting: false, + tokensUsed: 0, + contextLeftPercent: 100, + lastCompactTrigger: data.trigger, + lastCompactPreTokens: data.preTokens + })); + setMessages(prev => [...prev, { + type: 'system', + content: `✅ Context compacted (${data.trigger}). Was at ${data.percentUsedBeforeCompact}% usage.`, + timestamp: Date.now() + }]); + break; } }, []);