diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 8948302ce1..d5424b7422 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -282,9 +282,13 @@ const TaskHeader = ({ sideOffset={8}> {(() => { - const percentage = Math.round( - (((contextTokens || 0) + reservedForOutput) / contextWindow) * 100, - ) + // Calculate percentage of available input space used + // Available input space = context window - reserved for output + const availableInputSpace = contextWindow - reservedForOutput + const percentage = + availableInputSpace > 0 + ? Math.round(((contextTokens || 0) / availableInputSpace) * 100) + : 0 return ( <> diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 07aa5480af..c4ebe06973 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -91,6 +91,26 @@ vi.mock("@roo/array", () => ({ }, })) +// Create a variable to hold the mock model info for useSelectedModel +let mockModelInfo: { contextWindow: number; maxTokens: number } | undefined = undefined + +// Mock useSelectedModel hook +vi.mock("@/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: () => ({ + provider: "anthropic", + id: "test-model", + info: mockModelInfo, + isLoading: false, + isError: false, + }), +})) + +// Mock getModelMaxOutputTokens from @roo/api +let mockMaxOutputTokens = 0 +vi.mock("@roo/api", () => ({ + getModelMaxOutputTokens: () => mockMaxOutputTokens, +})) + describe("TaskHeader", () => { const defaultProps: TaskHeaderProps = { task: { type: "say", ts: Date.now(), text: "Test task", images: [] }, @@ -402,4 +422,52 @@ describe("TaskHeader", () => { expect(backButton?.querySelector("svg.lucide-arrow-left")).toBeInTheDocument() }) }) + + describe("Context window percentage calculation", () => { + // The percentage should be calculated as: + // contextTokens / (contextWindow - reservedForOutput) * 100 + // This represents the percentage of AVAILABLE input space used, + // not the percentage of the total context window. + + beforeEach(() => { + // Set up mock model with known contextWindow + mockModelInfo = { contextWindow: 1000, maxTokens: 200 } + // Set up mock for getModelMaxOutputTokens to return reservedForOutput + mockMaxOutputTokens = 200 + }) + + afterEach(() => { + // Reset mocks + mockModelInfo = undefined + mockMaxOutputTokens = 0 + }) + + it("should calculate percentage based on available input space, not total context window", () => { + // With the formula: contextTokens / (contextWindow - reservedForOutput) * 100 + // If contextTokens = 200, contextWindow = 1000, reservedForOutput = 200 + // Then available input space = 1000 - 200 = 800 + // Percentage = 200 / 800 * 100 = 25% + // + // Old (incorrect) formula would have been: (200 + 200) / 1000 * 100 = 40% + + renderTaskHeader({ contextTokens: 200 }) + + // The percentage should be rendered in the collapsed header state + // Verify that 25% is displayed (correct formula) and NOT 40% (old incorrect formula) + expect(screen.getByText("25%")).toBeInTheDocument() + expect(screen.queryByText("40%")).not.toBeInTheDocument() + }) + + it("should handle edge case when available input space is zero", () => { + // When contextWindow equals reservedForOutput, available space is 0 + // The percentage should be 0 to avoid division by zero + mockModelInfo = { contextWindow: 200, maxTokens: 200 } + mockMaxOutputTokens = 200 + + renderTaskHeader({ contextTokens: 100 }) + + // Should show 0% when available input space is 0 + expect(screen.getByText("0%")).toBeInTheDocument() + }) + }) })