feat(desktop): add context usage breakdown popover

Let users click the status bar context indicator to see how tokens are
split across system prompt, tools, rules, skills, MCP, and conversation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Austin Pickett 2026-06-29 09:18:10 -04:00
parent f1345290ed
commit fd324562d3
12 changed files with 524 additions and 4 deletions

156
agent/context_breakdown.py Normal file
View file

@ -0,0 +1,156 @@
"""Live session context-window breakdown for UI surfaces.
Estimates how the next provider request is composed: system prompt tiers,
tool schemas, and conversation history. Uses the same rough char/4 heuristic
as ``agent.model_metadata.estimate_request_tokens_rough`` so numbers align
with compression thresholds not exact tokenizer counts.
"""
from __future__ import annotations
import json
import re
from typing import Any, Dict, List, Optional, Sequence, Tuple
_SKILLS_BLOCK_RE = re.compile(r"<available_skills>.*?</available_skills>", re.DOTALL)
_SUBAGENT_TOOL_NAMES = frozenset({"delegate_task"})
_CATEGORY_COLORS = {
"system_prompt": "var(--context-usage-system)",
"tool_definitions": "var(--context-usage-tools)",
"rules": "var(--context-usage-rules)",
"skills": "var(--context-usage-skills)",
"mcp": "var(--context-usage-mcp)",
"subagent_definitions": "var(--context-usage-subagents)",
"memory": "var(--context-usage-memory)",
"conversation": "var(--context-usage-conversation)",
}
def _chars_to_tokens(text: str) -> int:
if not text:
return 0
return (len(text) + 3) // 4
def _json_tokens(value: Any) -> int:
if not value:
return 0
return _chars_to_tokens(json.dumps(value, ensure_ascii=False))
def _tool_name(tool: dict) -> str:
fn = tool.get("function") if isinstance(tool, dict) else None
if isinstance(fn, dict):
return str(fn.get("name") or "")
return str(tool.get("name") or "")
def _split_tools(tools: Sequence[dict]) -> Tuple[List[dict], List[dict], List[dict]]:
builtin: List[dict] = []
mcp: List[dict] = []
subagent: List[dict] = []
for tool in tools:
name = _tool_name(tool)
if name.startswith("mcp_"):
mcp.append(tool)
elif name in _SUBAGENT_TOOL_NAMES:
subagent.append(tool)
else:
builtin.append(tool)
return builtin, mcp, subagent
def _memory_blocks(agent: Any) -> Tuple[str, str]:
memory_block = ""
user_block = ""
store = getattr(agent, "_memory_store", None)
if store is None:
return memory_block, user_block
try:
if getattr(agent, "_memory_enabled", True):
memory_block = store.format_for_system_prompt("memory") or ""
if getattr(agent, "_user_profile_enabled", True):
user_block = store.format_for_system_prompt("user") or ""
except Exception:
pass
return memory_block, user_block
def _strip_blocks(text: str, *blocks: str) -> str:
out = text
for block in blocks:
if block:
out = out.replace(block, "")
return out.strip()
def compute_session_context_breakdown(
agent: Any,
messages: Optional[List[dict]] = None,
) -> Dict[str, Any]:
"""Return a Cursor-style context usage breakdown for one live agent."""
from agent.model_metadata import estimate_messages_tokens_rough
from agent.system_prompt import build_system_prompt_parts
parts = build_system_prompt_parts(agent)
stable = parts.get("stable", "") or ""
context = parts.get("context", "") or ""
volatile = parts.get("volatile", "") or ""
skills_match = _SKILLS_BLOCK_RE.search(stable)
skills_index = skills_match.group(0) if skills_match else ""
memory_block, user_block = _memory_blocks(agent)
memory_text = "\n\n".join(part for part in (memory_block, user_block) if part).strip()
system_core = _strip_blocks(stable, skills_index)
system_tail = _strip_blocks(volatile, memory_block, user_block)
system_prompt_text = "\n\n".join(part for part in (system_core, system_tail) if part).strip()
tools = list(getattr(agent, "tools", None) or [])
builtin_tools, mcp_tools, subagent_tools = _split_tools(tools)
conversation_tokens = estimate_messages_tokens_rough(messages or [])
categories = [
("system_prompt", "System prompt", _chars_to_tokens(system_prompt_text)),
("tool_definitions", "Tool definitions", _json_tokens(builtin_tools)),
("rules", "Rules", _chars_to_tokens(context)),
("skills", "Skills", _chars_to_tokens(skills_index)),
("mcp", "MCP", _json_tokens(mcp_tools)),
("subagent_definitions", "Subagent definitions", _json_tokens(subagent_tools)),
("memory", "Memory", _chars_to_tokens(memory_text)),
("conversation", "Conversation", conversation_tokens),
]
estimated_total = sum(tokens for _, _, tokens in categories)
comp = getattr(agent, "context_compressor", None)
context_max = int(getattr(comp, "context_length", 0) or 0) if comp else 0
measured_used = int(getattr(comp, "last_prompt_tokens", 0) or 0) if comp else 0
context_used = measured_used if measured_used > 0 else estimated_total
context_percent = (
max(0, min(100, round(context_used / context_max * 100)))
if context_max
else 0
)
return {
"categories": [
{
"color": _CATEGORY_COLORS.get(category_id, "var(--ui-text-tertiary)"),
"id": category_id,
"label": label,
"tokens": tokens,
}
for category_id, label, tokens in categories
if tokens > 0
],
"context_max": context_max,
"context_percent": context_percent,
"context_used": context_used,
"estimated_total": estimated_total,
"model": getattr(agent, "model", "") or "",
}

