Show context window usage inline in the status bar (#7607)

Signed-off-by: jh-block <jhugo@block.xyz>
This commit is contained in:
jh-block
2026-04-15 12:13:06 +02:00
committed by GitHub
parent fb4b38471b
commit 2d3e2dfe23
6 changed files with 94 additions and 183 deletions
+6 -1
View File
@@ -21,6 +21,7 @@ import { toastError } from '../toasts';
import MentionPopover, { DisplayItemWithMatch } from './MentionPopover';
import { COST_TRACKING_ENABLED } from '../updates';
import { CostTracker } from './bottom_menu/CostTracker';
import { ContextWindowIndicator } from './bottom_menu/ContextWindowIndicator';
import { DroppedFile, useFileDrop } from '../hooks/useFileDrop';
import { Recipe } from '../recipe';
import { MessageQueue, QueuedMessage } from './MessageQueue';
@@ -1628,13 +1629,17 @@ export default function ChatInput({
</div>
</>
)}
<ContextWindowIndicator
totalTokens={totalTokens || 0}
tokenLimit={tokenLimit}
alerts={alerts}
/>
<Tooltip>
<div>
<ModelsBottomBar
sessionId={sessionId}
dropdownRef={dropdownRef}
setView={setView}
alerts={alerts}
sessionModel={effectiveModel}
sessionProvider={effectiveProvider}
onModelChanged={setModelOverride}
+1 -85
View File
@@ -41,17 +41,6 @@ const alertStyles: Record<AlertType, string> = {
[AlertType.Info]: 'dark:bg-white dark:text-black bg-black text-white',
};
const formatTokenCount = (count: number): string => {
if (count >= 1000000) {
const millions = count / 1000000;
return millions % 1 === 0 ? `${millions.toFixed(0)}M` : `${millions.toFixed(1)}M`;
} else if (count >= 1000) {
const thousands = count / 1000;
return thousands % 1 === 0 ? `${thousands.toFixed(0)}k` : `${thousands.toFixed(1)}k`;
}
return count.toString();
};
export const AlertBox = ({ alert, className }: AlertBoxProps) => {
const intl = useIntl();
const { read } = useConfig();
@@ -125,8 +114,6 @@ export const AlertBox = ({ alert, className }: AlertBoxProps) => {
>
{alert.progress ? (
<div className="flex flex-col gap-2">
<span className="text-[11px]">{alert.message}</span>
{/* Auto-compact threshold indicator with edit */}
<div className="flex items-center justify-center gap-1 min-h-[20px]">
{isEditingThreshold ? (
@@ -140,16 +127,13 @@ export const AlertBox = ({ alert, className }: AlertBoxProps) => {
value={thresholdValue}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
// Allow empty input for easier editing
if (e.target.value === '') {
setThresholdValue(1);
} else if (!isNaN(val)) {
// Clamp value between 1 and 100
setThresholdValue(Math.max(1, Math.min(100, val)));
}
}}
onBlur={(e) => {
// On blur, ensure we have a valid value
const val = parseInt(e.target.value, 10);
if (isNaN(val) || val < 1) {
setThresholdValue(1);
@@ -167,11 +151,9 @@ export const AlertBox = ({ alert, className }: AlertBoxProps) => {
}
}}
onFocus={(e) => {
// Select all text on focus for easier editing
e.target.select();
}}
onClick={(e) => {
// Prevent issues with text selection
e.stopPropagation();
}}
className="w-12 px-1 text-[10px] bg-white/10 border border-current/30 rounded outline-none text-center focus:bg-white/20 focus:border-current/50 transition-colors"
@@ -213,72 +195,6 @@ export const AlertBox = ({ alert, className }: AlertBoxProps) => {
</>
)}
</div>
<div className="flex justify-between w-full relative">
{(() => {
let closestDotIndex = -1;
if (currentThreshold !== undefined && currentThreshold > 0 && currentThreshold <= 1) {
let minDistance = Infinity;
for (let j = 0; j < 30; j++) {
const dotPos = j / 29;
const distance = Math.abs(dotPos - currentThreshold);
if (distance < minDistance) {
minDistance = distance;
closestDotIndex = j;
}
}
}
return [...Array(30)].map((_, i) => {
const progress = alert.progress!.current / alert.progress!.total;
const progressPercentage = Math.round(progress * 100);
const dotPosition = i / 29; // 0 to 1 range for 30 dots
const isActive = dotPosition <= progress;
const isThresholdDot = i === closestDotIndex;
const getProgressColor = () => {
if (progressPercentage <= 50) {
return 'bg-green-500';
} else if (progressPercentage <= 75) {
return 'bg-yellow-500';
} else if (progressPercentage <= 90) {
return 'bg-orange-500';
} else {
return 'bg-red-500';
}
};
const progressColor = getProgressColor();
const inactiveColor = 'bg-gray-300 dark:bg-gray-600';
return (
<div
key={i}
className={cn(
'rounded-full transition-all relative',
isThresholdDot
? 'h-[6px] w-[6px] -mt-[2px]' // Make threshold dot twice as large
: 'h-[2px] w-[2px]',
isActive ? progressColor : inactiveColor
)}
/>
);
});
})()}
</div>
<div className="flex justify-between items-baseline text-[11px]">
<div className="flex gap-1 items-baseline">
<span className={'dark:text-black/60 text-white/60'}>
{formatTokenCount(alert.progress!.current)}
</span>
<span className={'dark:text-black/40 text-white/40'}>
{Math.round((alert.progress!.current / alert.progress!.total) * 100)}%
</span>
</div>
<span className={'dark:text-black/60 text-white/60'}>
{formatTokenCount(alert.progress!.total)}
</span>
</div>
{alert.showCompactButton && alert.onCompact && (
<button
onClick={(e) => {
@@ -288,7 +204,7 @@ export const AlertBox = ({ alert, className }: AlertBoxProps) => {
}}
disabled={alert.compactButtonDisabled}
className={cn(
'flex items-center gap-1.5 text-[11px] outline-none mt-1',
'flex items-center justify-center gap-1.5 text-[11px] outline-none',
alert.compactButtonDisabled
? 'opacity-50 cursor-not-allowed'
: 'hover:opacity-80 cursor-pointer'
@@ -73,8 +73,8 @@ describe('AlertBox', () => {
});
});
describe('Progress Bar', () => {
it('should render progress bar when progress is provided', () => {
describe('Progress Alert', () => {
it('should render auto-compact threshold when progress is provided', async () => {
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
@@ -86,53 +86,11 @@ describe('AlertBox', () => {
renderWithIntl(<AlertBox alert={alert} />);
expect(screen.getByText('50')).toBeInTheDocument();
expect(screen.getByText('50%')).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
// Check progress bar exists
const progressDots = screen
.getByText('Context window')
.parentElement?.parentElement?.querySelectorAll('.h-\\[2px\\]');
expect(progressDots).toBeDefined();
// Should show auto-compact threshold (default 80%)
expect(await screen.findByText(/Auto compact at 80%/)).toBeInTheDocument();
});
it('should handle zero current value', () => {
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
progress: {
current: 0,
total: 100,
},
};
renderWithIntl(<AlertBox alert={alert} />);
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('0%')).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
});
it('should handle 100% progress', () => {
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
progress: {
current: 100,
total: 100,
},
};
renderWithIntl(<AlertBox alert={alert} />);
// Use getAllByText since there are multiple "100" elements (current and total)
const hundredElements = screen.getAllByText('100');
expect(hundredElements).toHaveLength(2); // One for current, one for total
expect(screen.getByText('100%')).toBeInTheDocument();
});
it('should format large numbers with k suffix', () => {
it('should not render progress dots or token counts', () => {
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
@@ -142,28 +100,14 @@ describe('AlertBox', () => {
},
};
renderWithIntl(<AlertBox alert={alert} />);
const { container } = renderWithIntl(<AlertBox alert={alert} />);
expect(screen.getByText('1.5k')).toBeInTheDocument();
expect(screen.getByText('15%')).toBeInTheDocument();
expect(screen.getByText('10k')).toBeInTheDocument();
});
it('should handle progress over 100%', () => {
const alert: Alert = {
type: AlertType.Warning,
message: 'Context window',
progress: {
current: 150,
total: 100,
},
};
renderWithIntl(<AlertBox alert={alert} />);
expect(screen.getByText('150')).toBeInTheDocument();
expect(screen.getByText('150%')).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
// Progress dots and token counts are no longer rendered
expect(screen.queryByText('1.5k')).not.toBeInTheDocument();
expect(screen.queryByText('10k')).not.toBeInTheDocument();
expect(screen.queryByText('15%')).not.toBeInTheDocument();
const progressDots = container.querySelectorAll('.h-\\[2px\\]');
expect(progressDots.length).toBe(0);
});
});
@@ -272,7 +216,7 @@ describe('AlertBox', () => {
});
describe('Combined Features', () => {
it('should render progress bar and compact button together', () => {
it('should render threshold settings and compact button together', async () => {
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
@@ -286,9 +230,7 @@ describe('AlertBox', () => {
renderWithIntl(<AlertBox alert={alert} />);
expect(screen.getByText('75')).toBeInTheDocument();
expect(screen.getByText('75%')).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
expect(await screen.findByText(/Auto compact at 80%/)).toBeInTheDocument();
expect(screen.getByText('Compact now')).toBeInTheDocument();
});
@@ -300,7 +242,6 @@ describe('AlertBox', () => {
renderWithIntl(<AlertBox alert={alert} />);
// Use a function matcher to handle the whitespace-pre-line rendering
expect(
screen.getByText(
(content) =>
@@ -319,12 +260,11 @@ describe('AlertBox', () => {
const { container } = renderWithIntl(<AlertBox alert={alert} />);
// Should still render the alert container
const alertElement = container.querySelector('.flex.flex-col.gap-2');
expect(alertElement).toBeInTheDocument();
});
it('should handle progress with zero total', () => {
it('should handle progress with zero total gracefully', async () => {
const alert: Alert = {
type: AlertType.Info,
message: 'Context window',
@@ -336,10 +276,8 @@ describe('AlertBox', () => {
renderWithIntl(<AlertBox alert={alert} />);
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
// Progress percentage would be Infinity, but it should still render
expect(screen.getByText('Infinity%')).toBeInTheDocument();
// Should still render threshold settings
expect(await screen.findByText(/Auto compact at 80%/)).toBeInTheDocument();
});
});
});
@@ -1,5 +1,5 @@
import { AppEvents } from '../../constants/events';
import { useRef, useEffect, useCallback, useState } from 'react';
import React, { useRef, useEffect, useCallback, useState } from 'react';
import { FaCircle } from 'react-icons/fa';
import { isEqual } from 'lodash';
import { cn } from '../../utils';
@@ -8,9 +8,10 @@ import { AlertBox } from '../alerts';
interface AlertPopoverProps {
alerts: Alert[];
children?: React.ReactNode;
}
export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
export default function BottomMenuAlertPopover({ alerts, children }: AlertPopoverProps) {
const [isOpen, setIsOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [wasAutoShown, setWasAutoShown] = useState(false);
@@ -187,7 +188,7 @@ export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
}, [isOpen]);
// Use shouldShowIndicator instead of alerts.length for rendering decision
if (!shouldShowIndicator) {
if (!shouldShowIndicator && !children) {
return null;
}
@@ -204,17 +205,20 @@ export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
<>
<div className="relative">
<button
type="button"
ref={triggerRef}
className="cursor-pointer flex items-center justify-center min-w-5 min-h-5 rounded hover:bg-background-secondary"
className="cursor-pointer flex items-center gap-1.5 min-h-5 rounded hover:bg-background-secondary px-1"
onClick={() => {
setIsOpen(true);
if (alerts.length > 0) setIsOpen(true);
}}
onMouseEnter={() => {
setIsOpen(true);
setIsHovered(true);
setWasAutoShown(false);
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
if (alerts.length > 0) {
setIsOpen(true);
setIsHovered(true);
setWasAutoShown(false);
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
}
}
}}
onMouseLeave={() => {
@@ -227,9 +231,12 @@ export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
}, 100);
}}
>
<div className={cn('relative', triggerColor)}>
<FaCircle size={5} />
</div>
{shouldShowIndicator && (
<div className={cn('relative', triggerColor)}>
<FaCircle size={5} />
</div>
)}
{children}
</button>
</div>
@@ -0,0 +1,49 @@
import BottomMenuAlertPopover from './BottomMenuAlertPopover';
import { Alert } from '../alerts';
interface ContextWindowIndicatorProps {
totalTokens: number;
tokenLimit: number;
alerts: Alert[];
}
const formatTokenCount = (count: number): string => {
if (count >= 1000000) {
const millions = count / 1000000;
return millions % 1 === 0 ? `${millions.toFixed(0)}M` : `${millions.toFixed(1)}M`;
} else if (count >= 1000) {
const thousands = count / 1000;
return thousands % 1 === 0 ? `${thousands.toFixed(0)}k` : `${thousands.toFixed(1)}k`;
}
return count.toString();
};
const getProgressColor = (percentage: number): string => {
if (percentage <= 75) return 'text-text-primary/70';
if (percentage <= 90) return 'text-orange-500';
return 'text-red-500';
};
export function ContextWindowIndicator({
totalTokens,
tokenLimit,
alerts,
}: ContextWindowIndicatorProps) {
if (!tokenLimit) return null;
const percentage = Math.round((totalTokens / tokenLimit) * 100);
const colorClass = getProgressColor(percentage);
return (
<>
<div className="flex items-center h-full">
<BottomMenuAlertPopover alerts={alerts}>
<span className={`text-xs font-mono ${colorClass}`}>
{formatTokenCount(totalTokens)} / {formatTokenCount(tokenLimit)}
</span>
</BottomMenuAlertPopover>
</div>
<div className="w-px h-4 bg-border-primary mx-2" />
</>
);
}
@@ -12,8 +12,7 @@ import {
import { useConfig } from '../../../ConfigContext';
import { getProviderMetadata } from '../modelInterface';
import { getModelDisplayName } from '../predefinedModelsUtils';
import { Alert } from '../../../alerts';
import BottomMenuAlertPopover from '../../../bottom_menu/BottomMenuAlertPopover';
import { ModelSettingsPanel } from '../../localInference/ModelSettingsPanel';
import { ScrollArea } from '../../../ui/scroll-area';
import { defineMessages, useIntl } from '../../../../i18n';
@@ -45,7 +44,6 @@ interface ModelsBottomBarProps {
sessionId: string | null;
dropdownRef: React.RefObject<HTMLDivElement>;
setView: (view: View) => void;
alerts: Alert[];
sessionModel?: string | null;
sessionProvider?: string | null;
onModelChanged: (override: { model: string; provider: string }) => void;
@@ -56,7 +54,6 @@ export default function ModelsBottomBar({
sessionId,
dropdownRef,
setView,
alerts,
sessionModel,
sessionProvider,
onModelChanged,
@@ -120,7 +117,6 @@ export default function ModelsBottomBar({
return (
<div className="relative flex items-center" ref={dropdownRef}>
<BottomMenuAlertPopover alerts={alerts} />
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center hover:cursor-pointer max-w-[180px] md:max-w-[200px] lg:max-w-[380px] min-w-0 text-text-primary/70 hover:text-text-primary transition-colors">
<div className="flex items-center truncate max-w-[130px] md:max-w-[200px] lg:max-w-[360px] min-w-0">