agent-ecosystem/src/renderer/components/common/TokenUsageDisplay.tsx
2026-04-18 12:10:51 +03:00

581 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* TokenUsageDisplay - Compact token usage display with detailed breakdown on hover.
* Shows total tokens with an info icon that reveals a popover with:
* - Input tokens breakdown
* - Cache read/write tokens
* - Output tokens
* - Optional model information
*/
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
import { formatCostUsd } from '@shared/utils/costFormatting';
import { getModelColorClass } from '@shared/utils/modelParser';
import {
formatTokensCompact as formatTokens,
formatTokensDetailed,
} from '@shared/utils/tokenFormatting';
import { ChevronRight, Info } from 'lucide-react';
import type { ClaudeMdStats } from '@renderer/types/claudeMd';
import type { ContextStats } from '@renderer/types/contextInjection';
import type { ModelInfo } from '@shared/utils/modelParser';
interface TokenUsageDisplayProps {
/** Input tokens count */
inputTokens: number;
/** Output tokens count */
outputTokens: number;
/** Cache read tokens count */
cacheReadTokens: number;
/** Cache creation/write tokens count */
cacheCreationTokens: number;
/** Optional model name for display */
modelName?: string;
/** Optional model family for color styling */
modelFamily?: ModelInfo['family'];
/** Size variant - 'sm' for compact, 'md' for slightly larger */
size?: 'sm' | 'md';
/** Optional CLAUDE.md injection statistics (deprecated, use contextStats) */
claudeMdStats?: ClaudeMdStats;
/** Optional unified context statistics */
contextStats?: ContextStats;
/** Phase number for this AI group */
phaseNumber?: number;
/** Total number of phases in the session */
totalPhases?: number;
/** Optional USD cost for this usage */
costUsd?: number;
}
/**
* Expandable section showing session-wide context breakdown.
* Shows accumulated totals for CLAUDE.md, mentioned files, tool outputs, and thinking+text.
*/
const SessionContextSection = ({
contextStats,
totalInputTokens,
}: Readonly<{
contextStats: ContextStats;
totalInputTokens: number;
}>): React.JSX.Element => {
const [expanded, setExpanded] = useState(false);
const { tokensByCategory } = contextStats;
// contextStats.totalEstimatedTokens already includes all categories (CLAUDE.md, @files,
// tool outputs, thinking+text, task coordination, user messages) - no manual adjustment needed.
// Visible Context is always shown as a share of prompt-side input tokens so this section
// stays aligned with the unified context contract instead of silently switching semantics.
const contextPercent =
totalInputTokens > 0
? Math.min((contextStats.totalEstimatedTokens / totalInputTokens) * 100, 100).toFixed(1)
: '0.0';
// Count accumulated injections by category
const claudeMdCount = contextStats.accumulatedInjections.filter(
(inj) => inj.category === 'claude-md'
).length;
const mentionedFilesCount = contextStats.accumulatedInjections.filter(
(inj) => inj.category === 'mentioned-file'
).length;
const toolOutputsCount = contextStats.accumulatedInjections.filter(
(inj) => inj.category === 'tool-output'
).length;
const taskCoordinationCount = contextStats.accumulatedInjections.filter(
(inj) => inj.category === 'task-coordination'
).length;
const userMessagesCount = contextStats.accumulatedInjections.filter(
(inj) => inj.category === 'user-message'
).length;
// Calculate percentages for each category (relative to total input tokens)
const claudeMdPercent =
totalInputTokens > 0
? Math.min((tokensByCategory.claudeMd / totalInputTokens) * 100, 100).toFixed(1)
: '0.0';
const mentionedFilesPercent =
totalInputTokens > 0
? Math.min((tokensByCategory.mentionedFiles / totalInputTokens) * 100, 100).toFixed(1)
: '0.0';
const toolOutputsPercent =
totalInputTokens > 0
? Math.min((tokensByCategory.toolOutputs / totalInputTokens) * 100, 100).toFixed(1)
: '0.0';
const thinkingTextPercent =
totalInputTokens > 0
? Math.min((tokensByCategory.thinkingText / totalInputTokens) * 100, 100).toFixed(1)
: '0.0';
const taskCoordinationPercent =
totalInputTokens > 0
? Math.min((tokensByCategory.taskCoordination / totalInputTokens) * 100, 100).toFixed(1)
: '0.0';
const userMessagesPercent =
totalInputTokens > 0
? Math.min((tokensByCategory.userMessages / totalInputTokens) * 100, 100).toFixed(1)
: '0.0';
return (
<div className="mt-1">
{/* Divider */}
<div className="my-1" style={{ borderTop: '1px solid var(--color-border-subtle)' }} />
{/* Header - clickable to expand */}
<div
role="button"
tabIndex={0}
className="-mx-1 flex cursor-pointer items-center justify-between gap-3 rounded px-1 py-0.5 transition-colors hover:bg-white/5"
onClick={() => setExpanded(!expanded)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setExpanded(!expanded);
}
}}
>
<div className="flex items-center gap-1" style={{ color: COLOR_TEXT_MUTED }}>
<ChevronRight
className={`size-3 shrink-0 transition-transform duration-150 ${expanded ? 'rotate-90' : ''}`}
/>
<span className="whitespace-nowrap text-[10px]">Visible Context</span>
</div>
<span
className="whitespace-nowrap text-[10px] tabular-nums"
style={{ color: COLOR_TEXT_MUTED }}
>
{formatTokens(contextStats.totalEstimatedTokens)} ({contextPercent}% of prompt input)
</span>
</div>
{/* Expanded details */}
{expanded && (
<div className="mt-1 space-y-1.5 pl-4">
{/* CLAUDE.md */}
{tokensByCategory.claudeMd > 0 && (
<div className="flex items-center justify-between text-[10px]">
<span style={{ color: COLOR_TEXT_MUTED }}>
CLAUDE.md <span className="opacity-60">×{claudeMdCount}</span>
</span>
<span className="tabular-nums" style={{ color: COLOR_TEXT_SECONDARY }}>
{formatTokens(tokensByCategory.claudeMd)}{' '}
<span className="opacity-60">({claudeMdPercent}%)</span>
</span>
</div>
)}
{/* Mentioned Files */}
{tokensByCategory.mentionedFiles > 0 && (
<div className="flex items-center justify-between text-[10px]">
<span style={{ color: COLOR_TEXT_MUTED }}>
@files <span className="opacity-60">×{mentionedFilesCount}</span>
</span>
<span className="tabular-nums" style={{ color: COLOR_TEXT_SECONDARY }}>
{formatTokens(tokensByCategory.mentionedFiles)}{' '}
<span className="opacity-60">({mentionedFilesPercent}%)</span>
</span>
</div>
)}
{/* Tool Outputs */}
{tokensByCategory.toolOutputs > 0 && (
<div className="flex items-center justify-between text-[10px]">
<span style={{ color: COLOR_TEXT_MUTED }}>
Tool Outputs <span className="opacity-60">×{toolOutputsCount}</span>
</span>
<span className="tabular-nums" style={{ color: COLOR_TEXT_SECONDARY }}>
{formatTokens(tokensByCategory.toolOutputs)}{' '}
<span className="opacity-60">({toolOutputsPercent}%)</span>
</span>
</div>
)}
{/* Task Coordination */}
{tokensByCategory.taskCoordination > 0 && (
<div className="flex items-center justify-between text-[10px]">
<span style={{ color: COLOR_TEXT_MUTED }}>
Task Coordination <span className="opacity-60">×{taskCoordinationCount}</span>
</span>
<span className="tabular-nums" style={{ color: COLOR_TEXT_SECONDARY }}>
{formatTokens(tokensByCategory.taskCoordination)}{' '}
<span className="opacity-60">({taskCoordinationPercent}%)</span>
</span>
</div>
)}
{/* User Messages */}
{tokensByCategory.userMessages > 0 && (
<div className="flex items-center justify-between text-[10px]">
<span style={{ color: COLOR_TEXT_MUTED }}>
User Messages <span className="opacity-60">×{userMessagesCount}</span>
</span>
<span className="tabular-nums" style={{ color: COLOR_TEXT_SECONDARY }}>
{formatTokens(tokensByCategory.userMessages)}{' '}
<span className="opacity-60">({userMessagesPercent}%)</span>
</span>
</div>
)}
{/* Thinking + Text */}
{tokensByCategory.thinkingText > 0 && (
<div className="flex items-center justify-between text-[10px]">
<span style={{ color: COLOR_TEXT_MUTED }}>Thinking + Text</span>
<span className="tabular-nums" style={{ color: COLOR_TEXT_SECONDARY }}>
{formatTokens(tokensByCategory.thinkingText)}{' '}
<span className="opacity-60">({thinkingTextPercent}%)</span>
</span>
</div>
)}
{/* Hint about session scope */}
<div
className="pt-0.5 text-[9px] italic"
style={{ color: COLOR_TEXT_MUTED, opacity: 0.7 }}
>
Accumulated across entire session without duplication
</div>
</div>
)}
</div>
);
};
export const TokenUsageDisplay = ({
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
modelName,
modelFamily,
size = 'sm',
claudeMdStats,
contextStats,
phaseNumber,
totalPhases,
costUsd,
}: Readonly<TokenUsageDisplayProps>): React.JSX.Element => {
const totalTokens = inputTokens + cacheReadTokens + cacheCreationTokens + outputTokens;
// Total prompt-side tokens only (without output) - used as denominator for visible context %
const totalInputTokens = inputTokens + cacheReadTokens + cacheCreationTokens;
const formattedTotal = formatTokens(totalTokens);
// Size-based classes
const textSize = size === 'sm' ? 'text-xs' : 'text-sm';
const iconSize = size === 'sm' ? 'w-3 h-3' : 'w-3.5 h-3.5';
// Model color based on family
const modelColorClass = modelFamily ? getModelColorClass(modelFamily) : '';
// Use React state for hover instead of CSS group-hover to avoid
// interference with parent components that also use the 'group' class
const [showPopover, setShowPopover] = useState(false);
const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
const [arrowStyle, setArrowStyle] = useState<React.CSSProperties>({});
const containerRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isDraggingRef = useRef(false);
// Clear timeout helper
const clearHideTimeout = (): void => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
};
// Show popover immediately, clear any pending hide
const handleMouseEnter = (): void => {
clearHideTimeout();
setShowPopover(true);
};
// Hide popover with delay (allows mouse to move to popover)
const handleMouseLeave = (): void => {
// Don't hide while dragging inside the popover
if (isDraggingRef.current) return;
clearHideTimeout();
hideTimeoutRef.current = setTimeout(() => {
setShowPopover(false);
}, 150);
};
// Cleanup timeout on unmount and close on scroll
useEffect(() => {
return () => clearHideTimeout();
}, []);
// Close popover on scroll
useEffect(() => {
if (!showPopover) return;
const handleScroll = (e: Event): void => {
// Don't close if scrolling inside the popover
if (popoverRef.current && e.target instanceof Node && popoverRef.current.contains(e.target)) {
return;
}
setShowPopover(false);
};
window.addEventListener('scroll', handleScroll, true);
return () => window.removeEventListener('scroll', handleScroll, true);
}, [showPopover]);
// Calculate popover position based on trigger element
useEffect(() => {
if (showPopover && containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const popoverWidth = 220;
const margin = 12;
// Determine if popover should open left or right
const openLeft = rect.left + popoverWidth > viewportWidth - 20;
// Determine if popover should open above or below
const spaceBelow = viewportHeight - rect.bottom - margin;
const spaceAbove = rect.top - margin;
const openAbove = spaceBelow < 200 && spaceAbove > spaceBelow;
const maxHeight = Math.max(openAbove ? spaceAbove : spaceBelow, 120) - 8;
queueMicrotask(() => {
setPopoverStyle({
position: 'fixed',
...(openAbove ? { bottom: viewportHeight - rect.top + 4 } : { top: rect.bottom + 4 }),
left: openLeft ? rect.right - popoverWidth : rect.left,
minWidth: 200,
maxWidth: 280,
maxHeight,
overflowY: 'auto',
zIndex: 99999,
});
setArrowStyle({
position: 'absolute',
...(openAbove
? {
bottom: -4,
borderRight: '1px solid var(--color-border)',
borderBottom: '1px solid var(--color-border)',
borderLeft: 'none',
borderTop: 'none',
}
: {
top: -4,
borderLeft: '1px solid var(--color-border)',
borderTop: '1px solid var(--color-border)',
borderRight: 'none',
borderBottom: 'none',
}),
[openLeft ? 'right' : 'left']: 8,
width: 8,
height: 8,
transform: 'rotate(45deg)',
backgroundColor: 'var(--color-surface-raised)',
});
});
}
}, [showPopover]);
return (
<div
className={`inline-flex items-center gap-1 ${textSize}`}
style={{ color: COLOR_TEXT_MUTED }}
>
<span className="font-medium">{formattedTotal}</span>
{totalPhases && totalPhases > 1 && phaseNumber && (
<span
className="rounded px-1 py-0.5 text-[10px]"
style={{ backgroundColor: 'rgba(99, 102, 241, 0.15)', color: '#818cf8' }}
>
Phase {phaseNumber}/{totalPhases}
</span>
)}
<div
ref={containerRef}
role="button"
tabIndex={0}
className="relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleMouseEnter}
onBlur={(e) => {
// Don't close if focus moved into the popover
if (popoverRef.current?.contains(e.relatedTarget as Node)) return;
handleMouseLeave();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowPopover(!showPopover);
}
}}
aria-expanded={showPopover}
aria-haspopup="true"
>
<Info
className={`${iconSize} cursor-help transition-colors`}
style={{ color: COLOR_TEXT_MUTED }}
/>
{/* Popover - rendered via Portal to escape stacking context */}
{showPopover &&
createPortal(
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events -- tooltip uses mouse handlers for hover/drag behavior, not interactive
<div
ref={popoverRef}
role="tooltip"
className="rounded-lg p-3 shadow-xl"
style={{
...popoverStyle,
backgroundColor: 'var(--color-surface-raised)',
border: '1px solid var(--color-border)',
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.3)',
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseDown={(e) => {
e.stopPropagation();
isDraggingRef.current = true;
const handleMouseUp = (): void => {
isDraggingRef.current = false;
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mouseup', handleMouseUp);
}}
onClick={(e) => e.stopPropagation()}
>
{/* Arrow pointer */}
<div style={arrowStyle} />
<div className="space-y-2 text-xs">
{/* Input Tokens */}
<div className="flex items-center justify-between">
<span style={{ color: COLOR_TEXT_MUTED }}>Input Tokens</span>
<span
className="font-medium tabular-nums"
style={{ color: COLOR_TEXT_SECONDARY }}
>
{formatTokensDetailed(inputTokens)}
</span>
</div>
{/* Cache Read */}
<div className="flex items-center justify-between">
<span style={{ color: COLOR_TEXT_MUTED }}>Cache Read</span>
<span
className="font-medium tabular-nums"
style={{ color: COLOR_TEXT_SECONDARY }}
>
{formatTokensDetailed(cacheReadTokens)}
</span>
</div>
{/* Cache Write/Creation */}
<div className="flex items-center justify-between">
<span style={{ color: COLOR_TEXT_MUTED }}>Cache Write</span>
<span
className="font-medium tabular-nums"
style={{ color: COLOR_TEXT_SECONDARY }}
>
{formatTokensDetailed(cacheCreationTokens)}
</span>
</div>
{/* Output Tokens */}
<div className="flex items-center justify-between">
<span style={{ color: COLOR_TEXT_MUTED }}>Output Tokens</span>
<span
className="font-medium tabular-nums"
style={{ color: COLOR_TEXT_SECONDARY }}
>
{formatTokensDetailed(outputTokens)}
</span>
</div>
{/* Divider before Total */}
<div
className="my-1"
style={{ borderTop: '1px solid var(--color-border-subtle)' }}
/>
{/* Total */}
<div className="flex items-center justify-between">
<span className="font-medium" style={{ color: COLOR_TEXT_SECONDARY }}>
Total
</span>
<span
className="font-medium tabular-nums"
style={{ color: 'var(--color-text-primary, var(--color-text))' }}
>
{formatTokensDetailed(totalTokens)}
</span>
</div>
{/* Cost (USD) - if available */}
{costUsd !== undefined && costUsd > 0 && (
<div className="mt-1 flex items-center justify-between text-[10px]">
<span style={{ color: COLOR_TEXT_SECONDARY }}>Cost (USD)</span>
<span
className="tabular-nums"
style={{ color: 'var(--color-text-primary, var(--color-text))' }}
>
{formatCostUsd(costUsd)}
</span>
</div>
)}
{/* Visible Context Breakdown - expandable section */}
{contextStats && contextStats.totalEstimatedTokens > 0 && (
<SessionContextSection
contextStats={contextStats}
totalInputTokens={totalInputTokens}
/>
)}
{/* CLAUDE.md Breakdown - fallback when contextStats not provided (deprecated) */}
{!contextStats && claudeMdStats && (
<div
className="mt-1 flex items-center justify-between text-[10px]"
style={{ color: COLOR_TEXT_MUTED }}
>
<span className="whitespace-nowrap italic">
incl. CLAUDE.md ×{claudeMdStats.accumulatedCount}
</span>
<span className="tabular-nums">
{totalInputTokens > 0
? ((claudeMdStats.totalEstimatedTokens / totalInputTokens) * 100).toFixed(1)
: '0.0'}
%
</span>
</div>
)}
{/* Model Info (optional) */}
{modelName && (
<>
<div
className="my-1"
style={{ borderTop: '1px solid var(--color-border-subtle)' }}
/>
<div className="flex items-center justify-between">
<span style={{ color: COLOR_TEXT_MUTED }}>Model</span>
<span
className={`font-medium ${modelColorClass}`}
style={!modelColorClass ? { color: COLOR_TEXT_SECONDARY } : {}}
>
{modelName}
</span>
</div>
</>
)}
</div>
</div>,
document.body
)}
</div>
</div>
);
};