Polish context window usage card and meter

Calm the usage popover and settings card: smaller, muted provider logo
and name, more space between the header and the usage bars.

The popover divider used the `border` token, which equals the popover
background in dark themes and was invisible. Switch it to `borderAccent`
(the token the popover outlines itself with) and span it edge to edge.

Shrink the context meter ring and, while a session is active but usage
hasn't arrived yet, render a track-only ring in the final footprint so
the real ring fades in without shifting siblings. Agents that never
report usage still render nothing.
This commit is contained in:
Mohamed Boudra
2026-06-20 20:19:49 +07:00
parent 8bd6c617a3
commit 3493e0492d
4 changed files with 74 additions and 27 deletions
@@ -9,23 +9,25 @@ import { useProviderUsage } from "@/provider-usage/use-provider-usage";
import { formatTokenCount } from "./context-window-meter.utils"; import { formatTokenCount } from "./context-window-meter.utils";
interface ContextWindowMeterProps { interface ContextWindowMeterProps {
maxTokens: number; maxTokens: number | null;
usedTokens: number; usedTokens: number | null;
totalCostUsd?: number | null; totalCostUsd?: number | null;
showPercentage?: boolean; showPercentage?: boolean;
serverId?: string; serverId?: string;
/** The Paseo provider key, e.g. "claude", "gemini", "codex" */ /** The Paseo provider key, e.g. "claude", "gemini", "codex" */
provider?: string | null; provider?: string | null;
/** Reserve the meter footprint and show a loading ring while usage is pending. */
pending?: boolean;
} }
const SVG_SIZE = 16; const SVG_SIZE = 14;
const COMPACT_SVG_SIZE = 14; const COMPACT_SVG_SIZE = 12;
const CENTER = SVG_SIZE / 2; const CENTER = SVG_SIZE / 2;
const COMPACT_CENTER = COMPACT_SVG_SIZE / 2; const COMPACT_CENTER = COMPACT_SVG_SIZE / 2;
const RADIUS = 7; const RADIUS = 6;
const COMPACT_RADIUS = 6; const COMPACT_RADIUS = 5;
const STROKE_WIDTH = 2.25; const STROKE_WIDTH = 2;
const COMPACT_STROKE_WIDTH = 2; const COMPACT_STROKE_WIDTH = 1.75;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS; const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS; const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS;
@@ -100,6 +102,7 @@ export function ContextWindowMeter({
showPercentage = false, showPercentage = false,
serverId, serverId,
provider, provider,
pending = false,
}: ContextWindowMeterProps) { }: ContextWindowMeterProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -108,7 +111,8 @@ export function ContextWindowMeter({
serverId ?? null, serverId ?? null,
{ enabled: isTooltipOpen }, { enabled: isTooltipOpen },
); );
const percentage = getUsagePercentage(maxTokens, usedTokens); const percentage =
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
const handleTooltipOpenChange = useCallback( const handleTooltipOpenChange = useCallback(
(nextOpen: boolean) => { (nextOpen: boolean) => {
setIsTooltipOpen(nextOpen); setIsTooltipOpen(nextOpen);
@@ -119,14 +123,42 @@ export function ContextWindowMeter({
[refreshProviderUsage], [refreshProviderUsage],
); );
if (percentage === null) { const geometry = getMeterGeometry(showPercentage);
return null;
// No usage yet: reserve the footprint with a track-only ring while a session is
// active so the real ring fades in without shifting siblings. Render nothing when
// no usage is expected.
if (percentage === null || maxTokens === null || usedTokens === null) {
if (!pending) {
return null;
}
return (
<View style={geometry.containerStyle}>
<Svg
width={geometry.svgSize}
height={geometry.svgSize}
viewBox={`0 0 ${geometry.svgSize} ${geometry.svgSize}`}
style={styles.svg}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Circle
cx={geometry.center}
cy={geometry.center}
r={geometry.radius}
fill="none"
stroke={theme.colors.surface3}
strokeWidth={geometry.strokeWidth}
/>
</Svg>
{showPercentage ? <View style={styles.skeletonLabel} /> : null}
</View>
);
} }
const clampedPercentage = clampPercentage(percentage); const clampedPercentage = clampPercentage(percentage);
const roundedPercentage = Math.round(percentage); const roundedPercentage = Math.round(percentage);
const { svgSize, center, radius, strokeWidth, circumference, containerStyle } = const { svgSize, center, radius, strokeWidth, circumference, containerStyle } = geometry;
getMeterGeometry(showPercentage);
const dashOffset = circumference - (clampedPercentage / 100) * circumference; const dashOffset = circumference - (clampedPercentage / 100) * circumference;
const colors = getMeterColors(clampedPercentage, theme); const colors = getMeterColors(clampedPercentage, theme);
const formattedSessionCost = const formattedSessionCost =
@@ -230,8 +262,14 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
}, },
skeletonLabel: {
width: 22,
height: theme.fontSize.sm,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
},
tooltipContent: { tooltipContent: {
gap: theme.spacing[1], gap: theme.spacing[1.5],
minWidth: 200, minWidth: 200,
}, },
tooltipTitle: { tooltipTitle: {
+9 -1
View File
@@ -209,8 +209,10 @@ function renderContextWindowMeter(
showPercentage: boolean, showPercentage: boolean,
serverId: string, serverId: string,
provider: string | null, provider: string | null,
pending: boolean,
): ReactElement | null { ): ReactElement | null {
if (contextWindowMaxTokens === null || contextWindowUsedTokens === null) { const hasData = contextWindowMaxTokens !== null && contextWindowUsedTokens !== null;
if (!hasData && !pending) {
return null; return null;
} }
return ( return (
@@ -221,6 +223,7 @@ function renderContextWindowMeter(
showPercentage={showPercentage} showPercentage={showPercentage}
serverId={serverId} serverId={serverId}
provider={provider} provider={provider}
pending={pending}
/> />
); );
} }
@@ -1642,6 +1645,9 @@ export function Composer({
agentState.contextWindowUsedTokens, agentState.contextWindowUsedTokens,
); );
const contextWindowPending =
agentState.status === "initializing" || agentState.status === "running";
const contextWindowMeter = useMemo( const contextWindowMeter = useMemo(
() => () =>
renderContextWindowMeter( renderContextWindowMeter(
@@ -1651,6 +1657,7 @@ export function Composer({
isCompactLayout, isCompactLayout,
serverId, serverId,
agentState.provider, agentState.provider,
contextWindowPending,
), ),
[ [
contextWindowMaxTokens, contextWindowMaxTokens,
@@ -1659,6 +1666,7 @@ export function Composer({
isCompactLayout, isCompactLayout,
serverId, serverId,
agentState.provider, agentState.provider,
contextWindowPending,
], ],
); );
const { beforeVoiceContent, footerInlineContent } = useMemo( const { beforeVoiceContent, footerInlineContent } = useMemo(
+6 -9
View File
@@ -22,7 +22,7 @@ function ProviderUsageIcon({ iconKey, size, color = "" }: ProviderUsageIconProps
const ThemedProviderUsageIcon = withUnistyles(ProviderUsageIcon); const ThemedProviderUsageIcon = withUnistyles(ProviderUsageIcon);
const foregroundIconColor = (theme: Theme) => ({ color: theme.colors.foreground }); const mutedIconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function statusText(usage: ProviderUsage): string | null { function statusText(usage: ProviderUsage): string | null {
if (usage.status === "available") return null; if (usage.status === "available") return null;
@@ -65,11 +65,7 @@ export function ProviderUsageCard({
return ( return (
<View style={containerStyle}> <View style={containerStyle}>
<View style={styles.header}> <View style={styles.header}>
<ThemedProviderUsageIcon <ThemedProviderUsageIcon iconKey={usage.providerId} size={14} uniProps={mutedIconColor} />
iconKey={usage.providerId}
size={16}
uniProps={foregroundIconColor}
/>
<Text style={styles.name} numberOfLines={1}> <Text style={styles.name} numberOfLines={1}>
{usage.displayName} {usage.displayName}
</Text> </Text>
@@ -129,11 +125,12 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[3], gap: theme.spacing[3],
}, },
containerPadded: { containerPadded: {
gap: theme.spacing[4],
paddingVertical: theme.spacing[4], paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
}, },
containerCompact: { containerCompact: {
gap: theme.spacing[2], gap: theme.spacing[3],
}, },
header: { header: {
flexDirection: "row", flexDirection: "row",
@@ -143,7 +140,7 @@ const styles = StyleSheet.create((theme) => ({
name: { name: {
flexShrink: 1, flexShrink: 1,
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.sm,
}, },
headerSpacer: { headerSpacer: {
flex: 1, flex: 1,
@@ -170,7 +167,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
}, },
bars: { bars: {
gap: theme.spacing[2], gap: theme.spacing[3],
}, },
details: { details: {
gap: theme.spacing[1], gap: theme.spacing[1],
@@ -54,9 +54,13 @@ export function ProviderUsageTooltipSection({
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
divider: { divider: {
height: StyleSheet.hairlineWidth, height: 1,
backgroundColor: theme.colors.border, // Same token the popover draws its own outline with, so the rule reads as the
marginVertical: theme.spacing[1] + 1, // popover's edge. `border` is invisible here (equals the popover background).
backgroundColor: theme.colors.borderAccent,
marginVertical: theme.spacing[2],
// Cancel the tooltip content's horizontal padding so the rule spans edge to edge.
marginHorizontal: -theme.spacing[2],
}, },
detail: { detail: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,