feat: display context usage in UI for Codex and Claude Code (#1775)

* feat: display context usage in UI for Codex executor

- Extract token usage data from Codex execution logs.
- Track usage state within the `EntriesContext`.
- Display a context usage progress bar in the next action card.

* Add claude-code context token usage

* fix type issue

---------

Co-authored-by: Louis Knight-Webb <louis@bloop.ai>
This commit is contained in:
Solomon
2026-01-16 17:41:36 +00:00
committed by GitHub
parent ee212c5e61
commit 10f6a9171a
19 changed files with 344 additions and 13 deletions

View File

@@ -338,6 +338,9 @@ pub enum HistoryStrategy {
AmpResume,
}
/// Default context window for models (used until we get actual value from result)
const DEFAULT_CLAUDE_CONTEXT_WINDOW: u32 = 200_000;
/// Handles log processing and interpretation for Claude executor
pub struct ClaudeLogProcessor {
model_name: Option<String>,
@@ -347,6 +350,10 @@ pub struct ClaudeLogProcessor {
strategy: HistoryStrategy,
streaming_messages: HashMap<String, StreamingMessageState>,
streaming_message_id: Option<String>,
// Main model name (excluding subagents). Only used internally for context window tracking.
main_model_name: Option<String>,
main_model_context_window: u32,
context_tokens_used: u32,
}
impl ClaudeLogProcessor {
@@ -358,10 +365,13 @@ impl ClaudeLogProcessor {
fn new_with_strategy(strategy: HistoryStrategy) -> Self {
Self {
model_name: None,
main_model_name: None,
tool_map: HashMap::new(),
strategy,
streaming_messages: HashMap::new(),
streaming_message_id: None,
main_model_context_window: DEFAULT_CLAUDE_CONTEXT_WINDOW,
context_tokens_used: 0,
}
}
@@ -769,6 +779,7 @@ impl ClaudeLogProcessor {
ClaudeJson::System {
subtype,
api_key_source,
model,
..
} => {
// emit billing warning if required
@@ -780,6 +791,12 @@ impl ClaudeLogProcessor {
// keep the existing behaviour for the normal system message
match subtype.as_deref() {
Some("init") => {
if self.main_model_name.is_none() {
// this name matches the model names in the usage report in the result message
if let Some(model) = model {
self.main_model_name = Some(model.clone());
}
}
// Skip system init messages because it doesn't contain the actual model that will be used in assistant messages in case of claude-code-router.
// We'll send system initialized message with first assistant message that has a model field.
}
@@ -1103,7 +1120,11 @@ impl ClaudeLogProcessor {
ClaudeJson::ToolResult { .. } => {
// Add proper ToolResult support to NormalizedEntry when the type system supports it
}
ClaudeJson::StreamEvent { event, .. } => match event {
ClaudeJson::StreamEvent {
event,
parent_tool_use_id,
..
} => match event {
ClaudeStreamEvent::MessageStart { message } => {
if message.role == "assistant" {
if let Some(patch) = extract_model_name(self, message, entry_index_provider)
@@ -1152,7 +1173,21 @@ impl ClaudeLogProcessor {
}
}
ClaudeStreamEvent::ContentBlockStop { .. } => {}
ClaudeStreamEvent::MessageDelta { .. } => {}
ClaudeStreamEvent::MessageDelta { usage, .. } => {
// do not report context token usage for subagents
if parent_tool_use_id.is_none()
&& let Some(usage) = usage
{
let input_tokens = usage.input_tokens.unwrap_or(0)
+ usage.cache_creation_input_tokens.unwrap_or(0)
+ usage.cache_read_input_tokens.unwrap_or(0);
let output_tokens = usage.output_tokens.unwrap_or(0);
let total_tokens = input_tokens + output_tokens;
self.context_tokens_used = total_tokens as u32;
patches.push(self.add_token_usage_entry(entry_index_provider));
}
}
ClaudeStreamEvent::MessageStop => {
if let Some(message_id) = self.streaming_message_id.take() {
let _ = self.streaming_messages.remove(&message_id);
@@ -1160,7 +1195,22 @@ impl ClaudeLogProcessor {
}
ClaudeStreamEvent::Unknown => {}
},
ClaudeJson::Result { is_error, .. } => {
ClaudeJson::Result {
is_error,
model_usage,
..
} => {
// get the real model context window and correct the context usage entry
if let Some(context_window) = model_usage.as_ref().and_then(|model_usage| {
self.main_model_name
.as_ref()
.and_then(|name| model_usage.get(name))
.and_then(|usage| usage.context_window)
}) {
self.main_model_context_window = context_window;
patches.push(self.add_token_usage_entry(entry_index_provider));
}
if matches!(self.strategy, HistoryStrategy::AmpResume) && is_error.unwrap_or(false)
{
let entry = NormalizedEntry {
@@ -1325,6 +1375,26 @@ impl ClaudeLogProcessor {
},
}
}
fn add_token_usage_entry(
&mut self,
entry_index_provider: &EntryIndexProvider,
) -> json_patch::Patch {
let entry = NormalizedEntry {
timestamp: None,
entry_type: NormalizedEntryType::TokenUsageInfo(crate::logs::TokenUsageInfo {
total_tokens: self.context_tokens_used,
model_context_window: self.main_model_context_window,
}),
content: format!(
"Tokens used: {} / Context window: {}",
self.context_tokens_used, self.main_model_context_window
),
metadata: None,
};
let idx = entry_index_provider.next();
ConversationPatch::add_normalized_entry(idx, entry)
}
}
fn extract_model_name(
@@ -1542,6 +1612,10 @@ pub enum ClaudeJson {
num_turns: Option<u32>,
#[serde(default, alias = "sessionId")]
session_id: Option<String>,
#[serde(default, alias = "modelUsage")]
model_usage: Option<HashMap<String, ClaudeModelUsage>>,
#[serde(default)]
usage: Option<ClaudeUsage>,
},
ApprovalResponse {
call_id: String,
@@ -1661,6 +1735,14 @@ pub struct ClaudeUsage {
pub service_tier: Option<String>,
}
/// Per-model usage statistics from result message
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct ClaudeModelUsage {
#[serde(default)]
pub context_window: Option<u32>,
}
/// Structured tool data for Claude tools based on real samples
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
#[serde(tag = "name", content = "input")]

View File

@@ -17,7 +17,7 @@ use codex_protocol::{
ErrorEvent, EventMsg, ExecApprovalRequestEvent, ExecCommandBeginEvent, ExecCommandEndEvent,
ExecCommandOutputDeltaEvent, ExecOutputStream, FileChange as CodexProtoFileChange,
McpInvocation, McpToolCallBeginEvent, McpToolCallEndEvent, PatchApplyBeginEvent,
PatchApplyEndEvent, StreamErrorEvent, TokenUsageInfo, ViewImageToolCallEvent, WarningEvent,
PatchApplyEndEvent, StreamErrorEvent, ViewImageToolCallEvent, WarningEvent,
WebSearchBeginEvent, WebSearchEndEvent,
},
};
@@ -215,7 +215,6 @@ struct LogState {
mcp_tools: HashMap<String, McpToolState>,
patches: HashMap<String, PatchState>,
web_searches: HashMap<String, WebSearchState>,
token_usage_info: Option<TokenUsageInfo>,
}
enum StreamingTextKind {
@@ -233,7 +232,6 @@ impl LogState {
mcp_tools: HashMap::new(),
patches: HashMap::new(),
web_searches: HashMap::new(),
token_usage_info: None,
}
}
@@ -971,7 +969,28 @@ pub fn normalize_logs(msg_store: Arc<MsgStore>, worktree_path: &Path) {
}
EventMsg::TokenCount(payload) => {
if let Some(info) = payload.info {
state.token_usage_info = Some(info);
add_normalized_entry(
&msg_store,
&entry_index,
NormalizedEntry {
timestamp: None,
entry_type: NormalizedEntryType::TokenUsageInfo(
crate::logs::TokenUsageInfo {
total_tokens: info.last_token_usage.total_tokens as u32,
model_context_window: info
.model_context_window
.unwrap_or_default()
as u32,
},
),
content: format!(
"Tokens used: {} / Context window: {}",
info.last_token_usage.total_tokens,
info.model_context_window.unwrap_or_default()
),
metadata: None,
},
);
}
}
EventMsg::ContextCompacted(..) => {

View File

@@ -95,6 +95,13 @@ pub enum NormalizedEntryType {
execution_processes: usize,
needs_setup: bool,
},
TokenUsageInfo(TokenUsageInfo),
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct TokenUsageInfo {
pub total_tokens: u32,
pub model_context_window: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]

View File

@@ -207,6 +207,7 @@ fn generate_types_content() -> String {
executors::logs::CommandRunResult::decl(),
executors::logs::NormalizedEntry::decl(),
executors::logs::NormalizedEntryType::decl(),
executors::logs::TokenUsageInfo::decl(),
executors::logs::FileChange::decl(),
executors::logs::ActionType::decl(),
executors::logs::TodoItem::decl(),

View File

@@ -733,9 +733,14 @@ function DisplayConversationEntry({
const isUserMessage = entryType.type === 'user_message';
const isUserFeedback = entryType.type === 'user_feedback';
const isLoading = entryType.type === 'loading';
const isTokenUsage = entryType.type === 'token_usage_info';
const isFileEdit = (a: ActionType): a is FileEditAction =>
a.action === 'file_edit';
if (isTokenUsage) {
return null;
}
if (isUserMessage) {
return (
<UserMessage

View File

@@ -313,6 +313,10 @@ function NewDisplayConversationEntry(props: Props) {
// The new design doesn't need the next action bar
return null;
case 'token_usage_info':
// Displayed in the chat header as the context-usage gauge
return null;
case 'user_feedback':
case 'loading':
// Fallback to legacy component for these entry types

View File

@@ -10,7 +10,7 @@ import { useExecutionProcesses } from '@/hooks/useExecutionProcesses';
import { useUserSystem } from '@/components/ConfigProvider';
import { useApprovalFeedbackOptional } from '@/contexts/ApprovalFeedbackContext';
import { useMessageEditContext } from '@/contexts/MessageEditContext';
import { useEntries } from '@/contexts/EntriesContext';
import { useEntries, useTokenUsage } from '@/contexts/EntriesContext';
import { useReviewOptional } from '@/contexts/ReviewProvider';
import { useActions } from '@/contexts/ActionsContext';
import { useTodos } from '@/hooks/useTodos';
@@ -115,6 +115,7 @@ export function SessionChatBoxContainer({
// Get entries early to extract pending approval for scratch key
const { entries } = useEntries();
const tokenUsageInfo = useTokenUsage();
// Extract pending approval metadata from entries (needed for scratchId)
const pendingApproval = useMemo(() => {
@@ -567,6 +568,7 @@ export function SessionChatBoxContainer({
status="idle"
workspaceId={workspaceId}
projectId={projectId}
tokenUsageInfo={tokenUsageInfo}
editor={{
value: '',
onChange: () => {},
@@ -601,6 +603,7 @@ export function SessionChatBoxContainer({
onViewCode={handleViewCode}
workspaceId={workspaceId}
projectId={projectId}
tokenUsageInfo={tokenUsageInfo}
editor={{
value: editorValue,
onChange: handleEditorChange,

View File

@@ -0,0 +1,136 @@
import type { TokenUsageInfo } from 'shared/types';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { Tooltip } from './Tooltip';
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
export interface ContextUsageGaugeProps {
tokenUsageInfo?: TokenUsageInfo | null;
className?: string;
}
export function ContextUsageGauge({
tokenUsageInfo,
className,
}: ContextUsageGaugeProps) {
const { t } = useTranslation('common');
const { percentage, formattedUsed, formattedTotal, status } = useMemo(() => {
if (!tokenUsageInfo || tokenUsageInfo.model_context_window === 0) {
return {
percentage: 0,
formattedUsed: '0',
formattedTotal: '0',
status: 'empty' as const,
};
}
const pct = Math.min(
100,
(tokenUsageInfo.total_tokens / tokenUsageInfo.model_context_window) * 100
);
const formatTokens = (n: number) => {
if (n >= 1_000_000) {
const m = n / 1_000_000;
return m % 1 === 0 ? `${m}M` : `${m.toFixed(1)}M`;
}
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
return n.toString();
};
let statusValue: 'low' | 'medium' | 'high' | 'critical' | 'empty';
if (pct < 50) statusValue = 'low';
else if (pct < 75) statusValue = 'medium';
else if (pct < 90) statusValue = 'high';
else statusValue = 'critical';
return {
percentage: pct,
formattedUsed: formatTokens(tokenUsageInfo.total_tokens),
formattedTotal: formatTokens(tokenUsageInfo.model_context_window),
status: statusValue,
};
}, [tokenUsageInfo]);
const progress = clamp(percentage / 100, 0, 1);
const tooltip =
status === 'empty'
? t('contextUsage.emptyTooltip')
: t('contextUsage.tooltip', {
percentage: Math.round(percentage),
used: formattedUsed,
total: formattedTotal,
});
const progressColor =
status === 'empty'
? 'text-low/40'
: status === 'critical'
? 'text-error'
: status === 'high'
? 'text-brand-secondary'
: status === 'medium'
? 'text-normal'
: 'text-low';
const radius = 8;
const strokeWidth = 2;
const circumference = 2 * Math.PI * radius;
const dashOffset = circumference * (1 - progress);
return (
<Tooltip content={tooltip} side="bottom">
<div
className={cn(
'flex items-center justify-center rounded-sm p-half',
'hover:bg-panel transition-colors cursor-help',
className
)}
aria-label={
status === 'empty'
? t('contextUsage.label')
: t('contextUsage.ariaLabel', {
percentage: Math.round(percentage),
})
}
role="img"
>
<svg
viewBox="0 0 20 20"
className="size-icon-base -rotate-90"
aria-hidden="true"
>
<circle
cx="10"
cy="10"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-border/60"
/>
<circle
cx="10"
cy="10"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={dashOffset}
className={cn(
progressColor,
'transition-all duration-500 ease-out'
)}
/>
</svg>
</div>
</Tooltip>
);
}

View File

@@ -11,7 +11,12 @@ import {
WarningIcon,
} from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
import type { Session, BaseCodingAgent, TodoItem } from 'shared/types';
import type {
BaseCodingAgent,
Session,
TodoItem,
TokenUsageInfo,
} from 'shared/types';
import type { LocalImageMetadata } from '@/components/ui/wysiwyg/context/task-attempt-context';
import { formatDateShortWithTime } from '@/utils/date';
import { toPrettyCase } from '@/utils/string';
@@ -36,6 +41,7 @@ import {
DropdownMenuSeparator,
} from './Dropdown';
import { type ExecutorProps } from './CreateChatBox';
import { ContextUsageGauge } from './ContextUsageGauge';
// Re-export shared types
export type { EditorProps, VariantProps } from './ChatBoxBase';
@@ -135,6 +141,7 @@ interface SessionChatBoxProps {
inProgressTodo?: TodoItem | null;
localImages?: LocalImageMetadata[];
onViewCode?: () => void;
tokenUsageInfo?: TokenUsageInfo | null;
}
/**
@@ -161,6 +168,7 @@ export function SessionChatBox({
inProgressTodo,
localImages,
onViewCode,
tokenUsageInfo,
}: SessionChatBoxProps) {
const { t } = useTranslation('tasks');
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -585,6 +593,7 @@ export function SessionChatBox({
{!isNewSessionMode && (
<AgentIcon agent={agent} className="size-icon-xl" />
)}
<ContextUsageGauge tokenUsageInfo={tokenUsageInfo} />
<ToolbarDropdown
label={sessionLabel}
disabled={isInFeedbackMode || isInEditMode || isInApprovalMode}

View File

@@ -7,11 +7,13 @@ import {
ReactNode,
} from 'react';
import type { PatchTypeWithKey } from '@/hooks/useConversationHistory';
import { NormalizedEntry, TokenUsageInfo } from 'shared/types';
interface EntriesContextType {
entries: PatchTypeWithKey[];
setEntries: (entries: PatchTypeWithKey[]) => void;
reset: () => void;
tokenUsageInfo: TokenUsageInfo | null;
}
const EntriesContext = createContext<EntriesContextType | null>(null);
@@ -22,13 +24,29 @@ interface EntriesProviderProps {
export const EntriesProvider = ({ children }: EntriesProviderProps) => {
const [entries, setEntriesState] = useState<PatchTypeWithKey[]>([]);
const [tokenUsageInfo, setTokenUsageInfo] = useState<TokenUsageInfo | null>(
null
);
const extractTokenUsageInfo = (
entries: PatchTypeWithKey[]
): TokenUsageInfo | null => {
const latest = entries.findLast(
(e) =>
e.type === 'NORMALIZED_ENTRY' &&
e.content.entry_type.type === 'token_usage_info'
)?.content as NormalizedEntry | undefined;
return (latest?.entry_type as TokenUsageInfo) ?? null;
};
const setEntries = useCallback((newEntries: PatchTypeWithKey[]) => {
setEntriesState(newEntries);
setTokenUsageInfo(extractTokenUsageInfo(newEntries));
}, []);
const reset = useCallback(() => {
setEntriesState([]);
setTokenUsageInfo(null);
}, []);
const value = useMemo(
@@ -36,8 +54,9 @@ export const EntriesProvider = ({ children }: EntriesProviderProps) => {
entries,
setEntries,
reset,
tokenUsageInfo,
}),
[entries, setEntries, reset]
[entries, setEntries, reset, tokenUsageInfo]
);
return (
@@ -52,3 +71,11 @@ export const useEntries = (): EntriesContextType => {
}
return context;
};
export const useTokenUsage = () => {
const context = useContext(EntriesContext);
if (!context) {
throw new Error('useTokenUsage must be used within an EntriesProvider');
}
return context.tokenUsageInfo;
};

View File

@@ -279,5 +279,11 @@
"search": {
"matchCount": "{{current}} of {{total}}",
"noMatches": "No matches"
},
"contextUsage": {
"label": "Context usage",
"emptyTooltip": "Context usage appears after the next reply",
"tooltip": "Context: {{percentage}}% · {{used}} / {{total}} tokens",
"ariaLabel": "Context usage: {{percentage}}%"
}
}

View File

@@ -279,5 +279,11 @@
"search": {
"matchCount": "{{current}} de {{total}}",
"noMatches": "Sin coincidencias"
},
"contextUsage": {
"label": "Uso del contexto",
"emptyTooltip": "El uso del contexto aparece después de la próxima respuesta",
"tooltip": "Contexto: {{percentage}}% · {{used}} / {{total}} tokens",
"ariaLabel": "Uso del contexto: {{percentage}}%"
}
}

View File

@@ -279,5 +279,11 @@
"search": {
"matchCount": "{{current}} / {{total}}",
"noMatches": "一致なし"
},
"contextUsage": {
"label": "コンテキスト使用量",
"emptyTooltip": "コンテキスト使用量は次の返信後に表示されます",
"tooltip": "コンテキスト: {{percentage}}% · {{used}} / {{total}} tokens",
"ariaLabel": "コンテキスト使用量: {{percentage}}%"
}
}

View File

@@ -279,5 +279,11 @@
"search": {
"matchCount": "{{current}} / {{total}}",
"noMatches": "일치 항목 없음"
},
"contextUsage": {
"label": "컨텍스트 사용량",
"emptyTooltip": "컨텍스트 사용량은 다음 응답 후에 표시됩니다",
"tooltip": "컨텍스트: {{percentage}}% · {{used}} / {{total}} 토큰",
"ariaLabel": "컨텍스트 사용량: {{percentage}}%"
}
}

View File

@@ -279,5 +279,11 @@
"search": {
"matchCount": "{{current}} / {{total}}",
"noMatches": "无匹配项"
},
"contextUsage": {
"label": "上下文使用量",
"emptyTooltip": "上下文使用量将在下一次回复后显示",
"tooltip": "上下文:{{percentage}}% · {{used}} / {{total}} tokens",
"ariaLabel": "上下文使用量:{{percentage}}%"
}
}

View File

@@ -279,5 +279,11 @@
"search": {
"matchCount": "{{current}} / {{total}}",
"noMatches": "無匹配項"
},
"contextUsage": {
"label": "上下文使用量",
"emptyTooltip": "上下文使用量將在下一次回覆後顯示",
"tooltip": "上下文:{{percentage}}% · {{used}} / {{total}} tokens",
"ariaLabel": "上下文使用量:{{percentage}}%"
}
}

View File

@@ -982,7 +982,7 @@ export function ProjectTasks() {
actions={
<Button
variant="icon"
aria-label={t('common:buttons.close', { defaultValue: 'Close' })}
aria-label={t('common:buttons.close')}
onClick={() => {
setSelectedSharedTaskId(null);
if (projectId) {

View File

@@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",

View File

@@ -543,7 +543,9 @@ export type CommandRunResult = { exit_status: CommandExitStatus | null, output:
export type NormalizedEntry = { timestamp: string | null, entry_type: NormalizedEntryType, content: string, };
export type NormalizedEntryType = { "type": "user_message" } | { "type": "user_feedback", denied_tool: string, } | { "type": "assistant_message" } | { "type": "tool_use", tool_name: string, action_type: ActionType, status: ToolStatus, } | { "type": "system_message" } | { "type": "error_message", error_type: NormalizedEntryError, } | { "type": "thinking" } | { "type": "loading" } | { "type": "next_action", failed: boolean, execution_processes: number, needs_setup: boolean, };
export type NormalizedEntryType = { "type": "user_message" } | { "type": "user_feedback", denied_tool: string, } | { "type": "assistant_message" } | { "type": "tool_use", tool_name: string, action_type: ActionType, status: ToolStatus, } | { "type": "system_message" } | { "type": "error_message", error_type: NormalizedEntryError, } | { "type": "thinking" } | { "type": "loading" } | { "type": "next_action", failed: boolean, execution_processes: number, needs_setup: boolean, } | { "type": "token_usage_info" } & TokenUsageInfo;
export type TokenUsageInfo = { total_tokens: number, model_context_window: number, };
export type FileChange = { "action": "write", content: string, } | { "action": "delete" } | { "action": "rename", new_path: string, } | { "action": "edit",
/**