/** * 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; /** Thinking tokens (extended thinking content) - estimated from content */ thinkingTokens?: number; /** Text output tokens (Claude's text responses) - estimated from content */ textOutputTokens?: 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, totalTokens, thinkingTokens = 0, textOutputTokens = 0, }: Readonly<{ contextStats: ContextStats; totalTokens: number; thinkingTokens?: number; textOutputTokens?: number; }>): React.JSX.Element => { const [expanded, setExpanded] = useState(false); const { tokensByCategory } = contextStats; // Calculate combined thinking+text tokens and include in context total const thinkingTextTokens = thinkingTokens + textOutputTokens; const adjustedContextTotal = contextStats.totalEstimatedTokens + thinkingTextTokens; const contextPercent = totalTokens > 0 ? Math.min((adjustedContextTotal / totalTokens) * 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 const claudeMdPercent = totalTokens > 0 ? Math.min((tokensByCategory.claudeMd / totalTokens) * 100, 100).toFixed(1) : '0.0'; const mentionedFilesPercent = totalTokens > 0 ? Math.min((tokensByCategory.mentionedFiles / totalTokens) * 100, 100).toFixed(1) : '0.0'; const toolOutputsPercent = totalTokens > 0 ? Math.min((tokensByCategory.toolOutputs / totalTokens) * 100, 100).toFixed(1) : '0.0'; const thinkingTextPercent = totalTokens > 0 ? Math.min((thinkingTextTokens / totalTokens) * 100, 100).toFixed(1) : '0.0'; const taskCoordinationPercent = totalTokens > 0 ? Math.min((tokensByCategory.taskCoordination / totalTokens) * 100, 100).toFixed(1) : '0.0'; const userMessagesPercent = totalTokens > 0 ? Math.min((tokensByCategory.userMessages / totalTokens) * 100, 100).toFixed(1) : '0.0'; return (
{/* Divider */}
{/* Header - clickable to expand */}
setExpanded(!expanded)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setExpanded(!expanded); } }} >
Visible Context
{formatTokens(adjustedContextTotal)} ({contextPercent}%)
{/* Expanded details */} {expanded && (
{/* CLAUDE.md */} {tokensByCategory.claudeMd > 0 && (
CLAUDE.md ×{claudeMdCount} {formatTokens(tokensByCategory.claudeMd)}{' '} ({claudeMdPercent}%)
)} {/* Mentioned Files */} {tokensByCategory.mentionedFiles > 0 && (
@files ×{mentionedFilesCount} {formatTokens(tokensByCategory.mentionedFiles)}{' '} ({mentionedFilesPercent}%)
)} {/* Tool Outputs */} {tokensByCategory.toolOutputs > 0 && (
Tool Outputs ×{toolOutputsCount} {formatTokens(tokensByCategory.toolOutputs)}{' '} ({toolOutputsPercent}%)
)} {/* Task Coordination */} {tokensByCategory.taskCoordination > 0 && (
Task Coordination ×{taskCoordinationCount} {formatTokens(tokensByCategory.taskCoordination)}{' '} ({taskCoordinationPercent}%)
)} {/* User Messages */} {tokensByCategory.userMessages > 0 && (
User Messages ×{userMessagesCount} {formatTokens(tokensByCategory.userMessages)}{' '} ({userMessagesPercent}%)
)} {/* Thinking + Text */} {thinkingTextTokens > 0 && (
Thinking + Text {formatTokens(thinkingTextTokens)}{' '} ({thinkingTextPercent}%)
)} {/* Hint about session scope */}
Accumulated across entire session without duplication
)}
); }; export const TokenUsageDisplay = ({ inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, thinkingTokens = 0, textOutputTokens = 0, modelName, modelFamily, size = 'sm', claudeMdStats, contextStats, phaseNumber, totalPhases, costUsd, }: Readonly): React.JSX.Element => { const totalTokens = inputTokens + cacheReadTokens + cacheCreationTokens + outputTokens; 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({}); const [arrowStyle, setArrowStyle] = useState({}); const containerRef = useRef(null); const popoverRef = useRef(null); const hideTimeoutRef = useRef | 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 (
{formattedTotal} {totalPhases && totalPhases > 1 && phaseNumber && ( Phase {phaseNumber}/{totalPhases} )}
{ // 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" > {/* 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
{ 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 */}
{/* Input Tokens */}
Input Tokens {formatTokensDetailed(inputTokens)}
{/* Cache Read */}
Cache Read {formatTokensDetailed(cacheReadTokens)}
{/* Cache Write/Creation */}
Cache Write {formatTokensDetailed(cacheCreationTokens)}
{/* Output Tokens */}
Output Tokens {formatTokensDetailed(outputTokens)}
{/* Divider before Total */}
{/* Total */}
Total {formatTokensDetailed(totalTokens)}
{/* Cost (USD) - if available */} {costUsd !== undefined && costUsd > 0 && (
Cost (USD) {formatCostUsd(costUsd)}
)} {/* Visible Context Breakdown - expandable section */} {contextStats && (contextStats.totalEstimatedTokens > 0 || thinkingTokens > 0 || textOutputTokens > 0) && ( )} {/* CLAUDE.md Breakdown - fallback when contextStats not provided (deprecated) */} {!contextStats && claudeMdStats && (
incl. CLAUDE.md ×{claudeMdStats.accumulatedCount} {totalTokens > 0 ? ((claudeMdStats.totalEstimatedTokens / totalTokens) * 100).toFixed(1) : '0.0'} %
)} {/* Model Info (optional) */} {modelName && ( <>
Model {modelName}
)}
, document.body )}
); };