diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index c8814969f7..d37f3961ce 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -1392,7 +1392,12 @@ describe('AppContainer State Management', () => { pressKey({ name: 'c', ctrl: true }, 2); expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(2); - expect(mockHandleSlashCommand).toHaveBeenCalledWith('/quit'); + expect(mockHandleSlashCommand).toHaveBeenCalledWith( + '/quit', + undefined, + undefined, + false, + ); unmount(); }); @@ -1432,7 +1437,12 @@ describe('AppContainer State Management', () => { pressKey({ name: 'd', ctrl: true }, 2); - expect(mockHandleSlashCommand).toHaveBeenCalledWith('/quit'); + expect(mockHandleSlashCommand).toHaveBeenCalledWith( + '/quit', + undefined, + undefined, + false, + ); unmount(); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index a827483cd9..ee2c18d1a2 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -147,7 +147,9 @@ const SHELL_HEIGHT_PADDING = 10; export const AppContainer = (props: AppContainerProps) => { const { config, initializationResult, resumedSessionData } = props; - const historyManager = useHistory(); + const historyManager = useHistory({ + chatRecordingService: config.getGeminiClient()?.getChatRecordingService(), + }); useMemoryMonitor(historyManager); const settings = useSettings(); const isAlternateBuffer = useAlternateBuffer(); @@ -1026,7 +1028,7 @@ Logging in with Google... Please restart Gemini CLI to continue. recordExitFail(config); } if (ctrlCPressCount > 1) { - handleSlashCommand('/quit'); + handleSlashCommand('/quit', undefined, undefined, false); } else { ctrlCTimerRef.current = setTimeout(() => { setCtrlCPressCount(0); @@ -1044,7 +1046,7 @@ Logging in with Google... Please restart Gemini CLI to continue. recordExitFail(config); } if (ctrlDPressCount > 1) { - handleSlashCommand('/quit'); + handleSlashCommand('/quit', undefined, undefined, false); } else { ctrlDTimerRef.current = setTimeout(() => { setCtrlDPressCount(0); diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index 5a261caa63..859c04a231 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -30,6 +30,7 @@ describe('clearCommand', () => { beforeEach(() => { mockResetChat = vi.fn().mockResolvedValue(undefined); + const mockGetChatRecordingService = vi.fn(); vi.clearAllMocks(); mockContext = createMockCommandContext({ @@ -38,7 +39,11 @@ describe('clearCommand', () => { getGeminiClient: () => ({ resetChat: mockResetChat, + getChat: () => ({ + getChatRecordingService: mockGetChatRecordingService, + }), }) as unknown as GeminiClient, + setSessionId: vi.fn(), }, }, }); diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index c3f0585951..eca35a58fd 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -7,6 +7,7 @@ import { uiTelemetryService } from '@google/gemini-cli-core'; import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; +import { randomUUID } from 'node:crypto'; export const clearCommand: SlashCommand = { name: 'clear', @@ -14,6 +15,11 @@ export const clearCommand: SlashCommand = { kind: CommandKind.BUILT_IN, action: async (context, _args) => { const geminiClient = context.services.config?.getGeminiClient(); + const config = context.services.config; + const chatRecordingService = context.services.config + ?.getGeminiClient() + ?.getChat() + .getChatRecordingService(); if (geminiClient) { context.ui.setDebugMessage('Clearing terminal and resetting chat.'); @@ -24,6 +30,13 @@ export const clearCommand: SlashCommand = { context.ui.setDebugMessage('Clearing terminal.'); } + // Start a new conversation recording with a new session ID + if (config && chatRecordingService) { + const newSessionId = randomUUID(); + config.setSessionId(newSessionId); + chatRecordingService.initialize(); + } + uiTelemetryService.setLastPromptTokenCount(0); context.ui.clear(); }, diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index fbceb3f664..59a5e8dade 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -311,6 +311,7 @@ export const useSlashCommandProcessor = ( rawQuery: PartListUnion, oneTimeShellAllowlist?: Set, overwriteConfirmed?: boolean, + addToHistory: boolean = true, ): Promise => { if (!commands) { return false; @@ -326,8 +327,13 @@ export const useSlashCommandProcessor = ( setIsProcessing(true); - const userMessageTimestamp = Date.now(); - addItem({ type: MessageType.USER, text: trimmed }, userMessageTimestamp); + if (addToHistory) { + const userMessageTimestamp = Date.now(); + addItem( + { type: MessageType.USER, text: trimmed }, + userMessageTimestamp, + ); + } let hasError = false; const { diff --git a/packages/cli/src/ui/hooks/useEditorSettings.test.tsx b/packages/cli/src/ui/hooks/useEditorSettings.test.tsx index 012daa4c35..2b39fae02c 100644 --- a/packages/cli/src/ui/hooks/useEditorSettings.test.tsx +++ b/packages/cli/src/ui/hooks/useEditorSettings.test.tsx @@ -21,12 +21,13 @@ import type { LoadedSettings, } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; -import { MessageType, type HistoryItem } from '../types.js'; +import { MessageType } from '../types.js'; import { type EditorType, checkHasEditorType, allowEditorTypeInSandbox, } from '@google/gemini-cli-core'; +import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import { SettingPaths } from '../../config/settingPaths.js'; @@ -45,9 +46,7 @@ const mockAllowEditorTypeInSandbox = vi.mocked(allowEditorTypeInSandbox); describe('useEditorSettings', () => { let mockLoadedSettings: LoadedSettings; let mockSetEditorError: MockedFunction<(error: string | null) => void>; - let mockAddItem: MockedFunction< - (item: Omit, timestamp: number) => void - >; + let mockAddItem: MockedFunction; let result: ReturnType; function TestComponent() { diff --git a/packages/cli/src/ui/hooks/useEditorSettings.ts b/packages/cli/src/ui/hooks/useEditorSettings.ts index 962d085f12..fa15202661 100644 --- a/packages/cli/src/ui/hooks/useEditorSettings.ts +++ b/packages/cli/src/ui/hooks/useEditorSettings.ts @@ -9,13 +9,14 @@ import type { LoadableSettingScope, LoadedSettings, } from '../../config/settings.js'; -import { type HistoryItem, MessageType } from '../types.js'; +import { MessageType } from '../types.js'; import type { EditorType } from '@google/gemini-cli-core'; import { allowEditorTypeInSandbox, checkHasEditorType, getEditorDisplayName, } from '@google/gemini-cli-core'; +import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import { SettingPaths } from '../../config/settingPaths.js'; @@ -32,7 +33,7 @@ interface UseEditorSettingsReturn { export const useEditorSettings = ( loadedSettings: LoadedSettings, setEditorError: (error: string | null) => void, - addItem: (item: Omit, timestamp: number) => void, + addItem: UseHistoryManagerReturn['addItem'], ): UseEditorSettingsReturn => { const [isEditorDialogOpen, setIsEditorDialogOpen] = useState(false); diff --git a/packages/cli/src/ui/hooks/useHistoryManager.ts b/packages/cli/src/ui/hooks/useHistoryManager.ts index c25fc84a29..66eff02824 100644 --- a/packages/cli/src/ui/hooks/useHistoryManager.ts +++ b/packages/cli/src/ui/hooks/useHistoryManager.ts @@ -6,6 +6,7 @@ import { useState, useRef, useCallback, useMemo } from 'react'; import type { HistoryItem } from '../types.js'; +import type { ChatRecordingService } from '@google/gemini-cli-core/src/services/chatRecordingService.js'; // Type for the updater function passed to updateHistoryItem type HistoryItemUpdater = ( @@ -14,7 +15,11 @@ type HistoryItemUpdater = ( export interface UseHistoryManagerReturn { history: HistoryItem[]; - addItem: (itemData: Omit, baseTimestamp: number) => number; // Returns the generated ID + addItem: ( + itemData: Omit, + baseTimestamp: number, + isResuming?: boolean, + ) => number; // Returns the generated ID updateItem: ( id: number, updates: Partial> | HistoryItemUpdater, @@ -29,7 +34,11 @@ export interface UseHistoryManagerReturn { * Encapsulates the history array, message ID generation, adding items, * updating items, and clearing the history. */ -export function useHistory(): UseHistoryManagerReturn { +export function useHistory({ + chatRecordingService, +}: { + chatRecordingService?: ChatRecordingService | null; +} = {}): UseHistoryManagerReturn { const [history, setHistory] = useState([]); const messageIdCounterRef = useRef(0); @@ -45,7 +54,11 @@ export function useHistory(): UseHistoryManagerReturn { // Adds a new item to the history state with a unique ID. const addItem = useCallback( - (itemData: Omit, baseTimestamp: number): number => { + ( + itemData: Omit, + baseTimestamp: number, + isResuming: boolean = false, + ): number => { const id = getNextMessageId(baseTimestamp); const newItem: HistoryItem = { ...itemData, id } as HistoryItem; @@ -63,9 +76,47 @@ export function useHistory(): UseHistoryManagerReturn { } return [...prevHistory, newItem]; }); + + // Record UI-specific messages, but don't do it if we're actually loading + // an existing session. + if (!isResuming && chatRecordingService) { + switch (itemData.type) { + case 'compression': + case 'info': + chatRecordingService?.recordMessage({ + model: undefined, + type: 'info', + content: itemData.text ?? '', + }); + break; + case 'warning': + chatRecordingService?.recordMessage({ + model: undefined, + type: 'warning', + content: itemData.text ?? '', + }); + break; + case 'error': + chatRecordingService?.recordMessage({ + model: undefined, + type: 'error', + content: itemData.text ?? '', + }); + break; + case 'user': + case 'gemini': + case 'gemini_content': + // Core conversation recording handled by GeminiChat. + break; + default: + // Ignore the rest. + break; + } + } + return id; // Return the generated ID (even if not added, to keep signature) }, - [getNextMessageId], + [getNextMessageId, chatRecordingService], ); /** diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts index 6cf878dcd1..3face81079 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts @@ -55,6 +55,47 @@ describe('convertSessionToHistoryFormats', () => { }); }); + it('should convert system, warning, and error messages to appropriate types', () => { + const messages: MessageRecord[] = [ + { + id: 'msg-1', + timestamp: '2025-01-01T00:01:00Z', + content: 'System message', + type: 'info', + }, + { + id: 'msg-2', + timestamp: '2025-01-01T00:02:00Z', + content: 'Warning message', + type: 'warning', + }, + { + id: 'msg-3', + timestamp: '2025-01-01T00:03:00Z', + content: 'Error occurred', + type: 'error', + }, + ]; + + const result = convertSessionToHistoryFormats(messages); + + expect(result.uiHistory[0]).toEqual({ + type: MessageType.INFO, + text: 'System message', + }); + expect(result.uiHistory[1]).toEqual({ + type: MessageType.WARNING, + text: 'Warning message', + }); + expect(result.uiHistory[2]).toEqual({ + type: MessageType.ERROR, + text: 'Error occurred', + }); + + // System, warning, and error messages should not be included in client history + expect(result.clientHistory).toEqual([]); + }); + it('should filter out slash commands from client history', () => { const messages: MessageRecord[] = [ { diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.ts b/packages/cli/src/ui/hooks/useSessionBrowser.ts index 9f159aac4d..69a787b030 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.ts @@ -29,6 +29,15 @@ export function convertSessionToHistoryFormats( case 'user': messageType = MessageType.USER; break; + case 'info': + messageType = MessageType.INFO; + break; + case 'error': + messageType = MessageType.ERROR; + break; + case 'warning': + messageType = MessageType.WARNING; + break; default: messageType = MessageType.GEMINI; break; @@ -70,9 +79,9 @@ export function convertSessionToHistoryFormats( for (const msg of messages) { // Skip system/error messages and user slash commands - // if (msg.type === 'system' || msg.type === 'error') { - // continue; - // } + if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') { + continue; + } if (msg.type === 'user') { // Skip user slash commands @@ -91,8 +100,7 @@ export function convertSessionToHistoryFormats( }); } else if (msg.type === 'gemini') { // Handle Gemini messages with potential tool calls - const hasToolCalls = - 'toolCalls' in msg && msg.toolCalls && msg.toolCalls.length > 0; + const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0; if (hasToolCalls) { // Create model message with function calls diff --git a/packages/cli/src/ui/hooks/useSessionResume.test.ts b/packages/cli/src/ui/hooks/useSessionResume.test.ts index 93787b8d50..e135006471 100644 --- a/packages/cli/src/ui/hooks/useSessionResume.test.ts +++ b/packages/cli/src/ui/hooks/useSessionResume.test.ts @@ -101,11 +101,13 @@ describe('useSessionResume', () => { 1, { type: 'user', text: 'Hello' }, 0, + true, ); expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith( 2, { type: 'gemini', text: 'Hi there!' }, 1, + true, ); expect(mockRefreshStatic).toHaveBeenCalled(); expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith( @@ -328,11 +330,13 @@ describe('useSessionResume', () => { 1, { type: 'user', text: 'Hello from resumed session' }, 0, + true, ); expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith( 2, { type: 'gemini', text: 'Welcome back!' }, 1, + true, ); expect(mockGeminiClient.resumeChat).toHaveBeenCalled(); }); diff --git a/packages/cli/src/ui/hooks/useSessionResume.ts b/packages/cli/src/ui/hooks/useSessionResume.ts index e738b5ce56..9eea8726d3 100644 --- a/packages/cli/src/ui/hooks/useSessionResume.ts +++ b/packages/cli/src/ui/hooks/useSessionResume.ts @@ -59,7 +59,7 @@ export function useSessionResume({ setQuittingMessages(null); historyManagerRef.current.clearItems(); uiHistory.forEach((item, index) => { - historyManagerRef.current.addItem(item, index); + historyManagerRef.current.addItem(item, index, true); }); refreshStaticRef.current(); // Force Static component to re-render with the updated history. diff --git a/packages/cli/src/ui/hooks/useThemeCommand.ts b/packages/cli/src/ui/hooks/useThemeCommand.ts index 72133e9b11..47252b9aff 100644 --- a/packages/cli/src/ui/hooks/useThemeCommand.ts +++ b/packages/cli/src/ui/hooks/useThemeCommand.ts @@ -10,8 +10,9 @@ import type { LoadableSettingScope, LoadedSettings, } from '../../config/settings.js'; // Import LoadedSettings, AppSettings, MergedSetting -import { type HistoryItem, MessageType } from '../types.js'; +import { MessageType } from '../types.js'; import process from 'node:process'; +import type { UseHistoryManagerReturn } from './useHistoryManager.js'; interface UseThemeCommandReturn { isThemeDialogOpen: boolean; @@ -24,7 +25,7 @@ interface UseThemeCommandReturn { export const useThemeCommand = ( loadedSettings: LoadedSettings, setThemeError: (error: string | null) => void, - addItem: (item: Omit, timestamp: number) => void, + addItem: UseHistoryManagerReturn['addItem'], initialThemeError: string | null, ): UseThemeCommandReturn => { const [isThemeDialogOpen, setIsThemeDialogOpen] = diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 2ac9f4d99a..5bd9533bf1 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -62,7 +62,7 @@ export interface ToolCallRecord { */ export type ConversationRecordExtra = | { - type: 'user'; + type: 'user' | 'info' | 'error' | 'warning'; } | { type: 'gemini';