View file

@ -0,0 +1,147 @@
import { useEffect, useMemo, useState } from 'react'
import { useI18n } from '@/i18n'
import { formatK } from '@/lib/statusbar'
import { cn } from '@/lib/utils'
import type { ContextBreakdown, ContextUsageCategory, UsageStats } from '@/types/hermes'
interface ContextUsagePanelProps {
currentUsage: UsageStats
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
sessionId: string | null
}
export function ContextUsagePanel({ currentUsage, requestGateway, sessionId }: ContextUsagePanelProps) {
const { t } = useI18n()
const copy = t.shell.statusbar.contextUsagePanel
const [breakdown, setBreakdown] = useState<ContextBreakdown | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!sessionId) {
setBreakdown(null)
setLoading(false)
return
}
let cancelled = false
setLoading(true)
void requestGateway<ContextBreakdown>('session.context_breakdown', { session_id: sessionId })
.then(data => {
if (!cancelled) {
setBreakdown(data)
}
})
.catch(() => {
if (!cancelled) {
setBreakdown(null)
}
})
.finally(() => {
if (!cancelled) {
setLoading(false)
}
})
return () => {
cancelled = true
}
}, [requestGateway, sessionId])
const contextMax = breakdown?.context_max ?? currentUsage.context_max ?? 0
const contextUsed = breakdown?.context_used ?? currentUsage.context_used ?? 0
const contextPercent = Math.max(
0,
Math.min(100, Math.round(breakdown?.context_percent ?? currentUsage.context_percent ?? 0))
)
const categories = useMemo(
() =>
(breakdown?.categories ?? []).map(category => ({
...category,
label: copy.categories[category.id as keyof typeof copy.categories] ?? category.label
})),
[breakdown?.categories, copy.categories]
)
const segmentTotal = categories.reduce((sum, category) => sum + category.tokens, 0) || contextUsed || 1
return (
<div className="flex w-72 flex-col gap-3 p-3 text-[0.75rem]" data-slot="context-usage-panel">
<div className="flex items-baseline justify-between gap-2">
<p className="font-medium text-foreground">{copy.title}</p>
<span className="text-[0.6875rem] text-muted-foreground">
{copy.tokenSummary(`~${formatK(contextUsed)}`, formatK(contextMax))}
</span>
</div>
<p className="text-[0.6875rem] text-foreground">{copy.percentFull(contextPercent)}</p>
<ContextUsageBar categories={categories} segmentTotal={segmentTotal} />
<ul className="flex flex-col gap-1.5">
{categories.map(category => (
<li className="flex items-center justify-between gap-2" key={category.id}>
<span className="flex min-w-0 items-center gap-2">
<span
className="size-2 shrink-0 rounded-[2px]"
style={{ background: category.color }}
/>
<span className="truncate text-muted-foreground">{category.label}</span>
</span>
<span className="shrink-0 tabular-nums text-foreground">{formatCategoryTokens(category.tokens)}</span>
</li>
))}
</ul>
{loading && <p className="text-[0.6875rem] text-muted-foreground">{copy.loading}</p>}
{!loading && !categories.length && <p className="text-[0.6875rem] text-muted-foreground">{copy.empty}</p>}
</div>
)
}
function ContextUsageBar({
categories,
segmentTotal
}: {
categories: readonly ContextUsageCategory[]
segmentTotal: number
}) {
return (
<div
className={cn(
'flex h-1.5 overflow-hidden rounded-full',
categories.length ? 'bg-(--ui-stroke-tertiary)' : 'dither bg-(--ui-bg-elevated)'
)}
data-slot="context-usage-bar"
>
{categories.map(category => (
<span
className="h-full min-w-px"
key={category.id}
style={{
background: category.color,
width: `${(category.tokens / segmentTotal) * 100}%`
}}
/>
))}
</div>
)
}
function formatCategoryTokens(value: number): string {
if (!Number.isFinite(value) || value <= 0) {
return '0'
}
if (value >= 1_000) {
return `${formatK(value)}`
}
return value.toLocaleString()
}

