diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 55e16e0992..7fffa15743 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -233,6 +233,14 @@ vi.mock('./hooks/useLogger', () => ({ })), })); +vi.mock('./hooks/useInputHistoryStore.js', () => ({ + useInputHistoryStore: vi.fn(() => ({ + inputHistory: [], + addInput: vi.fn(), + initializeFromLogger: vi.fn(), + })), +})); + vi.mock('./hooks/useConsoleMessages.js', () => ({ useConsoleMessages: vi.fn(() => ({ consoleMessages: [], diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 8cb4eed67c..2783942c2f 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -56,6 +56,7 @@ import { DetailedMessagesDisplay } from './components/DetailedMessagesDisplay.js import { HistoryItemDisplay } from './components/HistoryItemDisplay.js'; import { ContextSummaryDisplay } from './components/ContextSummaryDisplay.js'; import { useHistory } from './hooks/useHistoryManager.js'; +import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; import process from 'node:process'; import type { EditorType, Config, IdeContext } from '@google/gemini-cli-core'; import { @@ -624,7 +625,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { shellModeActive, }); - const [userMessages, setUserMessages] = useState([]); + // Independent input history management (unaffected by /clear) + const inputHistoryStore = useInputHistoryStore(); // Stable reference for cancel handler to avoid circular dependency const cancelHandlerRef = useRef<() => void>(() => {}); @@ -673,7 +675,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { return; } - const lastUserMessage = userMessages.at(-1); + const lastUserMessage = inputHistoryStore.inputHistory.at(-1); let textToSet = lastUserMessage || ''; // Append queued messages if any exist @@ -688,7 +690,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { } }, [ buffer, - userMessages, + inputHistoryStore.inputHistory, getQueuedMessagesText, clearQueue, pendingHistoryItems, @@ -697,9 +699,15 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { // Input handling - queue messages for processing const handleFinalSubmit = useCallback( (submittedValue: string) => { + const trimmedValue = submittedValue.trim(); + if (trimmedValue.length > 0) { + // Add to independent input history + inputHistoryStore.addInput(trimmedValue); + } + // Always add to message queue addMessage(submittedValue); }, - [addMessage], + [addMessage, inputHistoryStore], ); const handleIdePromptComplete = useCallback( @@ -842,41 +850,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { const logger = useLogger(config.storage); + // Initialize independent input history from logger useEffect(() => { - const fetchUserMessages = async () => { - const pastMessagesRaw = (await logger?.getPreviousUserMessages()) || []; // Newest first - - const currentSessionUserMessages = history - .filter( - (item): item is HistoryItem & { type: 'user'; text: string } => - item.type === 'user' && - typeof item.text === 'string' && - item.text.trim() !== '', - ) - .map((item) => item.text) - .reverse(); // Newest first, to match pastMessagesRaw sorting - - // Combine, with current session messages being more recent - const combinedMessages = [ - ...currentSessionUserMessages, - ...pastMessagesRaw, - ]; - - // Deduplicate consecutive identical messages from the combined list (still newest first) - const deduplicatedMessages: string[] = []; - if (combinedMessages.length > 0) { - deduplicatedMessages.push(combinedMessages[0]); // Add the newest one unconditionally - for (let i = 1; i < combinedMessages.length; i++) { - if (combinedMessages[i] !== combinedMessages[i - 1]) { - deduplicatedMessages.push(combinedMessages[i]); - } - } - } - // Reverse to oldest first for useInputHistory - setUserMessages(deduplicatedMessages.reverse()); - }; - fetchUserMessages(); - }, [history, logger]); + inputHistoryStore.initializeFromLogger(logger); + }, [logger, inputHistoryStore]); const isInputActive = (streamingState === StreamingState.Idle || @@ -1336,7 +1313,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { inputWidth={inputWidth} suggestionsWidth={suggestionsWidth} onSubmit={handleFinalSubmit} - userMessages={userMessages} + userMessages={inputHistoryStore.inputHistory} onClearScreen={handleClearScreen} config={config} slashCommands={slashCommands} diff --git a/packages/cli/src/ui/hooks/useInputHistoryStore.test.ts b/packages/cli/src/ui/hooks/useInputHistoryStore.test.ts new file mode 100644 index 0000000000..5404cefc02 --- /dev/null +++ b/packages/cli/src/ui/hooks/useInputHistoryStore.test.ts @@ -0,0 +1,301 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act, renderHook } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { useInputHistoryStore } from './useInputHistoryStore.js'; + +describe('useInputHistoryStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should initialize with empty input history', () => { + const { result } = renderHook(() => useInputHistoryStore()); + + expect(result.current.inputHistory).toEqual([]); + }); + + it('should add input to history', () => { + const { result } = renderHook(() => useInputHistoryStore()); + + act(() => { + result.current.addInput('test message 1'); + }); + + expect(result.current.inputHistory).toEqual(['test message 1']); + + act(() => { + result.current.addInput('test message 2'); + }); + + expect(result.current.inputHistory).toEqual([ + 'test message 1', + 'test message 2', + ]); + }); + + it('should not add empty or whitespace-only inputs', () => { + const { result } = renderHook(() => useInputHistoryStore()); + + act(() => { + result.current.addInput(''); + }); + + expect(result.current.inputHistory).toEqual([]); + + act(() => { + result.current.addInput(' '); + }); + + expect(result.current.inputHistory).toEqual([]); + }); + + it('should deduplicate consecutive identical messages', () => { + const { result } = renderHook(() => useInputHistoryStore()); + + act(() => { + result.current.addInput('test message'); + }); + + act(() => { + result.current.addInput('test message'); // Same as previous + }); + + expect(result.current.inputHistory).toEqual(['test message']); + + act(() => { + result.current.addInput('different message'); + }); + + act(() => { + result.current.addInput('test message'); // Same as first, but not consecutive + }); + + expect(result.current.inputHistory).toEqual([ + 'test message', + 'different message', + 'test message', + ]); + }); + + it('should initialize from logger successfully', async () => { + const mockLogger = { + getPreviousUserMessages: vi + .fn() + .mockResolvedValue(['newest', 'middle', 'oldest']), + }; + + const { result } = renderHook(() => useInputHistoryStore()); + + await act(async () => { + await result.current.initializeFromLogger(mockLogger); + }); + + // Should reverse the order to oldest first + expect(result.current.inputHistory).toEqual(['oldest', 'middle', 'newest']); + expect(mockLogger.getPreviousUserMessages).toHaveBeenCalledTimes(1); + }); + + it('should handle logger initialization failure gracefully', async () => { + const mockLogger = { + getPreviousUserMessages: vi + .fn() + .mockRejectedValue(new Error('Logger error')), + }; + + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const { result } = renderHook(() => useInputHistoryStore()); + + await act(async () => { + await result.current.initializeFromLogger(mockLogger); + }); + + expect(result.current.inputHistory).toEqual([]); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to initialize input history from logger:', + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + + it('should initialize only once', async () => { + const mockLogger = { + getPreviousUserMessages: vi + .fn() + .mockResolvedValue(['message1', 'message2']), + }; + + const { result } = renderHook(() => useInputHistoryStore()); + + // Call initializeFromLogger twice + await act(async () => { + await result.current.initializeFromLogger(mockLogger); + }); + + await act(async () => { + await result.current.initializeFromLogger(mockLogger); + }); + + // Should be called only once + expect(mockLogger.getPreviousUserMessages).toHaveBeenCalledTimes(1); + expect(result.current.inputHistory).toEqual(['message2', 'message1']); + }); + + it('should handle null logger gracefully', async () => { + const { result } = renderHook(() => useInputHistoryStore()); + + await act(async () => { + await result.current.initializeFromLogger(null); + }); + + expect(result.current.inputHistory).toEqual([]); + }); + + it('should trim input before adding to history', () => { + const { result } = renderHook(() => useInputHistoryStore()); + + act(() => { + result.current.addInput(' test message '); + }); + + expect(result.current.inputHistory).toEqual(['test message']); + }); + + describe('deduplication logic from previous implementation', () => { + it('should deduplicate consecutive messages from past sessions during initialization', async () => { + const mockLogger = { + getPreviousUserMessages: vi + .fn() + .mockResolvedValue([ + 'message1', + 'message1', + 'message2', + 'message2', + 'message3', + ]), // newest first with duplicates + }; + + const { result } = renderHook(() => useInputHistoryStore()); + + await act(async () => { + await result.current.initializeFromLogger(mockLogger); + }); + + // Should deduplicate consecutive messages and reverse to oldest first + expect(result.current.inputHistory).toEqual([ + 'message3', + 'message2', + 'message1', + ]); + }); + + it('should deduplicate across session boundaries', async () => { + const mockLogger = { + getPreviousUserMessages: vi.fn().mockResolvedValue(['old2', 'old1']), // newest first + }; + + const { result } = renderHook(() => useInputHistoryStore()); + + // Initialize with past session + await act(async () => { + await result.current.initializeFromLogger(mockLogger); + }); + + // Add current session inputs + act(() => { + result.current.addInput('old2'); // Same as last past session message + }); + + // Should deduplicate across session boundary + expect(result.current.inputHistory).toEqual(['old1', 'old2']); + + act(() => { + result.current.addInput('new1'); + }); + + expect(result.current.inputHistory).toEqual(['old1', 'old2', 'new1']); + }); + + it('should preserve non-consecutive duplicates', async () => { + const mockLogger = { + getPreviousUserMessages: vi + .fn() + .mockResolvedValue(['message2', 'message1', 'message2']), // newest first with non-consecutive duplicate + }; + + const { result } = renderHook(() => useInputHistoryStore()); + + await act(async () => { + await result.current.initializeFromLogger(mockLogger); + }); + + // Non-consecutive duplicates should be preserved + expect(result.current.inputHistory).toEqual([ + 'message2', + 'message1', + 'message2', + ]); + }); + + it('should handle complex deduplication with current session', () => { + const { result } = renderHook(() => useInputHistoryStore()); + + // Add multiple messages with duplicates + act(() => { + result.current.addInput('hello'); + }); + act(() => { + result.current.addInput('hello'); // consecutive duplicate + }); + act(() => { + result.current.addInput('world'); + }); + act(() => { + result.current.addInput('world'); // consecutive duplicate + }); + act(() => { + result.current.addInput('hello'); // non-consecutive duplicate + }); + + // Should have deduplicated consecutive ones + expect(result.current.inputHistory).toEqual(['hello', 'world', 'hello']); + }); + + it('should maintain oldest-first order in final output', async () => { + const mockLogger = { + getPreviousUserMessages: vi + .fn() + .mockResolvedValue(['newest', 'middle', 'oldest']), // newest first + }; + + const { result } = renderHook(() => useInputHistoryStore()); + + await act(async () => { + await result.current.initializeFromLogger(mockLogger); + }); + + // Add current session messages + act(() => { + result.current.addInput('current1'); + }); + act(() => { + result.current.addInput('current2'); + }); + + // Should maintain oldest-first order + expect(result.current.inputHistory).toEqual([ + 'oldest', + 'middle', + 'newest', + 'current1', + 'current2', + ]); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useInputHistoryStore.ts b/packages/cli/src/ui/hooks/useInputHistoryStore.ts new file mode 100644 index 0000000000..86e7cd3960 --- /dev/null +++ b/packages/cli/src/ui/hooks/useInputHistoryStore.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; + +interface Logger { + getPreviousUserMessages(): Promise; +} + +export interface UseInputHistoryStoreReturn { + inputHistory: string[]; + addInput: (input: string) => void; + initializeFromLogger: (logger: Logger | null) => Promise; +} + +/** + * Hook for independently managing input history. + * Completely separated from chat history and unaffected by /clear commands. + */ +export function useInputHistoryStore(): UseInputHistoryStoreReturn { + const [inputHistory, setInputHistory] = useState([]); + const [_pastSessionMessages, setPastSessionMessages] = useState([]); + const [_currentSessionMessages, setCurrentSessionMessages] = useState< + string[] + >([]); + const [isInitialized, setIsInitialized] = useState(false); + + /** + * Recalculate the complete input history from past and current sessions. + * Applies the same deduplication logic as the previous implementation. + */ + const recalculateHistory = useCallback( + (currentSession: string[], pastSession: string[]) => { + // Combine current session (newest first) + past session (newest first) + const combinedMessages = [...currentSession, ...pastSession]; + + // Deduplicate consecutive identical messages (same algorithm as before) + const deduplicatedMessages: string[] = []; + if (combinedMessages.length > 0) { + deduplicatedMessages.push(combinedMessages[0]); // Add the newest one unconditionally + for (let i = 1; i < combinedMessages.length; i++) { + if (combinedMessages[i] !== combinedMessages[i - 1]) { + deduplicatedMessages.push(combinedMessages[i]); + } + } + } + + // Reverse to oldest first for useInputHistory + setInputHistory(deduplicatedMessages.reverse()); + }, + [], + ); + + /** + * Initialize input history from logger with past session data. + * Executed only once at app startup. + */ + const initializeFromLogger = useCallback( + async (logger: Logger | null) => { + if (isInitialized || !logger) return; + + try { + const pastMessages = (await logger.getPreviousUserMessages()) || []; + setPastSessionMessages(pastMessages); // Store as newest first + recalculateHistory([], pastMessages); + setIsInitialized(true); + } catch (error) { + // Start with empty history even if logger initialization fails + console.warn('Failed to initialize input history from logger:', error); + setPastSessionMessages([]); + recalculateHistory([], []); + setIsInitialized(true); + } + }, + [isInitialized, recalculateHistory], + ); + + /** + * Add new input to history. + * Recalculates the entire history with deduplication. + */ + const addInput = useCallback( + (input: string) => { + const trimmedInput = input.trim(); + if (!trimmedInput) return; // Filter empty/whitespace-only inputs + + setCurrentSessionMessages((prevCurrent) => { + const newCurrentSession = [...prevCurrent, trimmedInput]; + + setPastSessionMessages((prevPast) => { + recalculateHistory( + newCurrentSession.slice().reverse(), // Convert to newest first + prevPast, + ); + return prevPast; // No change to past messages + }); + + return newCurrentSession; + }); + }, + [recalculateHistory], + ); + + return { + inputHistory, + addInput, + initializeFromLogger, + }; +}