feat: Add live context window tracking with visual progress bar

- 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 <noreply@anthropic.com>
This commit is contained in:
2025-12-18 21:58:34 +01:00
parent 86a1d84ea1
commit 905e295f5d
4 changed files with 195 additions and 14 deletions

View File

@@ -851,6 +851,56 @@ wss.on('connection', async (ws, req) => {
// Ignore write errors // 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 }); sendToClient('claude_event', { event });
} catch (e) { } catch (e) {
// Non-JSON output, send as raw // Non-JSON output, send as raw

View File

@@ -23,7 +23,8 @@ export function StatusBar({ sessionStats, isProcessing, connected, permissionMod
cacheCreationTokens = 0, cacheCreationTokens = 0,
numTurns = 0, numTurns = 0,
contextWindow = 200000, 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, isCompacting = false,
} = sessionStats || {}; } = 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 currentMode = PERMISSION_MODES.find(m => m.value === permissionMode) || PERMISSION_MODES[0];
const ModeIcon = currentMode.icon; const ModeIcon = currentMode.icon;
// Context remaining: Use Claude's reported value if available, otherwise don't show // Context used percentage (0-100) - bar fills up as context is consumed
// Claude only reports this when context is getting low (< ~15%)
const contextRemaining = contextLeftPercent; 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 // Format cost
const formatCost = (cost) => { const formatCost = (cost) => {
@@ -135,20 +154,34 @@ export function StatusBar({ sessionStats, isProcessing, connected, permissionMod
)} )}
</div> </div>
{/* Right side: Token usage */} {/* Right side: Context usage with progress bar */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
{/* Context status - only shown when Claude reports it (context getting low) */} {/* Context progress bar - always visible when we have data */}
{contextRemaining !== null && ( {contextRemaining !== null && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-dark-500">Context:</span> <Database className="w-3 h-3 text-dark-500" />
<span className={`${ <div className="flex items-center gap-2">
contextRemaining <= 5 ? 'text-red-400 font-medium animate-pulse' : {/* Progress bar container - fills up as context is used */}
contextRemaining <= 15 ? 'text-red-400' : <div className="w-24 h-2 bg-dark-700 rounded-full overflow-hidden" title={`${tokensUsed.toLocaleString()} / ${contextWindow.toLocaleString()} tokens used`}>
contextRemaining <= 30 ? 'text-yellow-400' : 'text-green-400' <div
}`}> className={`h-full transition-all duration-300 ${getContextColor(contextUsedPercent)}`}
{contextRemaining}% left style={{ width: `${contextUsedPercent}%` }}
/>
</div>
{/* Percentage text - shows how full the context is */}
<span className={`min-w-[45px] text-right ${getContextTextColor(contextUsedPercent)}`}>
{contextUsedPercent}%
</span> </span>
</div> </div>
</div>
)}
{/* Show "waiting" state if no context data yet */}
{contextRemaining === null && connected && (
<div className="flex items-center gap-2 text-dark-500">
<Database className="w-3 h-3" />
<span>Context: --</span>
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -590,6 +590,61 @@ export function SessionProvider({ children }) {
} }
break; 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: default:
console.log(`[${sessionId}] Unhandled message type:`, type, data); console.log(`[${sessionId}] Unhandled message type:`, type, data);
} }

View File

@@ -229,6 +229,49 @@ export function useClaudeSession() {
blockedPath: data.blockedPath blockedPath: data.blockedPath
}); });
break; 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;
} }
}, []); }, []);