View file

@ -4,6 +4,7 @@ import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { ContextUsagePanel } from '@/app/shell/context-usage-panel'
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
@ -365,8 +366,17 @@ export function useStatusbarItems({
hidden: !contextUsage,
id: 'context-usage',
label: contextUsage,
title: copy.contextUsage,
variant: 'text'
menuAlign: 'end',
menuClassName: 'w-auto border-(--ui-stroke-secondary) p-0',
menuContent: (
<ContextUsagePanel
currentUsage={currentUsage}
requestGateway={requestGateway}
sessionId={activeSessionId}
/>
),
title: copy.openContextUsage,
variant: 'menu'
},
{
detail: <LiveDuration since={sessionStartedAt} />,
@ -402,18 +412,21 @@ export function useStatusbarItems({
...(backendVersionItem ? [backendVersionItem] : [])
],
[
activeSessionId,
backendVersionItem,
busy,
chatOpen,
clientVersionItem,
contextBar,
contextUsage,
copy,
currentUsage,
requestGateway,
sessionStartedAt,
showYoloToggle,
terminalTakeover,
toggleYolo,
turnStartedAt,
clientVersionItem,
backendVersionItem,
yoloActive
]
)

View file

@ -1844,6 +1844,24 @@ export const en: Translations = {
turnRunning: 'Running',
currentTurnElapsed: 'Current turn elapsed',
contextUsage: 'Context usage',
contextUsagePanel: {
categories: {
conversation: 'Conversation',
mcp: 'MCP',
memory: 'Memory',
rules: 'Rules',
skills: 'Skills',
subagent_definitions: 'Subagent definitions',
system_prompt: 'System prompt',
tool_definitions: 'Tool definitions'
},
empty: 'No context data yet',
loading: 'Loading breakdown…',
percentFull: percent => `${percent}% Full`,
title: 'Context Usage',
tokenSummary: (used, max) => `${used} / ${max} Tokens`
},
openContextUsage: 'Open context usage breakdown',
session: 'Session',
runtimeSessionElapsed: 'Runtime session elapsed',
yoloOn: 'YOLO on — auto-approving dangerous commands. Click to turn off. Shift+click toggles it globally.',

View file

@ -1964,6 +1964,24 @@ export const ja = defineLocale({
turnRunning: '実行中',
currentTurnElapsed: '現在のターン経過時間',
contextUsage: 'コンテキスト使用状況',
contextUsagePanel: {
categories: {
conversation: '会話',
mcp: 'MCP',
memory: 'メモリ',
rules: 'ルール',
skills: 'スキル',
subagent_definitions: 'サブエージェント定義',
system_prompt: 'システムプロンプト',
tool_definitions: 'ツール定義'
},
empty: 'コンテキストデータはまだありません',
loading: '内訳を読み込み中…',
percentFull: percent => `${percent}% 使用中`,
title: 'コンテキスト使用状況',
tokenSummary: (used, max) => `${used} / ${max} Tokens`
},
openContextUsage: 'コンテキスト使用状況の内訳を開く',
session: 'セッション',
runtimeSessionElapsed: 'ランタイムセッション経過時間',
yoloOn: 'YOLO オン — 危険なコマンドを自動承認中。クリックでオフに。Shift+クリックで全体に切り替え。',

View file

@ -1497,6 +1497,24 @@ export interface Translations {
turnRunning: string
currentTurnElapsed: string
contextUsage: string
contextUsagePanel: {
categories: {
conversation: string
mcp: string
memory: string
rules: string
skills: string
subagent_definitions: string
system_prompt: string
tool_definitions: string
}
empty: string
loading: string
percentFull: (percent: number) => string
title: string
tokenSummary: (used: string, max: string) => string
}
openContextUsage: string
session: string
runtimeSessionElapsed: string
yoloOn: string

View file

@ -1903,6 +1903,24 @@ export const zhHant = defineLocale({
turnRunning: '執行中',
currentTurnElapsed: '目前回合已用時間',
contextUsage: '上下文使用量',
contextUsagePanel: {
categories: {
conversation: '對話',
mcp: 'MCP',
memory: '記憶',
rules: '規則',
skills: '技能',
subagent_definitions: '子代理定義',
system_prompt: '系統提示詞',
tool_definitions: '工具定義'
},
empty: '尚無上下文資料',
loading: '正在載入明細…',
percentFull: percent => `已用 ${percent}%`,
title: '上下文使用量',
tokenSummary: (used, max) => `${used} / ${max} Tokens`
},
openContextUsage: '開啟上下文使用量明細',
session: '工作階段',
runtimeSessionElapsed: '執行時工作階段已用時間',
yoloOn: 'YOLO 已開啟 — 自動核准危險指令。點擊關閉。Shift+點擊可全域切換。',

View file

@ -2015,6 +2015,24 @@ export const zh: Translations = {
turnRunning: '运行中',
currentTurnElapsed: '当前回合已用时间',
contextUsage: '上下文用量',
contextUsagePanel: {
categories: {
conversation: '对话',
mcp: 'MCP',
memory: '记忆',
rules: '规则',
skills: '技能',
subagent_definitions: '子代理定义',
system_prompt: '系统提示词',
tool_definitions: '工具定义'
},
empty: '暂无上下文数据',
loading: '正在加载明细…',
percentFull: percent => `已用 ${percent}%`,
title: '上下文用量',
tokenSummary: (used, max) => `${used} / ${max} Tokens`
},
openContextUsage: '打开上下文用量明细',
session: '会话',
runtimeSessionElapsed: '运行时会话已用时间',
yoloOn: 'YOLO 已开启 - 自动批准危险命令。点击关闭。Shift+点击可全局切换。',

View file

@ -164,6 +164,14 @@
--ui-cyan: #4c7f8c;
--ui-blue: #0053fd;
--ui-purple: #9e94d5;
--context-usage-system: color-mix(in srgb, var(--ui-base) 55%, transparent);
--context-usage-tools: var(--ui-purple);
--context-usage-rules: var(--ui-green);
--context-usage-skills: var(--ui-yellow);
--context-usage-mcp: color-mix(in srgb, var(--ui-red) 72%, var(--ui-purple));
--context-usage-subagents: color-mix(in srgb, var(--ui-blue) 70%, var(--ui-cyan));
--context-usage-memory: color-mix(in srgb, var(--ui-orange) 80%, var(--ui-yellow));
--context-usage-conversation: var(--ui-cyan);
--ui-bg-chrome: color-mix(
in srgb,
var(--theme-background-seed) var(--theme-mix-chrome),

View file

@ -428,6 +428,22 @@ export interface UsageStats {
total: number
}
export interface ContextUsageCategory {
color: string
id: string
label: string
tokens: number
}
export interface ContextBreakdown {
categories: ContextUsageCategory[]
context_max: number
context_percent: number
context_used: number
estimated_total: number
model?: string
}
export interface AnalyticsDailyEntry {
actual_cost: number
api_calls: number

View file

@ -0,0 +1,60 @@
"""Tests for live session context breakdown."""
from unittest.mock import MagicMock, patch
from agent.context_breakdown import compute_session_context_breakdown
def _make_agent(
*,
stable: str = "identity and guidance",
context: str = "",
volatile: str = "timestamp line",
tools: list | None = None,
context_length: int = 200_000,
last_prompt_tokens: int = 0,
):
agent = MagicMock()
agent.model = "openai/gpt-5.4"
agent.tools = tools or [
{"type": "function", "function": {"name": "terminal", "description": "run"}},
{"type": "function", "function": {"name": "mcp_demo_tool", "description": "mcp"}},
{"type": "function", "function": {"name": "delegate_task", "description": "spawn"}},
]
agent._memory_store = None
agent._memory_enabled = True
agent._user_profile_enabled = True
agent.context_compressor = MagicMock(
context_length=context_length,
last_prompt_tokens=last_prompt_tokens,
)
return agent, {"stable": stable, "context": context, "volatile": volatile}
def test_breakdown_includes_major_categories():
stable = (
"base guidance\n"
"<available_skills>\n demo:\n - hello: hi\n</available_skills>"
)
context = "# Project Context\nFollow AGENTS.md"
volatile = "Current time: now"
history = [{"role": "user", "content": "hello there"}]
agent, parts = _make_agent(stable=stable, context=context, volatile=volatile)
with patch("agent.system_prompt.build_system_prompt_parts", return_value=parts):
data = compute_session_context_breakdown(agent, history)
ids = {item["id"] for item in data["categories"]}
assert {"system_prompt", "tool_definitions", "rules", "skills", "mcp", "subagent_definitions", "conversation"} <= ids
assert data["context_max"] == 200_000
assert data["estimated_total"] > 0
def test_breakdown_uses_measured_context_when_available():
agent, parts = _make_agent(last_prompt_tokens=42_000)
with patch("agent.system_prompt.build_system_prompt_parts", return_value=parts):
data = compute_session_context_breakdown(agent, [])
assert data["context_used"] == 42_000
assert data["context_percent"] == 21

View file

@ -6165,6 +6165,36 @@ def _(rid, params: dict) -> dict:
return _ok(rid, usage)
@method("session.context_breakdown")
def _(rid, params: dict) -> dict:
session, err = _sess_nowait(params, rid)
if err:
return err
agent = session.get("agent")
if agent is None:
usage = _get_usage(None)
return _ok(
rid,
{
"categories": [],
"context_max": usage.get("context_max", 0) or 0,
"context_percent": usage.get("context_percent", 0) or 0,
"context_used": usage.get("context_used", 0) or 0,
"estimated_total": 0,
"model": "",
},
)
with session["history_lock"]:
history = list(session.get("history", []))
try:
from agent.context_breakdown import compute_session_context_breakdown
payload = compute_session_context_breakdown(agent, history)
except Exception as exc:
return _err(rid, 5000, f"Could not compute context breakdown: {exc}")
return _ok(rid, payload)
def _pet_frame_counts(spritesheet) -> dict:
"""Real (padding-trimmed) frame count per state, for the desktop canvas.