572 lines · 21.1 KB · TypeScript
RawDownload
/** * 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 { 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;} /** * 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 (    <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(adjustedContextTotal)} ({contextPercent}%)        </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 */}          {thinkingTextTokens > 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(thinkingTextTokens)}{' '}                <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,  thinkingTokens = 0,  textOutputTokens = 0,  modelName,  modelFamily,  size = 'sm',  claudeMdStats,  contextStats,  phaseNumber,  totalPhases,}: Readonly<TokenUsageDisplayProps>): 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<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)' }}

Lines 1–500 of 572.