diff --git a/packages/cli/src/ui/commands/compressCommand.test.ts b/packages/cli/src/ui/commands/compressCommand.test.ts index a0c9418817..7be1d83336 100644 --- a/packages/cli/src/ui/commands/compressCommand.test.ts +++ b/packages/cli/src/ui/commands/compressCommand.test.ts @@ -98,6 +98,7 @@ describe('compressCommand', () => { beforePercentage: 20, afterPercentage: 10, isManual: true, + thresholdPercentage: 20, }, }, expect.any(Number), diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index 58bf63dd6f..4728ae44b1 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -76,8 +76,9 @@ export const compressCommand: SlashCommand = { isPending: false, beforePercentage, afterPercentage, - compressionStatus: compressed.compressionStatus, + compressionStatus: (Number(compressed.compressionStatus) as unknown) as CompressionStatus, isManual: true, + thresholdPercentage: Math.round(threshold * 100), }, } as HistoryItemCompression, Date.now(), diff --git a/packages/cli/src/ui/components/messages/CompressionMessage.tsx b/packages/cli/src/ui/components/messages/CompressionMessage.tsx index 2d17c4b6e9..6e319b0f5c 100644 --- a/packages/cli/src/ui/components/messages/CompressionMessage.tsx +++ b/packages/cli/src/ui/components/messages/CompressionMessage.tsx @@ -22,8 +22,13 @@ export interface CompressionDisplayProps { export function CompressionMessage({ compression, }: CompressionDisplayProps): React.JSX.Element { - const { isPending, beforePercentage, afterPercentage, compressionStatus } = - compression; + const { + isPending, + beforePercentage, + afterPercentage, + compressionStatus, + thresholdPercentage, + } = compression; const getCompressionText = () => { if (isPending) { @@ -31,8 +36,13 @@ export function CompressionMessage({ } switch (compressionStatus) { - case CompressionStatus.COMPRESSED: - return `Context compressed (${beforePercentage}% ➔ ${afterPercentage}%).`; + case CompressionStatus.COMPRESSED: { + let text = `Context compressed (${beforePercentage}% → ${afterPercentage}%).`; + if (thresholdPercentage != null) { + text += ` Adjust threshold (${thresholdPercentage}%) in /settings.`; + } + return text; + } case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT: return 'Compression was not beneficial for this history size.'; case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR: diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index fb2fca9108..dc0d7f537d 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -2637,6 +2637,7 @@ describe('useGeminiStream', () => { afterPercentage: 5, compressionStatus: 'compressed', isManual: false, + thresholdPercentage: 20, }, }), expect.any(Number), @@ -2696,6 +2697,68 @@ describe('useGeminiStream', () => { }); }); + it('should add informational messages when ChatCompressed event is received with a large prompt even if showContextCompression is false', async () => { + vi.mocked(tokenLimit).mockReturnValue(10000); + vi.mocked( + mockConfig.getContextWindowCompressionThreshold, + ).mockReturnValue(0.2); // 20% + vi.mocked(mockConfig.getShowContextCompression).mockReturnValue(false); + + // Setup mock to return a stream with ChatCompressed event and a large requestTokenCount (25%) + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.ChatCompressed, + value: { + originalTokenCount: 1000, + newTokenCount: 500, + compressionStatus: 'compressed', + requestTokenCount: 2500, // 25% > 20% + }, + }; + yield { + type: ServerGeminiEventType.Content, + value: 'Response after compression', + }; + yield { + type: ServerGeminiEventType.Finished, + value: { + finishReason: 'STOP', + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 20, + totalTokenCount: 30, + }, + }, + }; + })(), + ); + + const { result } = renderHookWithDefaults(); + + // Submit a query + await act(async () => { + await result.current.submitQuery('Test large prompt compression'); + }); + + // Check that compression message WAS added despite the setting + await waitFor(() => { + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'compression', + compression: expect.objectContaining({ + beforePercentage: 10, + afterPercentage: 5, + compressionStatus: 'compressed', + isManual: false, + thresholdPercentage: 20, + }), + }), + expect.any(Number), + ); + }); + }); + it.each([ { reason: 'STOP', diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 06913243f2..4dc9c6e601 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -38,8 +38,12 @@ import { GeminiCliOperation, getPlanModeExitMessage, isBackgroundExecutionData, +<<<<<<< HEAD Kind, ACTIVATE_SKILL_TOOL_NAME, +======= + CompressionStatus, +>>>>>>> 97ff2bea4 (feat(ui): restore threshold hint and thin arrow to compression message) } from '@google/gemini-cli-core'; import type { Config, @@ -1153,7 +1157,12 @@ export const useGeminiStream = ( ? Math.round((eventValue.newTokenCount / limit) * 100) : null; - if (!config.getShowContextCompression()) { + const threshold = config.getContextWindowCompressionThreshold(); + const isLargePrompt = + eventValue?.requestTokenCount != null && + eventValue.requestTokenCount / limit > threshold; + + if (!config.getShowContextCompression() && !isLargePrompt) { return; } @@ -1164,8 +1173,9 @@ export const useGeminiStream = ( isPending: false, beforePercentage, afterPercentage, - compressionStatus: eventValue?.compressionStatus ?? null, + compressionStatus: eventValue ? ((Number(eventValue.compressionStatus) as unknown) as CompressionStatus) : null, isManual: false, + thresholdPercentage: Math.round(threshold * 100), }, timestamp: new Date(userMessageTimestamp), } as HistoryItemWithoutId, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 4a8d6a5c34..d34bcbd220 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -141,7 +141,8 @@ export interface CompressionProps { beforePercentage: number | null; afterPercentage: number | null; compressionStatus: CompressionStatus | null; - isManual?: boolean; + isManual: boolean; + thresholdPercentage?: number | null; } /** diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index e93eedf055..281b12c271 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -248,6 +248,9 @@ describe('Gemini Client (client.ts)', () => { getEnableHooks: vi.fn().mockReturnValue(false), getChatCompression: vi.fn().mockReturnValue(undefined), getCompressionThreshold: vi.fn().mockReturnValue(undefined), + getShowContextWindowWarning: vi.fn().mockReturnValue(false), + getShowContextCompression: vi.fn().mockReturnValue(false), + getContextWindowCompressionThreshold: vi.fn().mockReturnValue(0.2), getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false), getShowModelInfoInChat: vi.fn().mockReturnValue(false), getContinueOnFailedApiCall: vi.fn(), @@ -1617,6 +1620,7 @@ ${JSON.stringify( originalTokenCount: initialTokenCount, newTokenCount: 400, compressionStatus: CompressionStatus.COMPRESSED, + requestTokenCount: 50, // Added to match updated interface }; }); @@ -1643,10 +1647,13 @@ ${JSON.stringify( }), ); - // 2. Should contain compression event + // 2. Should contain compression event with requestTokenCount expect(events).toContainEqual( expect.objectContaining({ type: GeminiEventType.ChatCompressed, + value: expect.objectContaining({ + requestTokenCount: expect.any(Number), + }), }), ); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 6f36fafc22..272fee6836 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -608,7 +608,19 @@ export class GeminiClient { // Check for context window overflow const modelForLimitCheck = this._getActiveModelForCurrentTurn(); - const compressed = await this.tryCompressChat(prompt_id, false); + // Estimate tokens. For text-only requests, we estimate based on character length. + // For requests with non-text parts (like images, tools), we use the countTokens API. + const estimatedRequestTokenCount = await calculateRequestTokenCount( + request, + this.getContentGeneratorOrFail(), + modelForLimitCheck, + ); + + const compressed = await this.tryCompressChat( + prompt_id, + false, + estimatedRequestTokenCount, + ); if (compressed.compressionStatus === CompressionStatus.COMPRESSED) { yield { type: GeminiEventType.ChatCompressed, value: compressed }; @@ -619,17 +631,13 @@ export class GeminiClient { await this.tryMaskToolOutputs(this.getHistory()); - // Estimate tokens. For text-only requests, we estimate based on character length. - // For requests with non-text parts (like images, tools), we use the countTokens API. - const estimatedRequestTokenCount = await calculateRequestTokenCount( - request, - this.getContentGeneratorOrFail(), - modelForLimitCheck, - ); - if (estimatedRequestTokenCount > remainingTokenCount) { if (!this.config.getShowContextWindowWarning()) { - const forcedCompressed = await this.tryCompressChat(prompt_id, true); + const forcedCompressed = await this.tryCompressChat( + prompt_id, + true, + estimatedRequestTokenCount, + ); if ( forcedCompressed.compressionStatus === CompressionStatus.COMPRESSED ) { @@ -1175,6 +1183,7 @@ export class GeminiClient { async tryCompressChat( prompt_id: string, force: boolean = false, + requestTokenCount?: number, ): Promise { // If the model is 'auto', we will use a placeholder model to check. // Compression occurs before we choose a model, so calling `count_tokens` @@ -1190,6 +1199,11 @@ export class GeminiClient { this.hasFailedCompressionAttempt, ); + const resultInfo = { + ...info, + requestTokenCount, + }; + if ( info.compressionStatus === CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT @@ -1225,7 +1239,7 @@ export class GeminiClient { } } - return info; + return resultInfo; } /** diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 9c0e536c48..c3118d84f9 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -188,6 +188,7 @@ export interface ChatCompressionInfo { originalTokenCount: number; newTokenCount: number; compressionStatus: CompressionStatus; + requestTokenCount?: number; } export type ServerGeminiChatCompressedEvent = {