Show context usage beside compact mode selector
This commit is contained in:
@@ -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],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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%",
|
||||||
|
|||||||
Reference in New Issue
Block a user