Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/features/chat/components/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,39 @@ 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 = {
className?: string;
sendMessage: UseChatHelpers<ChatUIMessage>["sendMessage"];
isLoading?: boolean;
onStop?: () => void;
messages?: ChatUIMessage[];
};

export function ChatInput({
className,
sendMessage,
isLoading = false,
onStop,
messages = [],
}: ChatInputProps) {
const { input, apiStatus, model, features, attachments, send } = useChatInputController({
sendMessage,
isLoading,
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 (
<div className={cn(`bg-background p-4 pt-0`, className)}>
Expand All @@ -59,6 +68,11 @@ export function ChatInput({
attachments.isDragging && "ring-primary ring-2",
)}
>
<ContextWindowBar
messages={messages}
contextLength={contextLength}
displayMode={contextWindowDisplay}
/>
{isIncognito && (
<div className={`
text-muted-foreground flex items-center gap-2 border-b
Expand Down
1 change: 1 addition & 0 deletions src/features/chat/components/chat-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export function ChatView({
sendMessage={sendMessage}
isLoading={isLoading}
onStop={onStop}
messages={messages}
/>
</div>
</div>
Expand Down
128 changes: 128 additions & 0 deletions src/features/chat/components/context-window-bar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={cn(
"relative w-full cursor-default overflow-hidden transition-all duration-300 ease-out",
isHovered ? "h-5" : "h-1",
)}
onMouseEnter={() => 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 */}
<div className="bg-muted/50 absolute inset-0" />

{/* Filled bar */}
<div
className={cn(
barColor,
"absolute inset-y-0 left-0 transition-all duration-500 ease-out",
isHovered ? "opacity-80" : "opacity-60",
)}
style={{ width: `${percentage}%` }}
/>

{/* Label (visible on hover) */}
<div
className={cn(
"absolute inset-0 flex items-center justify-center transition-opacity duration-200",
isHovered ? "opacity-100" : "opacity-0",
)}
>
<span className="text-foreground text-[10px] font-medium leading-none drop-shadow-sm">
~
{formatTokens(usedTokens)}
{" / "}
{formatTokens(contextLength)}
{" · ~"}
{Math.round(percentage)}
%
</span>
</div>
</div>
);
}
1 change: 1 addition & 0 deletions src/features/settings/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export async function createDefaultUserSettings(userId: string): Promise<UserSet
toolHandoffModel: "gemini-flash-lite",
desktopNotifications: false,
autoScrollDuringGeneration: true,
contextWindowDisplay: "auto",
favoriteModels: STARTER_FAVORITE_MODELS,
};

Expand Down
15 changes: 15 additions & 0 deletions src/features/settings/components/pages/input-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ const keyboardShortcuts = [
{ label: "Unfocus Input", keys: ["Esc"] },
];

const contextWindowDisplayOptions = [
{ value: "disabled" as const, label: "Disabled" },
{ value: "auto" as const, label: "Auto (>25%)" },
{ value: "always" as const, label: "Always" },
];

const sendMessageKeyboardShortcutOptions = [
{ value: "enter" as const, label: <Kbd>Enter</Kbd> },
{ value: "ctrlEnter" as const, label: (
Expand Down Expand Up @@ -92,6 +98,15 @@ export function InputPage() {
onChange={value => save({ sendMessageKeyboardShortcut: value })}
layout="flex"
/>

<SelectionCardItem
label="Context Window Usage"
description="Show a bar at the top of the input box indicating how much of the model's context window has been used."
options={contextWindowDisplayOptions}
value={settings.contextWindowDisplay ?? "auto"}
onChange={value => save({ contextWindowDisplay: value })}
layout="flex"
/>
</SettingsSection>

<Separator />
Expand Down
1 change: 1 addition & 0 deletions src/features/settings/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const DEFAULT_SETTINGS: UserSettingsData = {
toolHandoffModel: "gemini-flash-lite",
desktopNotifications: false,
autoScrollDuringGeneration: true,
contextWindowDisplay: "auto",
favoriteModels: STARTER_FAVORITE_MODELS,
};

Expand Down
3 changes: 3 additions & 0 deletions src/features/settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
});

/**
Expand Down Expand Up @@ -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<typeof preferencesSchema>;
Expand Down Expand Up @@ -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,
Expand Down
Loading