/**
* @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.')}
)}
);
};