diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 5b33013846..b08e9c1b94 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1061,6 +1061,7 @@ Logging in with Google... Restarting Gemini CLI to continue. backgroundShells, dismissBackgroundShell, retryStatus, + isCompressing, } = useGeminiStream( config.getGeminiClient(), historyManager.history, @@ -1613,6 +1614,7 @@ Logging in with Google... Restarting Gemini CLI to continue. streamingState, shouldShowFocusHint, retryStatus, + customWittyPhrases: isCompressing ? ['Optimizing context...'] : undefined, }); const handleGlobalKeypress = useCallback( diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index f41ee20895..3c028e4399 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -17,7 +17,7 @@ import { ToolGroupMessage } from './messages/ToolGroupMessage.js'; import { GeminiMessageContent } from './messages/GeminiMessageContent.js'; import { CompressionMessage } from './messages/CompressionMessage.js'; import { WarningMessage } from './messages/WarningMessage.js'; -import { Box } from 'ink'; +import { Box, Text } from 'ink'; import { AboutBox } from './AboutBox.js'; import { StatsDisplay } from './StatsDisplay.js'; import { ModelStatsDisplay } from './ModelStatsDisplay.js'; @@ -75,6 +75,14 @@ export const HistoryItemDisplay: React.FC = ({ {itemForDisplay.type === 'user_shell' && ( )} + {itemForDisplay.type === 'auto_compression' && ( + + + (Context optimized: {itemForDisplay.compression.originalTokenCount}{' '} + → {itemForDisplay.compression.newTokenCount} tokens) + + + )} {itemForDisplay.type === 'gemini' && ( mouse interaction > should toggle paste expansion on doub ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄" `; -exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > [Pasted Text: 10 lines] -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄" -`; - -exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > [Pasted Text: 10 lines]  -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄" -`; - -exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > line1  - line2  - line3  - line4  - line5  - line6  - line7  - line8  - line9  - line10  -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄" -`; - -exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 7`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > [Pasted Text: 10 lines]  -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄" -`; - exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ > Type your message or @path/to/file diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx index 3c17a3850f..256d3badc1 100644 --- a/packages/cli/src/ui/components/messages/GeminiMessage.tsx +++ b/packages/cli/src/ui/components/messages/GeminiMessage.tsx @@ -12,7 +12,6 @@ import { theme } from '../../semantic-colors.js'; import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js'; import { useUIState } from '../../contexts/UIStateContext.js'; import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; - interface GeminiMessageProps { text: string; isPending: boolean; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index f23574858f..a0f67fb3be 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -53,6 +53,7 @@ import { type Part, type PartListUnion, FinishReason } from '@google/genai'; import type { HistoryItem, HistoryItemThinking, + HistoryItemAutoCompression, HistoryItemWithoutId, HistoryItemToolGroup, IndividualToolCallDisplay, @@ -203,6 +204,8 @@ export const useGeminiStream = ( const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] = useStateAndRef(null); + const [isCompressing, setIsCompressing] = useState(false); + const [lastGeminiActivityTime, setLastGeminiActivityTime] = useState(0); const [pushedToolCallIds, pushedToolCallIdsRef, setPushedToolCallIds] = @@ -765,7 +768,10 @@ export const useGeminiStream = ( if (pendingHistoryItemRef.current) { addItem(pendingHistoryItemRef.current, userMessageTimestamp); } - setPendingHistoryItem({ type: 'gemini', text: '' }); + setPendingHistoryItem({ + type: 'gemini', + text: '', + }); newGeminiMessageBuffer = eventValue; } // Split large messages for better rendering performance. Ideally, @@ -773,11 +779,23 @@ export const useGeminiStream = ( const splitPoint = findLastSafeSplitPoint(newGeminiMessageBuffer); if (splitPoint === newGeminiMessageBuffer.length) { // Update the existing message with accumulated content - setPendingHistoryItem((item) => ({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - type: item?.type as 'gemini' | 'gemini_content', - text: newGeminiMessageBuffer, - })); + setPendingHistoryItem((item) => { + if (!item) return null; + if (item.type === 'gemini') { + return { + ...item, + type: 'gemini', + text: newGeminiMessageBuffer, + }; + } else if (item.type === 'gemini_content') { + return { + ...item, + type: 'gemini_content', + text: newGeminiMessageBuffer, + }; + } + return item; + }); } else { // This indicates that we need to split up this Gemini Message. // Splitting a message is primarily a performance consideration. There is a @@ -799,7 +817,10 @@ export const useGeminiStream = ( }, userMessageTimestamp, ); - setPendingHistoryItem({ type: 'gemini_content', text: afterText }); + setPendingHistoryItem({ + type: 'gemini_content', + text: afterText, + }); newGeminiMessageBuffer = afterText; } return newGeminiMessageBuffer; @@ -956,14 +977,16 @@ export const useGeminiStream = ( addItem(pendingHistoryItemRef.current, userMessageTimestamp); setPendingHistoryItem(null); } - return addItem({ - type: 'info', - text: - `IMPORTANT: This conversation exceeded the compress threshold. ` + - `A compressed context will be sent for future messages (compressed from: ` + - `${eventValue?.originalTokenCount ?? 'unknown'} to ` + - `${eventValue?.newTokenCount ?? 'unknown'} tokens).`, - }); + + if (eventValue) { + addItem( + { + type: 'auto_compression', + compression: eventValue, + } as HistoryItemAutoCompression, + userMessageTimestamp, + ); + } }, [addItem, pendingHistoryItemRef, setPendingHistoryItem], ); @@ -1095,6 +1118,10 @@ export const useGeminiStream = ( let geminiMessageBuffer = ''; const toolCallRequests: ToolCallRequestInfo[] = []; for await (const event of stream) { + if (event.type !== ServerGeminiEventType.ChatCompressing) { + setIsCompressing(false); + } + if ( event.type !== ServerGeminiEventType.Thought && thoughtRef.current !== null @@ -1140,7 +1167,11 @@ export const useGeminiStream = ( event.value.contextCleared, ); break; + case ServerGeminiEventType.ChatCompressing: + setIsCompressing(true); + break; case ServerGeminiEventType.ChatCompressed: + setIsCompressing(false); handleChatCompressionEvent(event.value, userMessageTimestamp); break; case ServerGeminiEventType.ToolCallConfirmation: @@ -1683,6 +1714,7 @@ export const useGeminiStream = ( return { streamingState, + isCompressing, submitQuery, initError, pendingHistoryItems, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 8481cca71f..b17e1fa098 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -6,6 +6,7 @@ import { type CompressionStatus, + type ChatCompressionInfo, type GeminiCLIExtension, type MCPServerConfig, type ThoughtSummary, @@ -248,6 +249,11 @@ export type HistoryItemThinking = HistoryItemBase & { thought: ThoughtSummary; }; +export type HistoryItemAutoCompression = HistoryItemBase & { + type: 'auto_compression'; + compression: ChatCompressionInfo; +}; + export type HistoryItemChatList = HistoryItemBase & { type: 'chat_list'; chats: ChatDetail[]; @@ -372,6 +378,7 @@ export type HistoryItemWithoutId = | HistoryItemMcpStatus | HistoryItemChatList | HistoryItemThinking + | HistoryItemAutoCompression | HistoryItemHooksList; export type HistoryItem = HistoryItemWithoutId & { id: number }; diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 185019434b..201f737a97 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -1077,6 +1077,7 @@ ${JSON.stringify( new AbortController().signal, 'test-prompt-id', ); + await stream.next(); // Trigger ChatCompressing await stream.next(); // Trigger the generator expect(countTokensSpy).toHaveBeenCalledWith( @@ -1924,8 +1925,10 @@ ${JSON.stringify( // Assert expect(events).toEqual([ + { type: GeminiEventType.ChatCompressing }, { type: GeminiEventType.ModelInfo, value: 'default-routed-model' }, { type: GeminiEventType.InvalidStream }, + { type: GeminiEventType.ChatCompressing }, { type: GeminiEventType.Content, value: 'Continued content' }, ]); @@ -1980,6 +1983,7 @@ ${JSON.stringify( // Assert expect(events).toEqual([ + { type: GeminiEventType.ChatCompressing }, { type: GeminiEventType.ModelInfo, value: 'default-routed-model' }, { type: GeminiEventType.InvalidStream }, ]); @@ -2017,8 +2021,8 @@ ${JSON.stringify( const events = await fromAsync(stream); // Assert - // We expect 3 events (model_info + original + 1 retry) - expect(events.length).toBe(3); + // We expect 5 events (chat_compressing + model_info + original + 1 retry chat_compressing + 1 retry model_info) + expect(events.length).toBe(5); expect( events .filter((e) => e.type === GeminiEventType.ModelInfo) diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index fb9edaa7a5..97ee94b34c 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -571,6 +571,7 @@ export class GeminiClient { // Check for context window overflow const modelForLimitCheck = this._getActiveModelForCurrentTurn(); + yield { type: GeminiEventType.ChatCompressing }; const compressed = await this.tryCompressChat(prompt_id, false); if (compressed.compressionStatus === CompressionStatus.COMPRESSED) { diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index a0f5fbd7bf..6d67c86b1a 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -68,6 +68,7 @@ export enum GeminiEventType { ModelInfo = 'model_info', AgentExecutionStopped = 'agent_execution_stopped', AgentExecutionBlocked = 'agent_execution_blocked', + ChatCompressing = 'chat_compressing', } export type ServerGeminiRetryEvent = { @@ -192,6 +193,10 @@ export type ServerGeminiChatCompressedEvent = { value: ChatCompressionInfo | null; }; +export type ServerGeminiChatCompressingEvent = { + type: GeminiEventType.ChatCompressing; +}; + export type ServerGeminiMaxSessionTurnsEvent = { type: GeminiEventType.MaxSessionTurns; }; @@ -213,6 +218,7 @@ export type ServerGeminiCitationEvent = { // The original union type, now composed of the individual types export type ServerGeminiStreamEvent = | ServerGeminiChatCompressedEvent + | ServerGeminiChatCompressingEvent | ServerGeminiCitationEvent | ServerGeminiContentEvent | ServerGeminiErrorEvent diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 90101052d9..6315d78c36 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -36,7 +36,7 @@ import { PreCompressTrigger } from '../hooks/types.js'; * Default threshold for compression token count as a fraction of the model's * token limit. If the chat history exceeds this threshold, it will be compressed. */ -export const DEFAULT_COMPRESSION_TOKEN_THRESHOLD = 0.5; +export const DEFAULT_COMPRESSION_TOKEN_THRESHOLD = 0.2; /** * The fraction of the latest chat history to keep. A value of 0.3