diff --git a/src/features/chat/components/chat-input.tsx b/src/features/chat/components/chat-input.tsx index 06cf1bba..c3809002 100644 --- a/src/features/chat/components/chat-input.tsx +++ b/src/features/chat/components/chat-input.tsx @@ -16,9 +16,11 @@ import { useChatInputController, } from "~/features/chat/hooks/use-chat-input-controller"; import { useChatUIStore } from "~/features/chat/store"; +import { useUserSettings } from "~/features/settings/hooks/use-user-settings"; import { cn } from "~/lib/utils"; import { ChatInputCapabilities } from "./chat-input-capabilities"; +import { ContextWindowBar } from "./context-window-bar"; import { ModelSelector } from "./ui/model-selector"; type ChatInputProps = { @@ -26,6 +28,7 @@ type ChatInputProps = { sendMessage: UseChatHelpers["sendMessage"]; isLoading?: boolean; onStop?: () => void; + messages?: ChatUIMessage[]; }; export function ChatInput({ @@ -33,6 +36,7 @@ export function ChatInput({ sendMessage, isLoading = false, onStop, + messages = [], }: ChatInputProps) { const { input, apiStatus, model, features, attachments, send } = useChatInputController({ sendMessage, @@ -40,6 +44,11 @@ export function ChatInput({ onStop, }); const isIncognito = useChatUIStore(state => state.isIncognito); + const { data: settings } = useUserSettings(); + const contextWindowDisplay = settings?.contextWindowDisplay ?? "auto"; + + const selectedModel = model.favorites.find(m => m.id === model.selectedId); + const contextLength = selectedModel?.contextLength ?? 0; return (
@@ -59,6 +68,11 @@ export function ChatInput({ attachments.isDragging && "ring-primary ring-2", )} > + {isIncognito && (
diff --git a/src/features/chat/components/context-window-bar.tsx b/src/features/chat/components/context-window-bar.tsx new file mode 100644 index 00000000..243d2058 --- /dev/null +++ b/src/features/chat/components/context-window-bar.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import type { ChatUIMessage } from "~/features/chat/types"; + +import { cn } from "~/lib/utils"; + +type ContextWindowBarProps = { + messages: ChatUIMessage[]; + contextLength: number; + displayMode: "disabled" | "auto" | "always"; +}; + +export function ContextWindowBar({ + messages, + contextLength, + displayMode, +}: ContextWindowBarProps) { + const [isHovered, setIsHovered] = useState(false); + + const { usedTokens, percentage } = useMemo(() => { + // Estimate context window usage by summing content that accumulates in history. + // + // We avoid using inputTokens from metadata because totalUsage.inputTokens + // is summed across all multi-step tool calls (search → extract → respond), + // dramatically over-counting the actual context size. + // + // Instead we estimate from the actual message content: + // - Assistant text/reasoning parts: estimate from character count (÷4) + // - Tool result parts: estimate from serialized output size (÷4) + // (search sources, extracted content — these are the largest contributors) + // - User messages: estimate from character count (÷4) + // - Baseline: ~500 tokens for system prompt + tool definitions + const CHARS_PER_TOKEN = 4; + const SYSTEM_PROMPT_BASELINE = 500; + let total = SYSTEM_PROMPT_BASELINE; + + for (const msg of messages) { + if (!msg.parts) continue; + + for (const part of msg.parts) { + if (part.type === "text") { + total += Math.ceil(((part as { text: string }).text?.length ?? 0) / CHARS_PER_TOKEN); + } + else if (part.type === "reasoning") { + total += Math.ceil(((part as { text: string }).text?.length ?? 0) / CHARS_PER_TOKEN); + } + else if (part.type.startsWith("tool-")) { + const toolPart = part as { input?: unknown; output?: unknown }; + const inputSize = toolPart.input ? JSON.stringify(toolPart.input).length : 0; + const outputSize = toolPart.output ? JSON.stringify(toolPart.output).length : 0; + total += Math.ceil((inputSize + outputSize) / CHARS_PER_TOKEN); + } + else if (part.type === "file") { + // File parts contribute their URL/data length + total += Math.ceil(((part as { url?: string }).url?.length ?? 0) / CHARS_PER_TOKEN); + } + } + } + + const pct = contextLength > 0 ? Math.min((total / contextLength) * 100, 100) : 0; + return { usedTokens: total, percentage: pct }; + }, [messages, contextLength]); + + if (displayMode === "disabled") return null; + if (displayMode === "auto" && percentage < 25) return null; + if (contextLength === 0) return null; + + const barColor = percentage >= 90 + ? "bg-destructive" + : percentage >= 70 + ? "bg-yellow-500" + : "bg-primary"; + + const formatTokens = (tokens: number) => { + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; + if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`; + return tokens.toLocaleString(); + }; + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + role="meter" + aria-valuenow={percentage} + aria-valuemin={0} + aria-valuemax={100} + aria-label={`Context window usage: ~${formatTokens(usedTokens)} / ${formatTokens(contextLength)} tokens (~${Math.round(percentage)}%)`} + > + {/* Background track */} +
+ + {/* Filled bar */} +
+ + {/* Label (visible on hover) */} +
+ + ~ + {formatTokens(usedTokens)} + {" / "} + {formatTokens(contextLength)} + {" · ~"} + {Math.round(percentage)} + % + +
+
+ ); +} diff --git a/src/features/settings/actions.ts b/src/features/settings/actions.ts index fab61c06..2df0b7e4 100644 --- a/src/features/settings/actions.ts +++ b/src/features/settings/actions.ts @@ -118,6 +118,7 @@ export async function createDefaultUserSettings(userId: string): Promise25%)" }, + { value: "always" as const, label: "Always" }, +]; + const sendMessageKeyboardShortcutOptions = [ { value: "enter" as const, label: Enter }, { value: "ctrlEnter" as const, label: ( @@ -92,6 +98,15 @@ export function InputPage() { onChange={value => save({ sendMessageKeyboardShortcut: value })} layout="flex" /> + + save({ contextWindowDisplay: value })} + layout="flex" + /> diff --git a/src/features/settings/queries.ts b/src/features/settings/queries.ts index 4e52643a..9f3c708c 100644 --- a/src/features/settings/queries.ts +++ b/src/features/settings/queries.ts @@ -33,6 +33,7 @@ const DEFAULT_SETTINGS: UserSettingsData = { toolHandoffModel: "gemini-flash-lite", desktopNotifications: false, autoScrollDuringGeneration: true, + contextWindowDisplay: "auto", favoriteModels: STARTER_FAVORITE_MODELS, }; diff --git a/src/features/settings/types.ts b/src/features/settings/types.ts index 48f5e971..0b4869f3 100644 --- a/src/features/settings/types.ts +++ b/src/features/settings/types.ts @@ -64,6 +64,7 @@ export const preferencesSchema = z.object({ toolHandoffModel: z.enum(toolModelIds).default("gemini-flash-lite"), desktopNotifications: z.boolean().default(false), autoScrollDuringGeneration: z.boolean().default(true), + contextWindowDisplay: z.enum(["disabled", "auto", "always"]).default("auto"), }); /** @@ -96,6 +97,7 @@ export const preferencesUpdateSchema = z.object({ toolHandoffModel: z.enum(toolModelIds).optional(), desktopNotifications: z.boolean().optional(), autoScrollDuringGeneration: z.boolean().optional(), + contextWindowDisplay: z.enum(["disabled", "auto", "always"]).optional(), }); export type PreferencesInput = z.infer; @@ -182,6 +184,7 @@ export type UserSettingsData = { toolHandoffModel: ToolModelId; desktopNotifications: boolean; autoScrollDuringGeneration: boolean; + contextWindowDisplay: "disabled" | "auto" | "always"; // List of favorite model IDs from OpenRouter (max 10) favoriteModels?: string[]; // Derived: which providers have a key configured (server can verify server-stored keys,