Show context window usage inline in the status bar (#7607)
Signed-off-by: jh-block <jhugo@block.xyz>
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user