/** * @license * Copyright 2025 Qwen * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { Box, Text } from 'ink'; import { theme } from '../../semantic-colors.js'; import type { ContextCategoryBreakdown, ContextToolDetail, ContextMemoryDetail, ContextSkillDetail, } from '../../types.js'; import { t } from '../../../i18n/index.js'; // Progress bar characters const FILLED = '\u2588'; // █ - filled block const BUFFER = '\u2592'; // ▒ - medium shade (autocompact buffer) const EMPTY = '\u2591'; // ░ - light shade (free space) const CONTENT_WIDTH = 56; interface ContextUsageProps { modelName: string; totalTokens: number; contextWindowSize: number; breakdown: ContextCategoryBreakdown; builtinTools: ContextToolDetail[]; mcpTools: ContextToolDetail[]; memoryFiles: ContextMemoryDetail[]; skills: ContextSkillDetail[]; /** True when totalTokens is estimated (no API call yet) */ isEstimated?: boolean; /** When true, show per-item detail breakdowns. Default: false (compact). */ showDetails?: boolean; } /** * Truncate a string to maxLen, appending '…' if truncated. */ function truncateName(name: string, maxLen: number): string { if (name.length <= maxLen) return name; return name.slice(0, maxLen - 1) + '\u2026'; } /** * Format token count for display (e.g. 1234 -> "1.2k", 123456 -> "123.5k") */ function formatTokens(tokens: number): string { if (tokens >= 1000) { return `${(tokens / 1000).toFixed(1)}k`; } return `${tokens}`; } /** * Render a three-segment progress bar: used | autocompact buffer | free space. */ const ProgressBar: React.FC<{ usedPercentage: number; bufferPercentage: number; width: number; }> = ({ usedPercentage, bufferPercentage, width }) => { const usedCount = Math.round((Math.min(usedPercentage, 100) / 100) * width); const bufferCount = Math.round( (Math.min(bufferPercentage, 100 - usedPercentage) / 100) * width, ); const freeCount = Math.max(0, width - usedCount - bufferCount); const usedStr = FILLED.repeat(Math.max(0, usedCount)); const freeStr = EMPTY.repeat(Math.max(0, freeCount)); const bufferStr = BUFFER.repeat(Math.max(0, bufferCount)); // Used color: accent by default, warning/error at high usage. let usedColor = theme.text.accent; if (usedPercentage > 80) { usedColor = theme.status.error; } else if (usedPercentage > 60) { usedColor = theme.status.warning; } return ( {usedStr} {freeStr} {bufferStr} ); }; /** * A row showing a category with its token count and percentage. */ const CategoryRow: React.FC<{ symbol: string; label: string; tokens: number; contextWindowSize: number; symbolColor?: string; }> = ({ symbol, label, tokens, contextWindowSize, symbolColor }) => { const percentage = ((tokens / contextWindowSize) * 100).toFixed(1); const tokenStr = `${formatTokens(tokens)} ${t('tokens')} (${percentage}%)`; return ( {symbol} {label} {tokenStr} ); }; /** * A detail row for individual items (MCP tools, memory files, skills). */ const DETAIL_NAME_MAX_LEN = 30; const DetailRow: React.FC<{ name: string; tokens: number; }> = ({ name, tokens }) => { const tokenStr = tokens > 0 ? `${formatTokens(tokens)} ${t('tokens')}` : `0 ${t('tokens')}`; return ( {'\u2514'} {truncateName(name, DETAIL_NAME_MAX_LEN)} {tokenStr} ); }; export const ContextUsage: React.FC = ({ modelName, totalTokens, contextWindowSize, breakdown, builtinTools, mcpTools, memoryFiles, skills, isEstimated, showDetails = false, }) => { const percentage = contextWindowSize > 0 ? (totalTokens / contextWindowSize) * 100 : 0; // Sort detail items by token count (descending) for better readability const sortedBuiltinTools = [...builtinTools].sort( (a, b) => b.tokens - a.tokens, ); const sortedMcpTools = [...mcpTools].sort((a, b) => b.tokens - a.tokens); const sortedMemoryFiles = [...memoryFiles].sort( (a, b) => b.tokens - a.tokens, ); // Sort skills: loaded first, then by total token cost descending const sortedSkills = [...skills].sort((a, b) => { if (a.loaded !== b.loaded) return a.loaded ? -1 : 1; const aTotal = a.tokens + (a.bodyTokens ?? 0); const bTotal = b.tokens + (b.bodyTokens ?? 0); return bTotal - aTotal; }); return ( {/* Title */} {t('Context Usage')} {isEstimated ? ( <> {/* No API data yet — show hint instead of progress bar */} {t('No API response yet. Send a message to see actual usage.')} {/* Estimated overhead categories */} {t('Estimated pre-conversation overhead')} {t('Model')}: {modelName} {' '} {t('Context window')}: {formatTokens(contextWindowSize)}{' '} {t('tokens')} ) : ( <> {/* Model name + context window info */} {t('Model')}: {modelName} {t('Context window')}: {formatTokens(contextWindowSize)}{' '} {t('tokens')} {/* Progress bar — three segments: used | free | buffer */} 0 ? (breakdown.autocompactBuffer / contextWindowSize) * 100 : 0 } width={CONTENT_WIDTH} /> {/* Legend — same layout as CategoryRow for alignment */} {/* Breakdown header */} {t('Usage by category')} )} {breakdown.mcpTools > 0 && ( )} {/* Only show Messages when we have real API data */} {!isEstimated && ( )} {showDetails ? ( <> {/* Built-in tools detail */} {sortedBuiltinTools.length > 0 && ( {t('Built-in tools')} {sortedBuiltinTools.map((tool) => ( ))} )} {/* MCP Tools detail */} {sortedMcpTools.length > 0 && ( {t('MCP tools')} {sortedMcpTools.map((tool) => ( ))} )} {/* Memory files detail */} {sortedMemoryFiles.length > 0 && ( {t('Memory files')} {sortedMemoryFiles.map((file) => ( ))} )} {/* Skills detail */} {sortedSkills.length > 0 && ( {t('Skills')} {sortedSkills.map((skill) => ( {'\u2514'} {truncateName(skill.name, DETAIL_NAME_MAX_LEN)} {skill.loaded && ( {t('active')} )} {formatTokens(skill.tokens)} {t('tokens')} {skill.loaded && skill.bodyTokens != null && skill.bodyTokens > 0 && ( {' \u2514'} {t('body loaded')} +{formatTokens(skill.bodyTokens)} {t('tokens')} )} ))} )} ) : ( {t('Run /context detail for per-item breakdown.')} )} ); };