feat: show remaining context window for Claude Code, Codex, and OpenCode

Track and display context window usage across all three agent providers,
letting users see how much context remains before hitting limits.
This commit is contained in:
Mohamed Boudra
2026-04-07 15:03:29 +07:00
parent 022eb33234
commit e06b691d5d
16 changed files with 1358 additions and 72 deletions
@@ -12,6 +12,7 @@ import {
DraftAgentStatusBar,
type DraftAgentStatusBarProps,
} from "./agent-status-bar";
import { ContextWindowMeter } from "./context-window-meter";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { useSessionStore } from "@/stores/session-store";
import {
@@ -135,6 +136,8 @@ export function AgentInputArea({
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
return {
status: agent?.status ?? null,
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
};
}),
);
@@ -636,6 +639,25 @@ export function AgentInputArea({
</View>
);
const hasContextWindowMeter =
typeof agentState.contextWindowMaxTokens === "number" &&
typeof agentState.contextWindowUsedTokens === "number";
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
const contextWindowUsedTokens = hasContextWindowMeter
? agentState.contextWindowUsedTokens
: null;
const beforeVoiceContent = (
<View style={styles.contextWindowMeterSlot}>
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
<ContextWindowMeter
maxTokens={contextWindowMaxTokens}
usedTokens={contextWindowUsedTokens}
/>
) : null}
</View>
);
const leftContent =
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
<DraftAgentStatusBar {...statusControls} />
@@ -715,6 +737,7 @@ export function AgentInputArea({
disabled={isSubmitLoading}
isInputActive={isInputActive}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}
voiceServerId={serverId}
voiceAgentId={agentId}
@@ -790,6 +813,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
contextWindowMeterSlot: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
},
realtimeVoiceButton: {
width: 28,
height: 28,
@@ -859,6 +859,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
model: currentAgent.model,
features: currentAgent.features,
thinkingOptionId: currentAgent.thinkingOptionId,
lastUsage: currentAgent.lastUsage,
}
: null;
}),
@@ -0,0 +1,153 @@
import { Pressable, Text, View } from "react-native";
import Svg, { Circle } from "react-native-svg";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
type ContextWindowMeterProps = {
maxTokens: number;
usedTokens: number;
};
const SVG_SIZE = 20;
const CENTER = SVG_SIZE / 2;
const RADIUS = 7;
const STROKE_WIDTH = 2.25;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
function isValidMaxTokens(value: number): boolean {
return Number.isFinite(value) && value > 0;
}
function isValidUsedTokens(value: number): boolean {
return Number.isFinite(value) && value >= 0;
}
function getUsagePercentage(maxTokens: number, usedTokens: number): number | null {
if (!isValidMaxTokens(maxTokens) || !isValidUsedTokens(usedTokens)) {
return null;
}
return (usedTokens / maxTokens) * 100;
}
function clampPercentage(value: number): number {
return Math.max(0, Math.min(100, value));
}
function formatTokenCount(value: number): string {
if (value >= 1_000_000) {
return `${Math.round(value / 1_000_000)}m`;
}
if (value >= 1_000) {
return `${Math.round(value / 1_000)}k`;
}
return Math.round(value).toString();
}
function getMeterColors(
percentage: number,
theme: ReturnType<typeof useUnistyles>["theme"],
): { progress: string; track: string } {
const track = theme.colors.surface3;
if (percentage > 90) {
return { progress: theme.colors.destructive, track };
}
if (percentage >= 70) {
return { progress: theme.colors.palette.amber[500], track };
}
return { progress: theme.colors.foregroundMuted, track };
}
export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const percentage = getUsagePercentage(maxTokens, usedTokens);
if (percentage === null) {
return null;
}
const clampedPercentage = clampPercentage(percentage);
const roundedPercentage = Math.round(percentage);
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE;
const colors = getMeterColors(clampedPercentage, theme);
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
style={styles.container}
accessibilityRole="image"
accessibilityLabel={`Context window ${roundedPercentage}% used`}
>
<Svg
width={SVG_SIZE}
height={SVG_SIZE}
viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`}
style={styles.svg}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
stroke={colors.track}
strokeWidth={STROKE_WIDTH}
/>
<Circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
stroke={colors.progress}
strokeWidth={STROKE_WIDTH}
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={dashOffset}
/>
</Svg>
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipContent}>
<Text style={styles.tooltipTitle}>Context window</Text>
<Text style={styles.tooltipText}>{`${roundedPercentage}% used`}</Text>
<Text
style={styles.tooltipDetail}
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
</View>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
svg: {
transform: [{ rotate: "-90deg" }],
},
tooltipContent: {
gap: theme.spacing[1],
},
tooltipTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.4,
},
tooltipDetail: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: theme.fontSize.xs * 1.4,
},
}));
@@ -79,6 +79,8 @@ export interface MessageInputProps {
isInputActive?: boolean;
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
leftContent?: React.ReactNode;
/** Content to render on the right side before the voice button (e.g., context window meter) */
beforeVoiceContent?: React.ReactNode;
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
rightContent?: React.ReactNode;
voiceServerId?: string;
@@ -201,6 +203,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
disabled = false,
isInputActive = true,
leftContent,
beforeVoiceContent,
rightContent,
voiceServerId,
voiceAgentId,
@@ -1015,6 +1018,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
{/* Right: voice button, contextual button (realtime/send/cancel) */}
<View style={styles.rightButtonGroup}>
{beforeVoiceContent}
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleVoicePress}
+28 -10
View File
@@ -44,6 +44,7 @@ type TooltipContextValue = {
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<View | null>;
enabled: boolean;
openOnPress: boolean;
delayDuration: number;
};
@@ -107,6 +108,18 @@ function measureElement(element: View): Promise<Rect> {
});
}
function isMobileTooltipEnvironment(): boolean {
if (Platform.OS !== "web") {
return true;
}
if (typeof navigator === "undefined") {
return false;
}
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
}
function computePosition({
triggerRect,
contentSize,
@@ -214,12 +227,8 @@ export function Tooltip({
onOpenChange,
});
const isWeb = Platform.OS === "web";
const isMobileWeb =
isWeb &&
typeof navigator !== "undefined" &&
/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
const enabled = isWeb ? (isMobileWeb ? enabledOnMobile : enabledOnDesktop) : enabledOnMobile;
const isMobile = isMobileTooltipEnvironment();
const enabled = isMobile ? enabledOnMobile : enabledOnDesktop;
const value = useMemo<TooltipContextValue>(
() => ({
@@ -227,9 +236,10 @@ export function Tooltip({
setOpen: setIsOpen,
triggerRef,
enabled,
openOnPress: isMobile,
delayDuration,
}),
[isOpen, setIsOpen, enabled, delayDuration],
[isOpen, setIsOpen, enabled, isMobile, delayDuration],
);
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
@@ -323,9 +333,17 @@ export function TooltipTrigger({
const handlePress = useCallback(
(e: any) => {
onPress?.(e);
if (!ctx.enabled || disabled) {
return;
}
if (ctx.openOnPress) {
clearOpenTimer();
ctx.setOpen(true);
return;
}
close();
},
[close, onPress],
[clearOpenTimer, close, ctx, disabled, onPress],
);
const triggerProps = {
@@ -492,7 +510,7 @@ export function TooltipContent({
statusBarTranslucent={Platform.OS === "android"}
onRequestClose={() => ctx.setOpen(false)}
>
<View pointerEvents="box-none" style={styles.overlay}>
<Pressable style={styles.overlay} onPress={() => ctx.setOpen(false)}>
<Animated.View
pointerEvents="none"
entering={FadeIn.duration(80)}
@@ -513,7 +531,7 @@ export function TooltipContent({
>
{children}
</Animated.View>
</View>
</Pressable>
</Modal>
);
}