返回 DeepSeek-Reasonix
ContextPanel.tsx
根目录 / desktop / frontend / src / components / ContextPanel.tsx
1 // ContextPanel shows the active tab's context gauge and token usage.
2 // All visible text is routed through the i18n dictionary.
3 import { lazy, Suspense, useState, type ReactNode } from "react";
4 import { asArray } from "../lib/array";
5 import { useContextPanelSnapshot } from "../lib/useContextPanelSnapshot";
6 import { useSessionRuntimeMs } from "../lib/useSessionRuntime";
7 import { contextWindowPercentages } from "../lib/contextWindow";
8 import { useI18n, type Locale, type Translator } from "../lib/i18n";
9 import { formatMoneyLocalized } from "../lib/money";
10 import { formatTokens, formatOptionalTokens } from "../lib/format";
11 import { appendRateBand, normalizeRateBand, rateBandLabel, type DisplayRateBand } from "../lib/costRateBand";
12 import type { BalanceInfo, ContextInfo, ContextPanelInfo, UsageSourceStats, WireUsage } from "../lib/types";
13 import { contextSessionCache } from "../lib/contextSessionCache";
14 import { ContextBudgetCard, resolveContextBudget } from "./ContextBudgetCard";
15 import type { Item } from "../lib/useController";
16 import { contextWindowStatus, formatCacheHitRate } from "../lib/contextPanelUtils";
17 export { contextSessionCache } from "../lib/contextSessionCache";
18 const McpListLayers = lazy(() => import("./McpListLayers").then((module) => ({ default: module.McpListLayers })));
19 interface ContextPanelProps {
20 tabId?: string;
21 items?: Item[];
22 context?: ContextInfo;
23 usage?: WireUsage;
24 sessionTokens?: number;
25 sessionCost?: number;
26 sessionCurrency?: string;
27 sessionTurns?: number;
28 turnTokens?: number;
29 turnCost?: number;
30 turnRateBand?: string;
31 balance?: BalanceInfo;
32 sessionGen?: number;
33 refreshKey?: number;
34 // Monotonic counter bumped by EVERY usage event (executor and subagent).
35 // The executor-gated `usage` prop freezes during sub-agent runs, which used
36 // to pin 会话指标/用量分析 for minutes; this keeps the snapshot ticking.
37 usageSeq?: number;
38 }
39
40 function fmtDuration(ms: number, t: Translator): string {
41 if (ms <= 0) return "-";
42 const totalSeconds = Math.max(1, Math.round(ms / 1000));
43 const minutes = Math.floor(totalSeconds / 60);
44 const seconds = totalSeconds % 60;
45 if (minutes <= 0) return t("context.durationSeconds", { seconds });
46 return t("context.durationMinutesSeconds", { minutes, seconds });
47 }
48
49 interface MetricTokenDisplay {
50 display: string;
51 exact: string;
52 }
53
54 function numberLocale(locale: Locale | string): string {
55 if (locale === "zh") return "zh-CN";
56 if (locale === "zh-TW") return "zh-TW";
57 return "en";
58 }
59
60 export function formatMetricTokens(tokens: number | undefined, locale: Locale | string): MetricTokenDisplay {
61 if (typeof tokens !== "number" || tokens <= 0) {
62 return { display: "-", exact: "-" };
63 }
64 const tag = numberLocale(locale);
65 const exact = tokens.toLocaleString(tag);
66 return { display: exact, exact };
67 }
68
69 function fmtUsageCacheRate(usage?: WireUsage): string {
70 if (!usage) return "-";
71 const denom = usage.cacheHitTokens + usage.cacheMissTokens;
72 if (denom <= 0) return "-";
73 return `${((usage.cacheHitTokens / denom) * 100).toFixed(2)}%`;
74 }
75
76 export { formatCacheHitRate } from "../lib/contextPanelUtils";
77
78 type MetricTone = "accent" | "good" | "notice" | "warn";
79 type UsageAnalysisView = "source" | "type";
80 type ContextUsageRefreshFields = Pick<
81 WireUsage,
82 "totalTokens" | "promptTokens" | "completionTokens" | "reasoningTokens" | "sessionCacheHitTokens" | "sessionCacheMissTokens"
83 >;
84
85 export function contextUsageRefreshKey(usage?: ContextUsageRefreshFields): string {
86 if (!usage) return "";
87 return [
88 usage.totalTokens ?? 0,
89 usage.promptTokens ?? 0,
90 usage.completionTokens ?? 0,
91 usage.reasoningTokens ?? 0,
92 usage.sessionCacheHitTokens ?? 0,
93 usage.sessionCacheMissTokens ?? 0,
94 ].join(":");
95 }
96
97 export function cacheHitTone(hitTokens: number, missTokens: number): MetricTone | undefined {
98 const denom = hitTokens + missTokens;
99 if (denom <= 0) return undefined;
100 const pct = (hitTokens / denom) * 100;
101 if (pct >= 80) return "good";
102 if (pct >= 60) return "notice";
103 return "warn";
104 }
105
106 export function formatSharePercent(value: number, total: number): string {
107 if (total <= 0 || value <= 0) return "-";
108 const pct = (value / total) * 100;
109 if (pct > 0 && pct < 1) return "<1%";
110 return `${Math.round(pct)}%`;
111 }
112
113 export function contextCostDisplay({
114 info,
115 sessionCost,
116 sessionCurrency,
117 usage,
118 }: {
119 info?: Pick<ContextPanelInfo, "sessionCost" | "sessionCurrency" | "sessionCostUsd" | "sessionCostComplete" | "sessionCostEstimated" | "sessionBillingMode" | "sessionCostQuote"> | null;
120 sessionCost?: number;
121 sessionCurrency?: string;
122 usage?: Pick<WireUsage, "cost" | "costUsd" | "currency" | "currencyCode" | "costQuote">;
123 }): {
124 amount: number;
125 currency?: string;
126 estimated?: boolean;
127 complete?: boolean;
128 billingMode?: string;
129 labelKind?: "estimated" | "payg_equivalent" | "fallback" | "bucketed" | "unavailable";
130 } {
131 // Prefer structured session quote, then per-usage quote.
132 const quote = info?.sessionCostQuote || usage?.costQuote;
133 if (quote?.displayStatus === "bucketed" || quote?.aggregateMode === "currency_buckets") {
134 return {
135 amount: 0,
136 currency: undefined,
137 estimated: true,
138 complete: false,
139 labelKind: "bucketed",
140 };
141 }
142 const fallbackOriginal = quote?.displayStatus === "fallback_original";
143 if (!fallbackOriginal && (info?.sessionCostComplete === false || quote?.displayStatus === "unavailable" || quote?.costComplete === false)) {
144 return {
145 amount: 0,
146 currency: info?.sessionCurrency || sessionCurrency || usage?.currencyCode || usage?.currency,
147 estimated: true,
148 complete: false,
149 labelKind: "unavailable",
150 };
151 }
152 const selected = quote?.selected;
153 if (selected?.amount) {
154 const n = Number(selected.amount);
155 if (Number.isFinite(n) && n > 0) {
156 const mode = quote?.billingMode || info?.sessionBillingMode;
157 return {
158 amount: n,
159 currency: selected.currency || usage?.currencyCode || usage?.currency || info?.sessionCurrency,
160 estimated: quote?.estimated !== false,
161 complete: quote?.displayComplete !== false,
162 billingMode: mode,
163 labelKind: fallbackOriginal ? "fallback" : mode === "subscription_equivalent" ? "payg_equivalent" : "estimated",
164 };
165 }
166 }
167 // Session-scoped scalar fallbacks (legacy telemetry).
168 if (info?.sessionCost && info.sessionCost > 0) {
169 return {
170 amount: info.sessionCost,
171 currency: info.sessionCurrency || sessionCurrency || usage?.currencyCode || usage?.currency,
172 estimated: true,
173 complete: true,
174 labelKind: "estimated",
175 };
176 }
177 if (sessionCost && sessionCost > 0) {
178 return {
179 amount: sessionCost,
180 currency: sessionCurrency || info?.sessionCurrency || usage?.currencyCode || usage?.currency,
181 estimated: true,
182 complete: true,
183 labelKind: "estimated",
184 };
185 }
186 if (info?.sessionCostUsd && info.sessionCostUsd > 0) {
187 return {
188 amount: info.sessionCostUsd,
189 currency: info.sessionCurrency || sessionCurrency || usage?.currencyCode || usage?.currency,
190 estimated: true,
191 complete: true,
192 labelKind: "estimated",
193 };
194 }
195 return {
196 amount: 0,
197 currency: info?.sessionCurrency || sessionCurrency || usage?.currencyCode || usage?.currency,
198 estimated: true,
199 complete: false,
200 labelKind: "unavailable",
201 };
202 }
203
204 interface ContextBreakdown {
205 promptTokens: number;
206 completionTokens: number;
207 reasoningTokens: number;
208 otherTokens: number;
209 promptPct: number;
210 completionPct: number;
211 reasoningPct: number;
212 otherPct: number;
213 }
214
215 function nonNegativeTokenCount(value: number): number {
216 return Number.isFinite(value) ? Math.max(0, value) : 0;
217 }
218
219 /** Prefer Context* (latest attempt) over billable aggregates for turn panels. */
220 export function liveTurnUsageBreakdown(
221 usage?: WireUsage | null,
222 info?: Pick<ContextPanelInfo, "promptTokens" | "completionTokens" | "reasoningTokens"> | null,
223 ): { promptTokens: number; completionTokens: number; reasoningTokens: number } {
224 if (usage) {
225 const hasContext =
226 (usage.contextPromptTokens ?? 0) > 0 || (usage.contextCompletionTokens ?? 0) > 0;
227 if (hasContext) {
228 return {
229 promptTokens: usage.contextPromptTokens ?? 0,
230 completionTokens: usage.contextCompletionTokens ?? 0,
231 reasoningTokens: usage.contextReasoningTokens ?? 0,
232 };
233 }
234 return {
235 promptTokens: usage.promptTokens ?? 0,
236 completionTokens: usage.completionTokens ?? 0,
237 reasoningTokens: usage.reasoningTokens ?? 0,
238 };
239 }
240 return {
241 promptTokens: info?.promptTokens ?? 0,
242 completionTokens: info?.completionTokens ?? 0,
243 reasoningTokens: info?.reasoningTokens ?? 0,
244 };
245 }
246
247 export function contextBreakdown(
248 usedTokens: number,
249 windowTokens: number,
250 promptTokens: number,
251 completionTokens: number,
252 reasoningTokens: number,
253 ): ContextBreakdown {
254 const used = nonNegativeTokenCount(usedTokens);
255 const window = nonNegativeTokenCount(windowTokens);
256 let prompt = nonNegativeTokenCount(promptTokens);
257 let reasoning = Math.min(nonNegativeTokenCount(reasoningTokens), nonNegativeTokenCount(completionTokens));
258 let completion = Math.max(0, nonNegativeTokenCount(completionTokens) - reasoning);
259 const known = prompt + completion + reasoning;
260
261 if (known > used && known > 0) {
262 const scale = used / known;
263 prompt *= scale;
264 completion *= scale;
265 reasoning *= scale;
266 }
267
268 const normalizedKnown = Math.min(used, prompt + completion + reasoning);
269 const other = Math.max(0, used - normalizedKnown);
270 const hasWindow = window > 0;
271 const promptPct = hasWindow ? Math.min(100, (prompt / window) * 100) : 0;
272 const completionPct = hasWindow ? Math.min(100, ((prompt + completion) / window) * 100) : 0;
273 const reasoningPct = hasWindow ? Math.min(100, ((prompt + completion + reasoning) / window) * 100) : 0;
274 const otherPct = hasWindow ? Math.min(100, (used / window) * 100) : 0;
275
276 return {
277 promptTokens: Math.round(prompt),
278 completionTokens: Math.round(completion),
279 reasoningTokens: Math.round(reasoning),
280 otherTokens: Math.round(other),
281 promptPct,
282 completionPct,
283 reasoningPct,
284 otherPct,
285 };
286 }
287
288 export { contextWindowStatus } from "../lib/contextPanelUtils";
289
290 const SOURCE_ORDER = ["executor", "planner", "subagent", "compaction", "classifier", "title"];
291
292 function sourceTone(source: string): string {
293 switch (source) {
294 case "executor": return "teal";
295 case "planner": return "blue";
296 case "subagent": return "amber";
297 case "compaction": return "slate";
298 case "classifier": return "violet";
299 case "title": return "rose";
300 default: return "default";
301 }
302 }
303
304 function sourceLabel(source: string, t: Translator): string {
305 switch (source) {
306 case "executor": return t("context.sourceExecutor");
307 case "planner": return t("context.sourcePlanner");
308 case "subagent": return t("context.sourceSubagent");
309 case "compaction": return t("context.sourceCompaction");
310 case "classifier": return t("context.sourceClassifier");
311 case "title": return t("context.sourceTitle");
312 default: return source;
313 }
314 }
315
316 function sourceCost(stats: UsageSourceStats): number {
317 return stats.sessionCost && stats.sessionCost > 0 ? stats.sessionCost : stats.sessionCostUsd ?? 0;
318 }
319
320 function sourceTokenTotal(row: Pick<ContextSourceRow, "promptTokens" | "completionTokens" | "totalTokens">): number {
321 return row.totalTokens > 0 ? row.totalTokens : row.promptTokens + row.completionTokens;
322 }
323
324 export interface ContextSourceRow {
325 source: string;
326 label: string;
327 promptTokens: number;
328 completionTokens: number;
329 cacheHitTokens: number;
330 cacheMissTokens: number;
331 totalTokens: number;
332 cost: number;
333 currency?: string;
334 requests: number;
335 estimated: boolean;
336 }
337
338 export function contextSourceRows(info: ContextPanelInfo | null, sessionCurrency?: string): ContextSourceRow[] {
339 const entries = Object.entries(info?.sources ?? {});
340 if (entries.length === 0) return [];
341 return entries
342 .filter(([, stats]) =>
343 (stats.requestCount ?? 0) > 0 ||
344 (stats.promptTokens ?? 0) > 0 ||
345 (stats.completionTokens ?? 0) > 0 ||
346 (stats.cacheHitTokens ?? 0) > 0 ||
347 (stats.cacheMissTokens ?? 0) > 0 ||
348 sourceCost(stats) > 0
349 )
350 .sort(([a], [b]) => {
351 const ia = SOURCE_ORDER.indexOf(a);
352 const ib = SOURCE_ORDER.indexOf(b);
353 if (ia >= 0 || ib >= 0) return (ia >= 0 ? ia : SOURCE_ORDER.length) - (ib >= 0 ? ib : SOURCE_ORDER.length);
354 return a.localeCompare(b);
355 })
356 .map(([source, stats]) => ({
357 source,
358 label: source,
359 promptTokens: stats.promptTokens ?? 0,
360 completionTokens: stats.completionTokens ?? 0,
361 cacheHitTokens: stats.cacheHitTokens ?? 0,
362 cacheMissTokens: stats.cacheMissTokens ?? 0,
363 totalTokens: stats.totalTokens ?? 0,
364 cost: sourceCost(stats),
365 currency: stats.sessionCurrency || sessionCurrency || info?.sessionCurrency,
366 requests: stats.requestCount ?? 0,
367 estimated: stats.estimated === true,
368 }));
369 }
370
371 export function ContextPanel({
372 tabId,
373 items,
374 context,
375 usage,
376 sessionTokens,
377 sessionCost,
378 sessionCurrency,
379 turnTokens,
380 turnCost,
381 turnRateBand,
382 balance,
383 sessionGen,
384 refreshKey,
385 usageSeq,
386 }: ContextPanelProps) {
387 const { locale, t } = useI18n();
388 const info = useContextPanelSnapshot(tabId, sessionGen, refreshKey, contextUsageRefreshKey(usage), usageSeq);
389 const runtimeMs = useSessionRuntimeMs(info);
390 const [analysisView, setAnalysisView] = useState<UsageAnalysisView>("source");
391 const usedTokens = context?.used ?? info?.usedTokens ?? 0;
392 const windowTokens = context?.window ?? info?.windowTokens ?? 0;
393 // Prefer live usage props (updated in real-time by the reducer during streaming)
394 // over the throttled async-fetched info snapshot. Multi-
395 // attempt stream recovery reports billable aggregates on prompt/completion
396 // and latest-attempt shape on Context* — use the latter for turn breakdown.
397 const turnBreakdown = liveTurnUsageBreakdown(usage, info);
398 const promptTokens = turnBreakdown.promptTokens;
399 const completionTokens = turnBreakdown.completionTokens;
400 const totalTokens = sessionTokens ?? context?.sessionTokens ?? info?.totalTokens ?? usage?.totalTokens ?? (promptTokens + completionTokens);
401 const reasoningTokens = turnBreakdown.reasoningTokens;
402 // Session-cumulative cache tokens for the top summary: all-sources telemetry
403 // first (matching the session cost and per-source rows in this panel — the
404 // wire session counters are executor-only), with the live counters bridging
405 // only a fresh session's first turn before the telemetry refresh. Hit and
406 // miss come as a pair from one source so the rate cannot mix scopes.
407 const { hit: sessionCacheHit, miss: sessionCacheMiss } = contextSessionCache(info, context, usage);
408 const totalTokensMetric = formatMetricTokens(totalTokens, locale);
409 const cost = contextCostDisplay({ info, sessionCost, sessionCurrency, usage });
410 const sourceUsageRows = contextSourceRows(info, sessionCurrency);
411 const showSourceUsageRows = sourceUsageRows.length > 0;
412 const sourceTotalTokens = sourceUsageRows.reduce((sum, row) => sum + sourceTokenTotal(row), 0);
413 const visibleSourceRows = sourceUsageRows.slice(0, 3);
414 const hiddenSourceRows = sourceUsageRows.slice(3);
415 const readFiles = asArray(info?.readFiles);
416 const changedFiles = asArray(info?.changedFiles);
417
418 const usagePercentages = contextWindowPercentages(usedTokens, windowTokens);
419 const rawUsagePct = usagePercentages.raw;
420 const usagePct = usagePercentages.display;
421 const compactRatio = context?.compactRatio && context.compactRatio > 0 ? context.compactRatio : 0.80;
422 const compactPct = Math.round(compactRatio * 100);
423 const reportedTriggerTokens = context?.maintenance?.triggerTokens ?? 0;
424 const triggerTokens = reportedTriggerTokens > 0
425 ? reportedTriggerTokens
426 : windowTokens > 0
427 ? Math.round(windowTokens * compactRatio)
428 : 0;
429 const compactTokens = triggerTokens > 0 ? triggerTokens : (windowTokens > 0 ? Math.round(windowTokens * compactRatio) : 0);
430 const tokensUntilCompact = compactTokens > usedTokens ? compactTokens - usedTokens : 0;
431 const breakdown = contextBreakdown(usedTokens, windowTokens, promptTokens, completionTokens, reasoningTokens);
432 const eventTimes = [
433 ...readFiles.map((file) => file.time),
434 ...changedFiles.map((file) => file.latestTime ?? 0),
435 ].filter((time) => time > 0);
436 const derivedElapsed = eventTimes.length > 1 ? Math.max(...eventTimes) - Math.min(...eventTimes) : 0;
437 const elapsed = runtimeMs > 0 ? runtimeMs : derivedElapsed;
438 const derivedRequestCount = Math.max(readFiles.length + changedFiles.length, 0);
439 const requestCount = info?.requestCount && info.requestCount > 0 ? info.requestCount : derivedRequestCount;
440 const windowStatus = contextWindowStatus(rawUsagePct, compactPct);
441 const balanceLabel = balance?.available && balance.display ? balance.display : "-";
442 const turnEstimated = usage?.estimated === true || info?.estimated === true;
443 const sessionEstimated = info?.sessionEstimated === true || context?.estimated === true;
444 const markEstimated = (value: string, estimated: boolean) => estimated && value !== "-" ? `≈${value}` : value;
445 const turnCostLabel = appendRateBand(markEstimated(formatMoneyLocalized(turnCost, sessionCurrency, { locale, empty: "dash" }), turnEstimated), turnRateBand, t);
446 const rawSessionCostLabel = cost.labelKind === "bucketed"
447 ? t("context.sessionCostBucketed")
448 : cost.labelKind === "unavailable"
449 ? t("context.sessionCostUnavailable")
450 : cost.labelKind === "fallback"
451 ? `${markEstimated(formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" }), sessionEstimated)} (${t("context.sessionCostFallback")})`
452 : markEstimated(formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" }), sessionEstimated);
453 const sessionCostLabel = rawSessionCostLabel;
454 const turnRateBandTitle = rateBandLabel(turnRateBand, t) ? t("billing.rateBand.tooltip") : undefined;
455 const sessionRateBand = normalizeRateBand(info?.sessionCostQuote?.rateBand);
456 const sessionRateBandTitle = sessionRateBand ? t("billing.rateBand.tooltip") : undefined;
457 const sessionRateBandBadge = sessionRateBand
458 ? { label: rateBandLabel(sessionRateBand, t) ?? sessionRateBand, tone: sessionRateBand, title: sessionRateBandTitle }
459 : undefined;
460 const totalTokensTitle = totalTokensMetric.exact === "-" ? "-" : t("context.tokensValue", { value: totalTokensMetric.exact });
461 const usedLabel = formatTokens(usedTokens);
462 const windowLabel = formatTokens(windowTokens);
463 const compactRemainingLabel = tokensUntilCompact > 0 ? formatTokens(tokensUntilCompact) : "0";
464 const compactMarkerPct = Math.max(0, Math.min(100, compactPct));
465 const usageMarkerPct = Math.max(6, Math.min(94, usagePct));
466 const compactLabelPct = Math.max(6, Math.min(94, compactMarkerPct));
467 const usageSummary = t("context.windowUsageSummary", { used: usedLabel, window: windowLabel, pct: rawUsagePct });
468 const compactSummary = t("context.windowCompactRemaining", { used: usedLabel, window: windowLabel, tokens: compactRemainingLabel, pct: compactPct });
469 const activeAnalysisView: UsageAnalysisView = showSourceUsageRows ? analysisView : "type";
470 const tokenTypeRows = [
471 { key: "prompt", label: t("context.prompt"), value: breakdown.promptTokens },
472 { key: "completion", label: t("context.completion"), value: breakdown.completionTokens },
473 { key: "reasoning", label: t("context.reasoning"), value: breakdown.reasoningTokens },
474 { key: "other", label: t("context.other"), value: breakdown.otherTokens },
475 ];
476 const tokenCompositionTotal = tokenTypeRows.reduce((sum, row) => sum + row.value, 0);
477 const renderSourceRow = (row: ContextSourceRow) => {
478 const inputMetric = formatMetricTokens(row.promptTokens, locale);
479 const outputMetric = formatMetricTokens(row.completionTokens, locale);
480 const hitMetric = formatMetricTokens(row.cacheHitTokens, locale);
481 const missMetric = formatMetricTokens(row.cacheMissTokens, locale);
482 const totalMetric = formatMetricTokens(sourceTokenTotal(row), locale);
483 const cacheReported = row.cacheHitTokens + row.cacheMissTokens > 0;
484 const cacheRate = cacheReported ? formatCacheHitRate(row.cacheHitTokens, row.cacheMissTokens) : t("context.cacheNotReported");
485 const costLabel = markEstimated(formatMoneyLocalized(row.cost, row.currency, { locale, empty: "dash" }), row.estimated);
486 return (
487 <div className="context-panel__source-row" key={row.source}>
488 <div className="context-panel__source-head">
489 <span>
490 <i className={`context-panel__source-dot context-panel__source-tone--${sourceTone(row.source)}`} aria-hidden="true" />
491 {sourceLabel(row.label, t)}
492 </span>
493 <em>{t("context.sourceRequests", { count: row.requests })}</em>
494 </div>
495 <div className="context-panel__source-summary">
496 <SourceMetric label={t("context.total")} value={totalMetric.display} title={totalMetric.exact} />
497 <SourceMetric label={t("context.sourceCacheRate")} value={cacheRate} />
498 <SourceMetric label={t("context.sourceCost")} value={costLabel} />
499 </div>
500 <details className="context-panel__source-details">
501 <summary>{t("context.sourceDetails")}</summary>
502 <div className="context-panel__source-details-body">
503 <SourceSplitBar
504 label={`${t("context.sourceInput")}/${t("context.sourceOutput")}`}
505 segments={[
506 { label: t("context.sourceInput"), value: row.promptTokens, tone: "input" },
507 { label: t("context.sourceOutput"), value: row.completionTokens, tone: "output" },
508 ]}
509 />
510 {cacheReported ? (
511 <SourceSplitBar
512 label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`}
513 segments={[
514 { label: t("context.sourceCacheHit"), value: row.cacheHitTokens, tone: "hit" },
515 { label: t("context.sourceCacheMiss"), value: row.cacheMissTokens, tone: "miss" },
516 ]}
517 compact
518 />
519 ) : (
520 <SourceSplitBar label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`} segments={[]} compact />
521 )}
522 <div className="context-panel__source-metrics">
523 <SourceMetric label={t("context.sourceInput")} value={inputMetric.display} title={inputMetric.exact} />
524 <SourceMetric label={t("context.sourceOutput")} value={outputMetric.display} title={outputMetric.exact} />
525 <SourceMetric label={t("context.sourceCacheHit")} value={hitMetric.display} title={hitMetric.exact} />
526 <SourceMetric label={t("context.sourceCacheMiss")} value={missMetric.display} title={missMetric.exact} />
527 </div>
528 </div>
529 </details>
530 </div>
531 );
532 };
533
534 return (
535 <div className="context-panel">
536 <div className="context-panel__body">
537 <section className="context-panel__overview">
538 <section className="context-panel__usage">
539 <SectionHeading title={t("context.windowTitle")} />
540 <div className={`context-panel__capacity-card context-panel__capacity-card--${windowStatus.tone}`}>
541 <div className="context-panel__capacity-top">
542 <span className="context-panel__capacity-status">{t(windowStatus.key)}</span>
543 <strong>{usedLabel}/{windowLabel}</strong>
544 </div>
545 <div className="context-panel__usage-progress context-panel__capacity-meter" aria-label={`${t(windowStatus.key)}. ${usageSummary}. ${compactSummary}`}>
546 <div className="context-panel__capacity-scale" aria-hidden="true">
547 <span className="context-panel__capacity-pin context-panel__capacity-pin--used" style={{ left: `${usageMarkerPct}%` }}>{rawUsagePct}%</span>
548 <span className="context-panel__capacity-pin context-panel__capacity-pin--compact" style={{ left: `${compactLabelPct}%` }}>{compactPct}%</span>
549 </div>
550 <div className="context-panel__progress-track" aria-hidden="true">
551 <span className="context-panel__progress-fill" style={{ width: `${usagePct}%` }} />
552 <span className="context-panel__compact-marker" style={{ left: `${compactMarkerPct}%` }} />
553 </div>
554 </div>
555 <div className="context-panel__capacity-foot">
556 <span>{t("context.windowUsedLabel")}</span>
557 <span className="context-panel__capacity-remaining">
558 <span>{t("context.windowCompactDistance")}</span>
559 <strong>{compactRemainingLabel}</strong>
560 </span>
561 </div>
562 </div><ContextBudgetCard budget={resolveContextBudget(context, info)} t={t} />
563 </section>
564 <Suspense fallback={null}>
565 <McpListLayers items={items} t={t} />
566 </Suspense>
567 <section className="context-panel__section context-panel__session-section">
568 <SectionHeading title={t("context.sessionMetrics")} />
569 <div className="context-panel__session-metrics">
570 <div className="context-panel__summary-rows">
571 <MiniStat label={t("status.cacheAvgLabel")} value={formatCacheHitRate(sessionCacheHit, sessionCacheMiss)} tone={cacheHitTone(sessionCacheHit, sessionCacheMiss)} />
572 <MiniStat label={t("context.sessionCost")} value={sessionCostLabel} title={sessionRateBandTitle} badge={sessionRateBandBadge} />
573 <MiniStat label={t("context.time")} value={fmtDuration(elapsed, t)} />
574 <MiniStat label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
575 <MiniStat label={t("context.sessionTokensShort")} value={markEstimated(totalTokensMetric.display, sessionEstimated)} title={totalTokensTitle} wide />
576 </div>
577 </div>
578 </section>
579 <section className="context-panel__creation-grid" aria-label={t("context.overview")}>
580 <MetricCard label={t("status.cacheLabel")} value={fmtUsageCacheRate(usage)} tone="accent" />
581 <MetricCard label={t("status.turnTokensLabel")} value={formatOptionalTokens(turnTokens)} />
582 <MetricCard label={t("status.turnCostLabel")} value={turnCostLabel} valueTitle={turnRateBandTitle} />
583 <MetricCard label={t("status.balanceLabel")} value={balanceLabel} tone="accent" />
584 </section>
585 <section className="context-panel__section context-panel__analysis">
586 <SectionHeading title={t("context.usageAnalysis")}>
587 {showSourceUsageRows && (
588 <div className="context-panel__view-switch" role="tablist" aria-label={t("context.usageAnalysisView")}>
589 <button
590 type="button"
591 className={`context-panel__view-tab${activeAnalysisView === "source" ? " context-panel__view-tab--active" : ""}`}
592 role="tab"
593 aria-selected={activeAnalysisView === "source"}
594 onClick={() => setAnalysisView("source")}
595 >
596 {t("context.usageAnalysisSource")}
597 </button>
598 <button
599 type="button"
600 className={`context-panel__view-tab${activeAnalysisView === "type" ? " context-panel__view-tab--active" : ""}`}
601 role="tab"
602 aria-selected={activeAnalysisView === "type"}
603 onClick={() => setAnalysisView("type")}
604 >
605 {t("context.usageAnalysisType")}
606 </button>
607 </div>
608 )}
609 </SectionHeading>
610 {activeAnalysisView === "source" ? (
611 <div className="context-panel__source-list" aria-label={t("context.sourceBreakdown")} role="tabpanel">
612 <div className="context-panel__source-overview">
613 <div className="context-panel__source-overview-head">
614 <strong>{t("context.sourceShareTitle")}</strong>
615 </div>
616 <div className="context-panel__source-sharebar" aria-hidden="true">
617 {sourceUsageRows.map((row) => {
618 const sharePct = sourceTotalTokens > 0 ? (sourceTokenTotal(row) / sourceTotalTokens) * 100 : 0;
619 if (sharePct <= 0) return null;
620 return (
621 <span
622 className={`context-panel__source-share context-panel__source-tone--${sourceTone(row.source)}`}
623 key={row.source}
624 style={{ width: `${sharePct}%` }}
625 />
626 );
627 })}
628 </div>
629 <div className="context-panel__source-legend">
630 {sourceUsageRows.map((row) => {
631 return (
632 <span key={row.source}>
633 <i className={`context-panel__source-dot context-panel__source-tone--${sourceTone(row.source)}`} aria-hidden="true" />
634 {sourceLabel(row.label, t)} {formatSharePercent(sourceTokenTotal(row), sourceTotalTokens)}
635 </span>
636 );
637 })}
638 </div>
639 </div>
640 {visibleSourceRows.map(renderSourceRow)}
641 {hiddenSourceRows.length > 0 && (
642 <details className="context-panel__source-more">
643 <summary>{t("context.moreSources", { count: hiddenSourceRows.length })}</summary>
644 <div className="context-panel__source-more-list">
645 {hiddenSourceRows.map(renderSourceRow)}
646 </div>
647 </details>
648 )}
649 </div>
650 ) : (
651 <div className="context-panel__type-panel" aria-label={t("context.tokenBreakdown")} role="tabpanel">
652 <div className="context-panel__type-overview">
653 <div className="context-panel__type-overview-head">
654 <strong>{t("context.tokenBreakdown")}</strong>
655 </div>
656 <div className="context-panel__type-sharebar" aria-hidden="true">
657 {tokenTypeRows.map((row) => row.value > 0 ? (
658 <span
659 className={`context-panel__type-share context-panel__type-share--${row.key}`}
660 key={row.key}
661 style={{ width: `${(row.value / Math.max(1, tokenCompositionTotal)) * 100}%` }}
662 />
663 ) : null)}
664 </div>
665 <div className="context-panel__type-legend">
666 {tokenTypeRows.map((row) => (
667 <span key={row.key}>
668 <i className={`context-panel__type-dot context-panel__type-dot--${row.key}`} aria-hidden="true" />
669 {row.label} {formatSharePercent(row.value, tokenCompositionTotal)}
670 </span>
671 ))}
672 </div>
673 </div>
674 <details className="context-panel__breakdown-details">
675 <summary>{t("context.sourceDetails")}</summary>
676 <div className="context-panel__breakdown">
677 {tokenTypeRows.map((row) => (
678 <TokenLegend key={row.key} label={row.label} value={row.value} color={row.key} />
679 ))}
680 </div>
681 </details>
682 </div>
683 )}
684 </section>
685 </section>
686 </div>
687
688 </div>
689 );
690 }
691
692 function SectionHeading({ title, meta, children }: { title: string; meta?: string; children?: ReactNode }) {
693 return (
694 <header className="context-panel__section-head">
695 <h3>{title}</h3>
696 {meta && <span>{meta}</span>}
697 {children}
698 </header>
699 );
700 }
701
702 function TokenLegend({ label, value, color }: { label: string; value: number; color: string }) {
703 return (
704 <div className="context-panel__legend-row">
705 <span className={`context-panel__legend-dot context-panel__legend-dot--${color}`} />
706 <span>{label}</span>
707 <strong>{value.toLocaleString()}</strong>
708 </div>
709 );
710 }
711
712 interface MiniStatBadge {
713 label: string;
714 tone: DisplayRateBand;
715 title?: string;
716 }
717
718 function MiniStat({ label, value, title, tone, wide, badge }: { label: string; value: string; title?: string; tone?: MetricTone; wide?: boolean; badge?: MiniStatBadge }) {
719 const toneClass = tone ? ` context-panel__mini-stat--${tone}` : "";
720 const wideClass = wide ? " context-panel__mini-stat--wide" : "";
721 const exactTitle = title && title !== value ? title : undefined;
722 const accessibleLabel = badge || exactTitle
723 ? `${label}: ${value}${badge ? `, ${badge.label}` : ""}${exactTitle ? `. ${exactTitle}` : ""}`
724 : undefined;
725 return (
726 <div className={`context-panel__mini-stat${toneClass}${wideClass}`} aria-label={accessibleLabel}>
727 <div className="context-panel__mini-stat-head">
728 <span className="context-panel__mini-stat-label">{label}</span>
729 {badge && (
730 <span className={`context-panel__rate-band context-panel__rate-band--${badge.tone}`} title={badge.title}>
731 {badge.label}
732 </span>
733 )}
734 </div>
735 <strong title={exactTitle}>{value}</strong>
736 </div>
737 );
738 }
739
740 function MetricCard({ label, value, valueTitle, tone, wide }: { label: string; value: string; valueTitle?: string; tone?: "accent" | "good" | "notice" | "warn"; wide?: boolean }) {
741 const toneClass = tone ? ` context-panel__metric--${tone}` : "";
742 const wideClass = wide ? " context-panel__metric--wide" : "";
743 const exactTitle = valueTitle && valueTitle !== value ? valueTitle : undefined;
744 return (
745 <div className={`context-panel__metric${toneClass}${wideClass}`} aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
746 <span>{label}</span>
747 <strong title={exactTitle}>{value}</strong>
748 </div>
749 );
750 }
751
752 function SourceMetric({ label, value, title }: { label: string; value: string; title?: string }) {
753 const exactTitle = title && title !== value ? title : undefined;
754 return (
755 <div className="context-panel__source-metric" aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
756 <span>{label}</span>
757 <strong title={exactTitle}>{value}</strong>
758 </div>
759 );
760 }
761
762 function SourceSplitBar({ label, segments, compact }: { label: string; segments: Array<{ label: string; value: number; tone: string }>; compact?: boolean }) {
763 const total = segments.reduce((sum, segment) => sum + Math.max(0, segment.value), 0);
764 const visible = segments.filter((segment) => segment.value > 0);
765 const compactClass = compact ? " context-panel__source-bar--compact" : "";
766 if (total <= 0 || visible.length === 0) {
767 return (
768 <div className="context-panel__source-bar-row">
769 <span>{label}</span>
770 <div className={`context-panel__source-bar context-panel__source-bar--empty${compactClass}`} aria-hidden="true" />
771 </div>
772 );
773 }
774 return (
775 <div className="context-panel__source-bar-row">
776 <span>{label}</span>
777 <div className={`context-panel__source-bar${compactClass}`}>
778 {visible.map((segment) => {
779 const width = (segment.value / total) * 100;
780 return (
781 <span
782 className={`context-panel__source-bar-segment context-panel__source-bar-segment--${segment.tone}`}
783 key={segment.tone}
784 style={{ width: `${width}%` }}
785 title={`${segment.label}: ${segment.value.toLocaleString()}`}
786 />
787 );
788 })}
789 </div>
790 </div>
791 );
792 }
793
793 lines Plain Text