feat: show context usage as circular progress
Replaces static context icons with live circular progress indicators Applies consistent context progress in desktop, mobile, VS Code, and mini-chat headers Keeps usage coloring tied to existing status thresholds
This commit is contained in:
@@ -26,7 +26,7 @@ import { opencodeClient } from '@/lib/opencode/client';
|
|||||||
import type { ProjectEntry, RuntimeAPIs } from '@/lib/api/types';
|
import type { ProjectEntry, RuntimeAPIs } from '@/lib/api/types';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { resolveProjectForDirectory, resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
import { resolveProjectForDirectory, resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||||
import { formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS } from '@/lib/quota';
|
import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota';
|
||||||
import { getDisplayModelName } from '@/lib/quota/model-families';
|
import { getDisplayModelName } from '@/lib/quota/model-families';
|
||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
@@ -144,14 +144,61 @@ const getWindowValueClass = (window: UsageWindow): string => {
|
|||||||
return 'text-foreground';
|
return 'text-foreground';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ContextProgressIcon: React.FC<{ percentage: number }> = ({ percentage }) => {
|
||||||
|
const progressPct = clampPercent(percentage) ?? 0;
|
||||||
|
const tone = resolveUsageTone(percentage);
|
||||||
|
const progressColor = tone === 'critical'
|
||||||
|
? 'var(--status-error)'
|
||||||
|
: tone === 'warn'
|
||||||
|
? 'var(--status-warning)'
|
||||||
|
: 'var(--status-success)';
|
||||||
|
const size = 18;
|
||||||
|
const stroke = 3;
|
||||||
|
const radius = (size - stroke) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${size} ${size}`}
|
||||||
|
className="size-[18px] -rotate-90"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={Math.round(progressPct)}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--interactive-border)"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={progressColor}
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={circumference * (1 - progressPct / 100)}
|
||||||
|
className="transition-[stroke-dashoffset,stroke] duration-300"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const MetadataRow: React.FC<{
|
const MetadataRow: React.FC<{
|
||||||
icon: IconName;
|
icon?: IconName;
|
||||||
|
iconNode?: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}> = ({ icon, label, children }) => (
|
}> = ({ icon, iconNode, label, children }) => (
|
||||||
<div className="flex min-w-0 items-center gap-3 rounded-xl px-2.5 py-2.5">
|
<div className="flex min-w-0 items-center gap-3 rounded-xl px-2.5 py-2.5">
|
||||||
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||||
<Icon name={icon} className="size-[18px]" />
|
{iconNode ?? (icon ? <Icon name={icon} className="size-[18px]" /> : null)}
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 typography-ui-label text-muted-foreground">{label}</span>
|
<span className="shrink-0 typography-ui-label text-muted-foreground">{label}</span>
|
||||||
<span className="min-w-0 flex-1 truncate text-right typography-ui-label font-medium text-foreground">
|
<span className="min-w-0 flex-1 truncate text-right typography-ui-label font-medium text-foreground">
|
||||||
@@ -244,7 +291,10 @@ const SessionMetadataOverlay: React.FC<{
|
|||||||
{branchLabel}
|
{branchLabel}
|
||||||
</MetadataRow>
|
</MetadataRow>
|
||||||
{contextDisplay ? (
|
{contextDisplay ? (
|
||||||
<MetadataRow icon="pie-chart" label={t('mobile.header.metadata.context')}>
|
<MetadataRow
|
||||||
|
iconNode={<ContextProgressIcon percentage={contextDisplay.percentage} />}
|
||||||
|
label={t('mobile.header.metadata.context')}
|
||||||
|
>
|
||||||
<span className="inline-flex items-baseline gap-1.5 tabular-nums">
|
<span className="inline-flex items-baseline gap-1.5 tabular-nums">
|
||||||
<span className={cn('font-semibold', contextDisplay.colorClass)}>{contextDisplay.percentage.toFixed(1)}%</span>
|
<span className={cn('font-semibold', contextDisplay.colorClass)}>{contextDisplay.percentage.toFixed(1)}%</span>
|
||||||
<span className="text-muted-foreground">{contextDisplay.tokens}</span>
|
<span className="text-muted-foreground">{contextDisplay.tokens}</span>
|
||||||
|
|||||||
@@ -2203,7 +2203,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
pressed={isContextPanelActive}
|
pressed={isContextPanelActive}
|
||||||
className={!showMiniChatHeaderAction ? 'mr-3.5' : ''}
|
className={!showMiniChatHeaderAction ? 'mr-3.5' : ''}
|
||||||
valueClassName="typography-ui-label font-medium leading-none text-foreground"
|
valueClassName="typography-ui-label font-medium leading-none text-foreground"
|
||||||
percentIconClassName="h-5 w-5"
|
percentIconClassName="h-4.5 w-4.5"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{desktopChangesPanelAction}
|
{desktopChangesPanelAction}
|
||||||
|
|||||||
@@ -1018,7 +1018,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
|
|||||||
valueClassName="font-semibold leading-none"
|
valueClassName="font-semibold leading-none"
|
||||||
hideIcon
|
hideIcon
|
||||||
showPercentIcon
|
showPercentIcon
|
||||||
percentIconClassName="h-5 w-5"
|
percentIconClassName="h-4.5 w-4.5"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
|
|||||||
valueClassName="font-semibold leading-none"
|
valueClassName="font-semibold leading-none"
|
||||||
hideIcon
|
hideIcon
|
||||||
showPercentIcon
|
showPercentIcon
|
||||||
percentIconClassName="h-5 w-5"
|
percentIconClassName="h-4.5 w-4.5"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
|||||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { clampPercent, resolveUsageTone } from '@/lib/quota';
|
||||||
|
|
||||||
interface ContextUsageDisplayProps {
|
interface ContextUsageDisplayProps {
|
||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
@@ -41,6 +42,13 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState(false);
|
const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState(false);
|
||||||
const colorPct = typeof colorPercentage === 'number' ? colorPercentage : percentage;
|
const colorPct = typeof colorPercentage === 'number' ? colorPercentage : percentage;
|
||||||
|
const progressPct = clampPercent(percentage) ?? 0;
|
||||||
|
const progressTone = resolveUsageTone(colorPct);
|
||||||
|
const progressColor = progressTone === 'critical'
|
||||||
|
? 'var(--status-error)'
|
||||||
|
: progressTone === 'warn'
|
||||||
|
? 'var(--status-warning)'
|
||||||
|
: 'var(--status-success)';
|
||||||
|
|
||||||
const formatTokens = (tokens: number) => {
|
const formatTokens = (tokens: number) => {
|
||||||
if (tokens >= 1_000_000) {
|
if (tokens >= 1_000_000) {
|
||||||
@@ -58,6 +66,12 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
|||||||
return 'text-status-success';
|
return 'text-status-success';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const circularProgressSize = 20;
|
||||||
|
const circularProgressStroke = 3;
|
||||||
|
const circularProgressRadius = (circularProgressSize - circularProgressStroke) / 2;
|
||||||
|
const circularProgressCircumference = 2 * Math.PI * circularProgressRadius;
|
||||||
|
const circularProgressOffset = circularProgressCircumference * (1 - progressPct / 100);
|
||||||
|
|
||||||
const safeOutputLimit = typeof outputLimit === 'number' ? Math.max(outputLimit, 0) : 0;
|
const safeOutputLimit = typeof outputLimit === 'number' ? Math.max(outputLimit, 0) : 0;
|
||||||
const tooltipLines = [
|
const tooltipLines = [
|
||||||
t('contextUsage.tooltip.usedTokens', { tokens: formatTokens(totalTokens) }),
|
t('contextUsage.tooltip.usedTokens', { tokens: formatTokens(totalTokens) }),
|
||||||
@@ -73,10 +87,35 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
|||||||
<span className={cn('font-medium inline-flex items-center gap-1.5', valueClassName)}>
|
<span className={cn('font-medium inline-flex items-center gap-1.5', valueClassName)}>
|
||||||
{showPercentIcon ? (
|
{showPercentIcon ? (
|
||||||
<>
|
<>
|
||||||
<Icon name="donut-chart-fill"
|
<svg
|
||||||
className={cn('h-3.5 w-3.5', percentIconClassName, getPercentageColor(colorPct))}
|
viewBox={`0 0 ${circularProgressSize} ${circularProgressSize}`}
|
||||||
aria-hidden="true"
|
className={cn('h-3.5 w-3.5 -rotate-90', percentIconClassName)}
|
||||||
/>
|
role="progressbar"
|
||||||
|
aria-valuenow={Math.round(progressPct)}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx={circularProgressSize / 2}
|
||||||
|
cy={circularProgressSize / 2}
|
||||||
|
r={circularProgressRadius}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--interactive-border)"
|
||||||
|
strokeWidth={circularProgressStroke}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={circularProgressSize / 2}
|
||||||
|
cy={circularProgressSize / 2}
|
||||||
|
r={circularProgressRadius}
|
||||||
|
fill="none"
|
||||||
|
stroke={progressColor}
|
||||||
|
strokeWidth={circularProgressStroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circularProgressCircumference}
|
||||||
|
strokeDashoffset={circularProgressOffset}
|
||||||
|
className="transition-[stroke-dashoffset,stroke] duration-300"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
<span className="text-foreground">{Math.min(percentage, 999).toFixed(1)}%</span>
|
<span className="text-foreground">{Math.min(percentage, 999).toFixed(1)}%</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user