Show context usage beside compact mode selector

This commit is contained in:
Mohamed Boudra
2026-05-31 22:38:46 +07:00
parent 360110accc
commit 2227540fad
2 changed files with 62 additions and 35 deletions
@@ -7,13 +7,19 @@ interface ContextWindowMeterProps {
maxTokens: number; maxTokens: number;
usedTokens: number; usedTokens: number;
totalCostUsd?: number | null; totalCostUsd?: number | null;
showPercentage?: boolean;
} }
const SVG_SIZE = 16; const SVG_SIZE = 16;
const COMPACT_SVG_SIZE = 14;
const CENTER = SVG_SIZE / 2; const CENTER = SVG_SIZE / 2;
const COMPACT_CENTER = COMPACT_SVG_SIZE / 2;
const RADIUS = 7; const RADIUS = 7;
const COMPACT_RADIUS = 6;
const STROKE_WIDTH = 2.25; const STROKE_WIDTH = 2.25;
const COMPACT_STROKE_WIDTH = 2;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS; const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS;
function isValidMaxTokens(value: number): boolean { function isValidMaxTokens(value: number): boolean {
return Number.isFinite(value) && value > 0; return Number.isFinite(value) && value > 0;
@@ -72,6 +78,7 @@ export function ContextWindowMeter({
maxTokens, maxTokens,
usedTokens, usedTokens,
totalCostUsd, totalCostUsd,
showPercentage = false,
}: ContextWindowMeterProps) { }: ContextWindowMeterProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const percentage = getUsagePercentage(maxTokens, usedTokens); const percentage = getUsagePercentage(maxTokens, usedTokens);
@@ -82,47 +89,56 @@ export function ContextWindowMeter({
const clampedPercentage = clampPercentage(percentage); const clampedPercentage = clampPercentage(percentage);
const roundedPercentage = Math.round(percentage); const roundedPercentage = Math.round(percentage);
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE; const svgSize = showPercentage ? COMPACT_SVG_SIZE : SVG_SIZE;
const center = showPercentage ? COMPACT_CENTER : CENTER;
const radius = showPercentage ? COMPACT_RADIUS : RADIUS;
const strokeWidth = showPercentage ? COMPACT_STROKE_WIDTH : STROKE_WIDTH;
const circumference = showPercentage ? COMPACT_CIRCUMFERENCE : CIRCUMFERENCE;
const dashOffset = circumference - (clampedPercentage / 100) * circumference;
const colors = getMeterColors(clampedPercentage, theme); const colors = getMeterColors(clampedPercentage, theme);
const formattedSessionCost = const formattedSessionCost =
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null; typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
const containerStyle = showPercentage ? styles.containerWithLabel : styles.container;
return ( return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild triggerRefProp="ref"> <TooltipTrigger asChild triggerRefProp="ref">
<Pressable <Pressable
style={styles.container} style={containerStyle}
accessibilityRole="image" accessibilityRole="image"
accessibilityLabel={`Context window ${roundedPercentage}% used`} accessibilityLabel={`Context window ${roundedPercentage}% used`}
> >
<Svg <Svg
width={SVG_SIZE} width={svgSize}
height={SVG_SIZE} height={svgSize}
viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`} viewBox={`0 0 ${svgSize} ${svgSize}`}
style={styles.svg} style={styles.svg}
accessibilityElementsHidden accessibilityElementsHidden
importantForAccessibility="no-hide-descendants" importantForAccessibility="no-hide-descendants"
> >
<Circle <Circle
cx={CENTER} cx={center}
cy={CENTER} cy={center}
r={RADIUS} r={radius}
fill="none" fill="none"
stroke={colors.track} stroke={colors.track}
strokeWidth={STROKE_WIDTH} strokeWidth={strokeWidth}
/> />
<Circle <Circle
cx={CENTER} cx={center}
cy={CENTER} cy={center}
r={RADIUS} r={radius}
fill="none" fill="none"
stroke={colors.progress} stroke={colors.progress}
strokeWidth={STROKE_WIDTH} strokeWidth={strokeWidth}
strokeLinecap="round" strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE} strokeDasharray={circumference}
strokeDashoffset={dashOffset} strokeDashoffset={dashOffset}
/> />
</Svg> </Svg>
{showPercentage ? (
<Text style={styles.percentageLabel}>{`${roundedPercentage}%`}</Text>
) : null}
</Pressable> </Pressable>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
@@ -149,9 +165,22 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
containerWithLabel: {
height: 28,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
borderRadius: theme.borderRadius.full,
},
svg: { svg: {
transform: [{ rotate: "-90deg" }], transform: [{ rotate: "-90deg" }],
}, },
percentageLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
tooltipContent: { tooltipContent: {
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
+19 -21
View File
@@ -190,6 +190,7 @@ function renderContextWindowMeter(
contextWindowMaxTokens: number | null, contextWindowMaxTokens: number | null,
contextWindowUsedTokens: number | null, contextWindowUsedTokens: number | null,
totalCostUsd: number | null, totalCostUsd: number | null,
showPercentage: boolean,
): ReactElement | null { ): ReactElement | null {
if (contextWindowMaxTokens === null || contextWindowUsedTokens === null) { if (contextWindowMaxTokens === null || contextWindowUsedTokens === null) {
return null; return null;
@@ -199,6 +200,7 @@ function renderContextWindowMeter(
maxTokens={contextWindowMaxTokens} maxTokens={contextWindowMaxTokens}
usedTokens={contextWindowUsedTokens} usedTokens={contextWindowUsedTokens}
totalCostUsd={totalCostUsd} totalCostUsd={totalCostUsd}
showPercentage={showPercentage}
/> />
); );
} }
@@ -206,13 +208,13 @@ function renderContextWindowMeter(
function resolveContextWindowPlacement( function resolveContextWindowPlacement(
meter: ReactElement | null, meter: ReactElement | null,
isMobile: boolean, isMobile: boolean,
): { beforeVoiceContent: ReactNode; footerRight: ReactNode } { ): { beforeVoiceContent: ReactNode; footerInlineContent: ReactNode } {
if (isMobile) { if (isMobile) {
return { beforeVoiceContent: null, footerRight: meter }; return { beforeVoiceContent: null, footerInlineContent: meter };
} }
return { return {
beforeVoiceContent: <View style={styles.contextWindowMeterSlot}>{meter}</View>, beforeVoiceContent: <View style={styles.contextWindowMeterSlot}>{meter}</View>,
footerRight: null, footerInlineContent: null,
}; };
} }
@@ -238,13 +240,18 @@ interface RenderAttachmentTrayArgs {
handleRemoveAttachment: (index: number) => void; handleRemoveAttachment: (index: number) => void;
} }
function renderComposerFooter(footer: ReactNode, footerRight: ReactNode): ReactElement | null { function renderComposerFooter(
if (!footer && !footerRight) return null; footer: ReactNode,
footerInlineContent: ReactNode,
): ReactElement | null {
if (!footer && !footerInlineContent) return null;
return ( return (
<View style={styles.footer}> <View style={styles.footer}>
<View style={styles.footerContent}> <View style={styles.footerContent}>
<View style={styles.footerLeft}>{footer}</View> <View style={styles.footerLeft}>
<View style={styles.footerRight}>{footerRight}</View> {footer}
{footerInlineContent}
</View>
</View> </View>
</View> </View>
); );
@@ -1468,10 +1475,11 @@ export function Composer({
contextWindowMaxTokens, contextWindowMaxTokens,
contextWindowUsedTokens, contextWindowUsedTokens,
agentState.totalCostUsd, agentState.totalCostUsd,
isMobile,
), ),
[contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd], [contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, isMobile],
); );
const { beforeVoiceContent, footerRight } = useMemo( const { beforeVoiceContent, footerInlineContent } = useMemo(
() => resolveContextWindowPlacement(contextWindowMeter, isMobile), () => resolveContextWindowPlacement(contextWindowMeter, isMobile),
[contextWindowMeter, isMobile], [contextWindowMeter, isMobile],
); );
@@ -1718,7 +1726,7 @@ export function Composer({
</View> </View>
</View> </View>
</View> </View>
{renderComposerFooter(footer, footerRight)} {renderComposerFooter(footer, footerInlineContent)}
</Animated.View> </Animated.View>
); );
} }
@@ -1782,6 +1790,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
flexShrink: 1, flexShrink: 1,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[1],
// On mobile, cancel the leading glyph's internal padding (chip paddingHorizontal) // On mobile, cancel the leading glyph's internal padding (chip paddingHorizontal)
// so its icon aligns to the composer border before the footer inset is applied. // so its icon aligns to the composer border before the footer inset is applied.
marginLeft: { marginLeft: {
@@ -1789,17 +1798,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
md: 0, md: 0,
}, },
}, },
footerRight: {
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
// On mobile, cancel the trailing glyph's internal inset (28px box around a 16px
// ring) so its right edge aligns to the composer border before the footer inset.
marginRight: {
xs: -6,
md: 0,
},
},
messageInputContainer: { messageInputContainer: {
position: "relative", position: "relative",
width: "100%", width: "100%",