Download packages/cli/src/ui/AppContainer.test.tsx from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 120 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/ui/AppContainer.test.tsx
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/cli/src/ui/AppContainer.test.tsx
-
curl -L -o AppContainer.test.tsx https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/ui/AppContainer.test.tsx
120 kB
| /** | |
| * @license | |
| * Copyright 2026 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import { | |
| describe, | |
| it, | |
| expect, | |
| vi, | |
| beforeEach, | |
| afterEach, | |
| type Mock, | |
| type MockedObject, | |
| } from 'vitest'; | |
| import { render, cleanup, persistentStateMock } from '../test-utils/render.js'; | |
| import { waitFor } from '../test-utils/async.js'; | |
| import { act, useContext } from 'react'; | |
| import { AppContainer } from './AppContainer.js'; | |
| import { SettingsContext } from './contexts/SettingsContext.js'; | |
| import { type TrackedToolCall } from './hooks/useToolScheduler.js'; | |
| import { | |
| type Config, | |
| makeFakeConfig, | |
| CoreEvent, | |
| type UserFeedbackPayload, | |
| type ResumedSessionData, | |
| type StartupWarning, | |
| WarningPriority, | |
| AuthType, | |
| type AgentDefinition, | |
| CoreToolCallStatus, | |
| } from '@google/gemini-cli-core'; | |
| // Mock coreEvents | |
| const mockCoreEvents = vi.hoisted(() => ({ | |
| on: vi.fn(), | |
| off: vi.fn(), | |
| drainBacklogs: vi.fn(), | |
| emit: vi.fn(), | |
| })); | |
| // Mock IdeClient | |
| const mockIdeClient = vi.hoisted(() => ({ | |
| getInstance: vi.fn().mockReturnValue(new Promise(() => {})), | |
| })); | |
| // Mock stdout and ink app | |
| const mocks = vi.hoisted(() => ({ | |
| mockStdout: { write: vi.fn() }, | |
| mockRerender: vi.fn(), | |
| })); | |
| const terminalNotificationsMocks = vi.hoisted(() => ({ | |
| notifyViaTerminal: vi.fn().mockResolvedValue(true), | |
| isNotificationsEnabled: vi.fn(() => true), | |
| getNotificationMethod: vi.fn(() => 'auto'), | |
| buildRunEventNotificationContent: vi.fn((event) => ({ | |
| title: 'Mock Notification', | |
| subtitle: 'Mock Subtitle', | |
| body: JSON.stringify(event), | |
| })), | |
| })); | |
| vi.mock('@google/gemini-cli-core', async (importOriginal) => { | |
| const actual = | |
| await importOriginal<typeof import('@google/gemini-cli-core')>(); | |
| return { | |
| ...actual, | |
| coreEvents: mockCoreEvents, | |
| IdeClient: mockIdeClient, | |
| writeToStdout: vi.fn((...args) => | |
| process.stdout.write( | |
| ...(args as Parameters<typeof process.stdout.write>), | |
| ), | |
| ), | |
| writeToStderr: vi.fn((...args) => | |
| process.stderr.write( | |
| ...(args as Parameters<typeof process.stderr.write>), | |
| ), | |
| ), | |
| patchStdio: vi.fn(() => () => {}), | |
| createWorkingStdio: vi.fn(() => ({ | |
| stdout: process.stdout, | |
| stderr: process.stderr, | |
| })), | |
| enableMouseEvents: vi.fn(), | |
| disableMouseEvents: vi.fn(), | |
| FileDiscoveryService: vi.fn().mockImplementation(() => ({ | |
| initialize: vi.fn(), | |
| })), | |
| startupProfiler: { | |
| flush: vi.fn(), | |
| start: vi.fn(), | |
| end: vi.fn(), | |
| }, | |
| }; | |
| }); | |
| import ansiEscapes from 'ansi-escapes'; | |
| import { type LoadedSettings } from '../config/settings.js'; | |
| import { createMockSettings } from '../test-utils/settings.js'; | |
| import type { InitializationResult } from '../core/initializer.js'; | |
| import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; | |
| import { StreamingState, MessageType } from './types.js'; | |
| import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; | |
| import { | |
| UIActionsContext, | |
| type UIActions, | |
| } from './contexts/UIActionsContext.js'; | |
| import { KeypressProvider } from './contexts/KeypressContext.js'; | |
| import { OverflowProvider } from './contexts/OverflowContext.js'; | |
| import { | |
| useOverflowActions, | |
| type OverflowActions, | |
| } from './contexts/OverflowContext.js'; | |
| // Mock useStdout and useApp to capture terminal title writes and spy on re-renders | |
| vi.mock('ink', async (importOriginal) => { | |
| const actual = await importOriginal<typeof import('ink')>(); | |
| return { | |
| ...actual, | |
| useStdout: () => ({ stdout: mocks.mockStdout }), | |
| useApp: () => ({ | |
| rerender: mocks.mockRerender, | |
| }), | |
| measureElement: vi.fn(), | |
| }; | |
| }); | |
| import { InputContext, type InputState } from './contexts/InputContext.js'; | |
| import { QuotaContext, type QuotaState } from './contexts/QuotaContext.js'; | |
| // Helper component will read the context values provided by AppContainer | |
| // so we can assert against them in our tests. | |
| let capturedUIState: UIState; | |
| let capturedInputState: InputState; | |
| let capturedQuotaState: QuotaState; | |
| let capturedUIActions: UIActions; | |
| let capturedOverflowActions: OverflowActions; | |
| function TestContextConsumer() { | |
| capturedUIState = useContext(UIStateContext)!; | |
| capturedInputState = useContext(InputContext)!; | |
| capturedQuotaState = useContext(QuotaContext)!; | |
| capturedUIActions = useContext(UIActionsContext)!; | |
| capturedOverflowActions = useOverflowActions()!; | |
| return null; | |
| } | |
| vi.mock('./App.js', () => ({ | |
| App: TestContextConsumer, | |
| })); | |
| vi.mock('./hooks/useQuotaAndFallback.js'); | |
| vi.mock('./hooks/useHistoryManager.js'); | |
| vi.mock('./hooks/useThemeCommand.js'); | |
| vi.mock('./auth/useAuth.js'); | |
| vi.mock('../config/auth.js', () => ({ | |
| validateAuthMethod: vi.fn().mockResolvedValue(null), | |
| })); | |
| vi.mock('./hooks/useEditorSettings.js'); | |
| vi.mock('./hooks/useSettingsCommand.js'); | |
| vi.mock('./hooks/useModelCommand.js'); | |
| vi.mock('./hooks/slashCommandProcessor.js'); | |
| vi.mock('./hooks/useConsoleMessages.js'); | |
| vi.mock('./hooks/useTerminalSize.js', () => ({ | |
| useTerminalSize: vi.fn(() => ({ columns: 80, rows: 24 })), | |
| })); | |
| vi.mock('./hooks/useGeminiStream.js'); | |
| vi.mock('./hooks/vim.js'); | |
| vi.mock('./hooks/useFocus.js'); | |
| vi.mock('./hooks/useBracketedPaste.js'); | |
| vi.mock('./hooks/useLoadingIndicator.js'); | |
| vi.mock('./hooks/useSuspend.js'); | |
| vi.mock('./hooks/useFolderTrust.js'); | |
| vi.mock('./hooks/useIdeTrustListener.js'); | |
| vi.mock('./hooks/useMessageQueue.js'); | |
| vi.mock('./hooks/useApprovalModeIndicator.js'); | |
| vi.mock('./hooks/useGitBranchName.js'); | |
| vi.mock('./hooks/useExtensionUpdates.js'); | |
| vi.mock('./contexts/VimModeContext.js'); | |
| vi.mock('./contexts/SessionContext.js'); | |
| vi.mock('./components/shared/text-buffer.js'); | |
| vi.mock('./hooks/useLogger.js'); | |
| vi.mock('./hooks/useInputHistoryStore.js'); | |
| vi.mock('./hooks/atCommandProcessor.js'); | |
| vi.mock('./hooks/useHookDisplayState.js'); | |
| vi.mock('./hooks/useBanner.js', () => ({ | |
| useBanner: vi.fn((bannerData) => ({ | |
| bannerText: ( | |
| bannerData.warningText || | |
| bannerData.defaultText || | |
| '' | |
| ).replace(/\\n/g, '\n'), | |
| })), | |
| })); | |
| vi.mock('./hooks/useShellInactivityStatus.js', () => ({ | |
| useShellInactivityStatus: vi.fn(() => ({ | |
| shouldShowFocusHint: false, | |
| inactivityStatus: 'none', | |
| })), | |
| })); | |
| vi.mock('../utils/terminalNotifications.js', () => ({ | |
| notifyViaTerminal: terminalNotificationsMocks.notifyViaTerminal, | |
| isNotificationsEnabled: terminalNotificationsMocks.isNotificationsEnabled, | |
| getNotificationMethod: terminalNotificationsMocks.getNotificationMethod, | |
| buildRunEventNotificationContent: | |
| terminalNotificationsMocks.buildRunEventNotificationContent, | |
| })); | |
| vi.mock('./hooks/useTerminalTheme.js', () => ({ | |
| useTerminalTheme: vi.fn(), | |
| })); | |
| import { useHookDisplayState } from './hooks/useHookDisplayState.js'; | |
| import { useTerminalTheme } from './hooks/useTerminalTheme.js'; | |
| import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js'; | |
| import { useFocus } from './hooks/useFocus.js'; | |
| // Mock external utilities | |
| vi.mock('../utils/events.js'); | |
| vi.mock('../utils/handleAutoUpdate.js'); | |
| vi.mock('./utils/ConsolePatcher.js'); | |
| vi.mock('../utils/cleanup.js'); | |
| import { useHistory } from './hooks/useHistoryManager.js'; | |
| import { useThemeCommand } from './hooks/useThemeCommand.js'; | |
| import { useAuthCommand } from './auth/useAuth.js'; | |
| import { validateAuthMethod } from '../config/auth.js'; | |
| import { useEditorSettings } from './hooks/useEditorSettings.js'; | |
| import { useSettingsCommand } from './hooks/useSettingsCommand.js'; | |
| import { useModelCommand } from './hooks/useModelCommand.js'; | |
| import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; | |
| import { useErrorCount } from './hooks/useConsoleMessages.js'; | |
| import { useGeminiStream } from './hooks/useGeminiStream.js'; | |
| import { useVim } from './hooks/vim.js'; | |
| import { useFolderTrust } from './hooks/useFolderTrust.js'; | |
| import { useIdeTrustListener } from './hooks/useIdeTrustListener.js'; | |
| import { useMessageQueue } from './hooks/useMessageQueue.js'; | |
| import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js'; | |
| import { useGitBranchName } from './hooks/useGitBranchName.js'; | |
| import { | |
| useConfirmUpdateRequests, | |
| useExtensionUpdates, | |
| } from './hooks/useExtensionUpdates.js'; | |
| import { useVimMode } from './contexts/VimModeContext.js'; | |
| import { useSessionStats } from './contexts/SessionContext.js'; | |
| import { useTextBuffer } from './components/shared/text-buffer.js'; | |
| import { useLogger } from './hooks/useLogger.js'; | |
| import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; | |
| import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; | |
| import { useKeypress, type Key } from './hooks/useKeypress.js'; | |
| import * as useKeypressModule from './hooks/useKeypress.js'; | |
| import { useSuspend } from './hooks/useSuspend.js'; | |
| import { | |
| writeToStdout, | |
| enableMouseEvents, | |
| disableMouseEvents, | |
| } from '@google/gemini-cli-core'; | |
| import { type ExtensionManager } from '../config/extension-manager.js'; | |
| import { | |
| WARNING_PROMPT_DURATION_MS, | |
| EXPAND_HINT_DURATION_MS, | |
| } from './constants.js'; | |
| describe('AppContainer State Management', () => { | |
| let mockConfig: Config; | |
| let mockSettings: LoadedSettings; | |
| let mockInitResult: InitializationResult; | |
| let mockExtensionManager: MockedObject<ExtensionManager>; | |
| type AppContainerProps = { | |
| settings?: LoadedSettings; | |
| config?: Config; | |
| version?: string; | |
| initResult?: InitializationResult; | |
| startupWarnings?: StartupWarning[]; | |
| resumedSessionData?: ResumedSessionData; | |
| }; | |
| // Helper to generate the AppContainer JSX for render and rerender | |
| const getAppContainer = ({ | |
| settings = mockSettings, | |
| config = mockConfig, | |
| version = '1.0.0', | |
| initResult = mockInitResult, | |
| startupWarnings, | |
| resumedSessionData, | |
| }: AppContainerProps = {}) => ( | |
| <SettingsContext.Provider value={settings}> | |
| <KeypressProvider config={config}> | |
| <OverflowProvider> | |
| <AppContainer | |
| config={config} | |
| version={version} | |
| initializationResult={initResult} | |
| startupWarnings={startupWarnings} | |
| resumedSessionData={resumedSessionData} | |
| /> | |
| </OverflowProvider> | |
| </KeypressProvider> | |
| </SettingsContext.Provider> | |
| ); | |
| // Helper to render the AppContainer | |
| const renderAppContainer = async (props?: AppContainerProps) => | |
| render(getAppContainer(props)); | |
| // Create typed mocks for all hooks | |
| const mockedUseQuotaAndFallback = useQuotaAndFallback as Mock; | |
| const mockedUseHistory = useHistory as Mock; | |
| const mockedUseThemeCommand = useThemeCommand as Mock; | |
| const mockedUseAuthCommand = useAuthCommand as Mock; | |
| const mockedUseEditorSettings = useEditorSettings as Mock; | |
| const mockedUseSettingsCommand = useSettingsCommand as Mock; | |
| const mockedUseModelCommand = useModelCommand as Mock; | |
| const mockedUseSlashCommandProcessor = useSlashCommandProcessor as Mock; | |
| const mockedUseConsoleMessages = useErrorCount as Mock; | |
| const mockedUseGeminiStream = useGeminiStream as Mock; | |
| const mockedUseVim = useVim as Mock; | |
| const mockedUseFolderTrust = useFolderTrust as Mock; | |
| const mockedUseIdeTrustListener = useIdeTrustListener as Mock; | |
| const mockedUseMessageQueue = useMessageQueue as Mock; | |
| const mockedUseApprovalModeIndicator = useApprovalModeIndicator as Mock; | |
| const mockedUseGitBranchName = useGitBranchName as Mock; | |
| const mockedUseConfirmUpdateRequests = useConfirmUpdateRequests as Mock; | |
| const mockedUseExtensionUpdates = useExtensionUpdates as Mock; | |
| const mockedUseVimMode = useVimMode as Mock; | |
| const mockedUseSessionStats = useSessionStats as Mock; | |
| const mockedUseTextBuffer = useTextBuffer as Mock; | |
| const mockedUseLogger = useLogger as Mock; | |
| const mockedUseLoadingIndicator = useLoadingIndicator as Mock; | |
| const mockedUseSuspend = useSuspend as Mock; | |
| const mockedUseInputHistoryStore = useInputHistoryStore as Mock; | |
| const mockedUseHookDisplayState = useHookDisplayState as Mock; | |
| const mockedUseTerminalTheme = useTerminalTheme as Mock; | |
| const mockedUseShellInactivityStatus = useShellInactivityStatus as Mock; | |
| const mockedUseFocusState = useFocus as Mock; | |
| const DEFAULT_GEMINI_STREAM_MOCK = { | |
| streamingState: 'idle', | |
| submitQuery: vi.fn(), | |
| initError: null, | |
| pendingHistoryItems: [], | |
| thought: null, | |
| cancelOngoingRequest: vi.fn(), | |
| handleApprovalModeChange: vi.fn(), | |
| activePtyId: null, | |
| loopDetectionConfirmationRequest: null, | |
| backgroundTaskCount: 0, | |
| isBackgroundTaskVisible: false, | |
| toggleBackgroundTasks: vi.fn(), | |
| backgroundCurrentExecution: vi.fn(), | |
| backgroundTasks: new Map(), | |
| registerBackgroundTask: vi.fn(), | |
| dismissBackgroundTask: vi.fn(), | |
| }; | |
| beforeEach(() => { | |
| persistentStateMock.reset(); | |
| vi.clearAllMocks(); | |
| mockIdeClient.getInstance.mockReturnValue(new Promise(() => {})); | |
| // Initialize mock stdout for terminal title tests | |
| mocks.mockStdout.write.mockClear(); | |
| (disableMouseEvents as import('vitest').Mock).mockClear(); | |
| capturedUIState = null!; | |
| // **Provide a default return value for EVERY mocked hook.** | |
| mockedUseQuotaAndFallback.mockReturnValue({ | |
| proQuotaRequest: null, | |
| handleProQuotaChoice: vi.fn(), | |
| }); | |
| mockedUseHistory.mockReturnValue({ | |
| history: [], | |
| addItem: vi.fn(), | |
| updateItem: vi.fn(), | |
| clearItems: vi.fn(), | |
| loadHistory: vi.fn(), | |
| }); | |
| mockedUseThemeCommand.mockReturnValue({ | |
| isThemeDialogOpen: false, | |
| openThemeDialog: vi.fn(), | |
| handleThemeSelect: vi.fn(), | |
| handleThemeHighlight: vi.fn(), | |
| }); | |
| mockedUseAuthCommand.mockReturnValue({ | |
| authState: 'authenticated', | |
| setAuthState: vi.fn(), | |
| authError: null, | |
| onAuthError: vi.fn(), | |
| }); | |
| mockedUseEditorSettings.mockReturnValue({ | |
| isEditorDialogOpen: false, | |
| openEditorDialog: vi.fn(), | |
| handleEditorSelect: vi.fn(), | |
| exitEditorDialog: vi.fn(), | |
| }); | |
| mockedUseSettingsCommand.mockReturnValue({ | |
| isSettingsDialogOpen: false, | |
| openSettingsDialog: vi.fn(), | |
| closeSettingsDialog: vi.fn(), | |
| }); | |
| mockedUseModelCommand.mockReturnValue({ | |
| isModelDialogOpen: false, | |
| openModelDialog: vi.fn(), | |
| closeModelDialog: vi.fn(), | |
| }); | |
| mockedUseSlashCommandProcessor.mockReturnValue({ | |
| handleSlashCommand: vi.fn(), | |
| slashCommands: [], | |
| pendingHistoryItems: [], | |
| commandContext: {}, | |
| shellConfirmationRequest: null, | |
| confirmationRequest: null, | |
| }); | |
| mockedUseConsoleMessages.mockReturnValue({ | |
| errorCount: 0, | |
| handleNewMessage: vi.fn(), | |
| clearErrorCount: vi.fn(), | |
| }); | |
| mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK); | |
| mockedUseVim.mockReturnValue({ handleInput: vi.fn() }); | |
| mockedUseFolderTrust.mockReturnValue({ | |
| isFolderTrustDialogOpen: false, | |
| handleFolderTrustSelect: vi.fn(), | |
| isRestarting: false, | |
| }); | |
| mockedUseIdeTrustListener.mockReturnValue({ | |
| needsRestart: false, | |
| restartReason: 'NONE', | |
| }); | |
| mockedUseMessageQueue.mockReturnValue({ | |
| messageQueue: [], | |
| addMessage: vi.fn(), | |
| clearQueue: vi.fn(), | |
| getQueuedMessagesText: vi.fn().mockReturnValue(''), | |
| }); | |
| mockedUseApprovalModeIndicator.mockReturnValue(false); | |
| mockedUseGitBranchName.mockReturnValue('main'); | |
| mockedUseVimMode.mockReturnValue({ | |
| isVimEnabled: false, | |
| toggleVimEnabled: vi.fn(), | |
| }); | |
| mockedUseSessionStats.mockReturnValue({ stats: {} }); | |
| mockedUseTextBuffer.mockReturnValue({ | |
| text: '', | |
| setText: vi.fn(), | |
| lines: [''], | |
| cursor: [0, 0], | |
| handleInput: vi.fn().mockReturnValue(false), | |
| }); | |
| mockedUseLogger.mockReturnValue({ | |
| getPreviousUserMessages: vi.fn().mockResolvedValue([]), | |
| }); | |
| mockedUseInputHistoryStore.mockReturnValue({ | |
| inputHistory: [], | |
| addInput: vi.fn(), | |
| initializeFromLogger: vi.fn(), | |
| }); | |
| mockedUseLoadingIndicator.mockReturnValue({ | |
| elapsedTime: '0.0s', | |
| currentLoadingPhrase: '', | |
| }); | |
| mockedUseSuspend.mockReturnValue({ | |
| handleSuspend: vi.fn(), | |
| }); | |
| mockedUseHookDisplayState.mockReturnValue([]); | |
| mockedUseTerminalTheme.mockReturnValue(undefined); | |
| mockedUseShellInactivityStatus.mockReturnValue({ | |
| shouldShowFocusHint: false, | |
| inactivityStatus: 'none', | |
| }); | |
| mockedUseFocusState.mockReturnValue({ | |
| isFocused: true, | |
| hasReceivedFocusEvent: true, | |
| }); | |
| mockedUseConfirmUpdateRequests.mockReturnValue({ | |
| addConfirmUpdateExtensionRequest: vi.fn(), | |
| confirmUpdateExtensionRequests: [], | |
| }); | |
| mockedUseExtensionUpdates.mockReturnValue({ | |
| extensionsUpdateState: new Map(), | |
| extensionsUpdateStateInternal: new Map(), | |
| dispatchExtensionStateUpdate: vi.fn(), | |
| }); | |
| // Mock Config | |
| mockConfig = makeFakeConfig(); | |
| vi.spyOn(mockConfig, 'getUseRenderProcess').mockReturnValue(false); | |
| // Mock config's getTargetDir to return consistent workspace directory | |
| vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace'); | |
| vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined); | |
| vi.spyOn(mockConfig, 'getDebugMode').mockReturnValue(false); | |
| mockExtensionManager = { | |
| getExtensions: vi.fn().mockReturnValue([]), | |
| setRequestConsent: vi.fn(), | |
| setRequestSetting: vi.fn(), | |
| start: vi.fn(), | |
| } as unknown as MockedObject<ExtensionManager>; | |
| vi.spyOn(mockConfig, 'getExtensionLoader').mockReturnValue( | |
| mockExtensionManager, | |
| ); | |
| // Mock LoadedSettings | |
| mockSettings = createMockSettings({ | |
| hideBanner: false, | |
| hideTips: false, | |
| hideFooter: false, | |
| showMemoryUsage: false, | |
| theme: 'default', | |
| ui: { | |
| showStatusInTitle: false, | |
| hideWindowTitle: false, | |
| useAlternateBuffer: false, | |
| }, | |
| }); | |
| // Mock InitializationResult | |
| mockInitResult = { | |
| themeError: null, | |
| authError: null, | |
| shouldOpenAuthDialog: false, | |
| geminiMdFileCount: 0, | |
| } as InitializationResult; | |
| }); | |
| afterEach(() => { | |
| cleanup(); | |
| vi.restoreAllMocks(); | |
| }); | |
| describe('Basic Rendering', () => { | |
| it('renders without crashing with minimal props', async () => { | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| expect(capturedUIState).toBeTruthy(); | |
| unmount(); | |
| }); | |
| it('renders with startup warnings', async () => { | |
| const startupWarnings: StartupWarning[] = [ | |
| { | |
| id: 'w1', | |
| message: 'Warning 1', | |
| priority: WarningPriority.High, | |
| }, | |
| { | |
| id: 'w2', | |
| message: 'Warning 2', | |
| priority: WarningPriority.High, | |
| }, | |
| ]; | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ startupWarnings }), | |
| ); | |
| expect(capturedUIState).toBeTruthy(); | |
| unmount(); | |
| }); | |
| it('shows full UI details by default', async () => { | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| expect(capturedUIState.cleanUiDetailsVisible).toBe(true); | |
| unmount(); | |
| }); | |
| it('starts in minimal UI mode when Focus UI preference is persisted', async () => { | |
| persistentStateMock.get.mockReturnValueOnce(true); | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| settings: mockSettings, | |
| }), | |
| ); | |
| expect(capturedUIState.cleanUiDetailsVisible).toBe(false); | |
| expect(persistentStateMock.get).toHaveBeenCalledWith('focusUiEnabled'); | |
| unmount(); | |
| }); | |
| }); | |
| describe('State Initialization', () => { | |
| it('calls validateAuthMethod and onAuthError if validation fails', async () => { | |
| const mockOnAuthError = vi.fn(); | |
| mockedUseAuthCommand.mockReturnValue({ | |
| authState: 'authenticated', | |
| setAuthState: vi.fn(), | |
| authError: null, | |
| onAuthError: mockOnAuthError, | |
| }); | |
| vi.mocked(validateAuthMethod).mockResolvedValueOnce('Validation Failed'); | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| settings: createMockSettings({ | |
| merged: { | |
| security: { | |
| auth: { selectedType: 'oauth-personal', useExternal: false }, | |
| }, | |
| }, | |
| }), | |
| }), | |
| ); | |
| await waitFor(() => { | |
| expect(validateAuthMethod).toHaveBeenCalledWith('oauth-personal'); | |
| expect(mockOnAuthError).toHaveBeenCalledWith('Validation Failed'); | |
| }); | |
| unmount(); | |
| }); | |
| it('sends a macOS notification when confirmation is pending and terminal is unfocused', async () => { | |
| mockedUseFocusState.mockReturnValue({ | |
| isFocused: false, | |
| hasReceivedFocusEvent: true, | |
| }); | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| pendingHistoryItems: [ | |
| { | |
| type: 'tool_group', | |
| tools: [ | |
| { | |
| callId: 'call-1', | |
| name: 'run_shell_command', | |
| description: 'Run command', | |
| resultDisplay: undefined, | |
| status: CoreToolCallStatus.AwaitingApproval, | |
| confirmationDetails: { | |
| type: 'exec', | |
| title: 'Run shell command', | |
| command: 'ls', | |
| rootCommand: 'ls', | |
| rootCommands: ['ls'], | |
| }, | |
| }, | |
| ], | |
| }, | |
| ], | |
| }); | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); | |
| expect( | |
| terminalNotificationsMocks.buildRunEventNotificationContent, | |
| ).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| type: 'attention', | |
| }), | |
| ); | |
| unmount(); | |
| }); | |
| it('does not send attention notification when terminal is focused', async () => { | |
| mockedUseFocusState.mockReturnValue({ | |
| isFocused: true, | |
| hasReceivedFocusEvent: true, | |
| }); | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| pendingHistoryItems: [ | |
| { | |
| type: 'tool_group', | |
| tools: [ | |
| { | |
| callId: 'call-2', | |
| name: 'run_shell_command', | |
| description: 'Run command', | |
| resultDisplay: undefined, | |
| status: CoreToolCallStatus.AwaitingApproval, | |
| confirmationDetails: { | |
| type: 'exec', | |
| title: 'Run shell command', | |
| command: 'ls', | |
| rootCommand: 'ls', | |
| rootCommands: ['ls'], | |
| }, | |
| }, | |
| ], | |
| }, | |
| ], | |
| }); | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| expect( | |
| terminalNotificationsMocks.notifyViaTerminal, | |
| ).not.toHaveBeenCalled(); | |
| unmount(); | |
| }); | |
| it('sends attention notification when focus reporting is unavailable', async () => { | |
| mockedUseFocusState.mockReturnValue({ | |
| isFocused: true, | |
| hasReceivedFocusEvent: false, | |
| }); | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| pendingHistoryItems: [ | |
| { | |
| type: 'tool_group', | |
| tools: [ | |
| { | |
| callId: 'call-focus-unknown', | |
| name: 'run_shell_command', | |
| description: 'Run command', | |
| resultDisplay: undefined, | |
| status: CoreToolCallStatus.AwaitingApproval, | |
| confirmationDetails: { | |
| type: 'exec', | |
| title: 'Run shell command', | |
| command: 'ls', | |
| rootCommand: 'ls', | |
| rootCommands: ['ls'], | |
| }, | |
| }, | |
| ], | |
| }, | |
| ], | |
| }); | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); | |
| unmount(); | |
| }); | |
| it('sends a macOS notification when a response completes while unfocused', async () => { | |
| mockedUseFocusState.mockReturnValue({ | |
| isFocused: false, | |
| hasReceivedFocusEvent: true, | |
| }); | |
| let currentStreamingState: 'idle' | 'responding' = 'responding'; | |
| mockedUseGeminiStream.mockImplementation(() => ({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| streamingState: currentStreamingState, | |
| })); | |
| const { unmount, rerender } = await act(async () => renderAppContainer()); | |
| currentStreamingState = 'idle'; | |
| await act(async () => { | |
| rerender(getAppContainer()); | |
| }); | |
| expect( | |
| terminalNotificationsMocks.buildRunEventNotificationContent, | |
| ).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| type: 'session_complete', | |
| detail: 'Gemini CLI finished responding.', | |
| }), | |
| ); | |
| expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); | |
| unmount(); | |
| }); | |
| it('sends completion notification when focus reporting is unavailable', async () => { | |
| mockedUseFocusState.mockReturnValue({ | |
| isFocused: true, | |
| hasReceivedFocusEvent: false, | |
| }); | |
| let currentStreamingState: 'idle' | 'responding' = 'responding'; | |
| mockedUseGeminiStream.mockImplementation(() => ({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| streamingState: currentStreamingState, | |
| })); | |
| const { unmount, rerender } = await act(async () => renderAppContainer()); | |
| currentStreamingState = 'idle'; | |
| await act(async () => { | |
| rerender(getAppContainer()); | |
| }); | |
| expect( | |
| terminalNotificationsMocks.buildRunEventNotificationContent, | |
| ).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| type: 'session_complete', | |
| detail: 'Gemini CLI finished responding.', | |
| }), | |
| ); | |
| unmount(); | |
| }); | |
| it('does not send completion notification when another action-required dialog is pending', async () => { | |
| mockedUseFocusState.mockReturnValue({ | |
| isFocused: false, | |
| hasReceivedFocusEvent: true, | |
| }); | |
| mockedUseQuotaAndFallback.mockReturnValue({ | |
| proQuotaRequest: { kind: 'upgrade' }, | |
| handleProQuotaChoice: vi.fn(), | |
| }); | |
| let currentStreamingState: 'idle' | 'responding' = 'responding'; | |
| mockedUseGeminiStream.mockImplementation(() => ({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| streamingState: currentStreamingState, | |
| })); | |
| const { unmount, rerender } = await act(async () => renderAppContainer()); | |
| currentStreamingState = 'idle'; | |
| await act(async () => { | |
| rerender(getAppContainer()); | |
| }); | |
| expect( | |
| terminalNotificationsMocks.notifyViaTerminal, | |
| ).not.toHaveBeenCalled(); | |
| unmount(); | |
| }); | |
| it('can send repeated attention notifications for the same key after pending state clears', async () => { | |
| mockedUseFocusState.mockReturnValue({ | |
| isFocused: false, | |
| hasReceivedFocusEvent: true, | |
| }); | |
| let pendingHistoryItems = [ | |
| { | |
| type: 'tool_group', | |
| tools: [ | |
| { | |
| callId: 'repeat-key-call', | |
| name: 'run_shell_command', | |
| description: 'Run command', | |
| resultDisplay: undefined, | |
| status: CoreToolCallStatus.AwaitingApproval, | |
| confirmationDetails: { | |
| type: 'exec', | |
| title: 'Run shell command', | |
| command: 'ls', | |
| rootCommand: 'ls', | |
| rootCommands: ['ls'], | |
| }, | |
| }, | |
| ], | |
| }, | |
| ]; | |
| mockedUseGeminiStream.mockImplementation(() => ({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| pendingHistoryItems, | |
| })); | |
| const { unmount, rerender } = await act(async () => renderAppContainer()); | |
| expect( | |
| terminalNotificationsMocks.notifyViaTerminal, | |
| ).toHaveBeenCalledTimes(1); | |
| pendingHistoryItems = []; | |
| await act(async () => { | |
| rerender(getAppContainer()); | |
| }); | |
| pendingHistoryItems = [ | |
| { | |
| type: 'tool_group', | |
| tools: [ | |
| { | |
| callId: 'repeat-key-call', | |
| name: 'run_shell_command', | |
| description: 'Run command', | |
| resultDisplay: undefined, | |
| status: CoreToolCallStatus.AwaitingApproval, | |
| confirmationDetails: { | |
| type: 'exec', | |
| title: 'Run shell command', | |
| command: 'ls', | |
| rootCommand: 'ls', | |
| rootCommands: ['ls'], | |
| }, | |
| }, | |
| ], | |
| }, | |
| ]; | |
| await act(async () => { | |
| rerender(getAppContainer()); | |
| }); | |
| expect( | |
| terminalNotificationsMocks.notifyViaTerminal, | |
| ).toHaveBeenCalledTimes(2); | |
| unmount(); | |
| }); | |
| it('initializes with theme error from initialization result', async () => { | |
| const initResultWithError = { | |
| ...mockInitResult, | |
| themeError: 'Failed to load theme', | |
| }; | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| initResult: initResultWithError, | |
| }), | |
| ); | |
| expect(capturedUIState).toBeTruthy(); | |
| unmount(); | |
| }); | |
| it('handles debug mode state', async () => { | |
| const debugConfig = makeFakeConfig(); | |
| vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true); | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ config: debugConfig }), | |
| ); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Context Providers', () => { | |
| it('provides AppContext with correct values', async () => { | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ version: '2.0.0' }), | |
| ); | |
| expect(capturedUIState).toBeTruthy(); | |
| // Should render and unmount cleanly | |
| unmount(); | |
| }); | |
| it('provides UIStateContext with state management', async () => { | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| expect(capturedUIState).toBeTruthy(); | |
| unmount(); | |
| }); | |
| it('provides UIActionsContext with action handlers', async () => { | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| expect(capturedUIState).toBeTruthy(); | |
| unmount(); | |
| }); | |
| it('provides ConfigContext with config object', async () => { | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| expect(capturedUIState).toBeTruthy(); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Settings Integration', () => { | |
| it('handles settings with all display options disabled', async () => { | |
| const settingsAllHidden = createMockSettings({ | |
| hideBanner: true, | |
| hideTips: true, | |
| hideFooter: true, | |
| showMemoryUsage: false, | |
| }); | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ settings: settingsAllHidden }), | |
| ); | |
| expect(capturedUIState).toBeTruthy(); | |
| unmount(); | |
| }); | |
| it('handles settings with memory usage enabled', async () => { | |
| const settingsWithMemory = createMockSettings({ | |
| showMemoryUsage: true, | |
| }); | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ settings: settingsWithMemory }), | |
| ); | |
| expect(capturedUIState).toBeTruthy(); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Version Handling', () => { | |
| it.each(['1.0.0', '2.1.3-beta', '3.0.0-nightly'])( | |
| 'handles version format: %s', | |
| async (version) => { | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ version }), | |
| ); | |
| expect(capturedUIState).toBeTruthy(); | |
| unmount(); | |
| }, | |
| ); | |
| }); | |
| describe('Error Handling', () => { | |
| it('handles config methods that might throw', async () => { | |
| const errorConfig = makeFakeConfig(); | |
| vi.spyOn(errorConfig, 'getModel').mockImplementation(() => { | |
| throw new Error('Config error'); | |
| }); | |
| // Should still render without crashing - errors should be handled internally | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ config: errorConfig }), | |
| ); | |
| unmount(); | |
| }); | |
| it('handles undefined settings gracefully', async () => { | |
| const undefinedSettings = createMockSettings(); | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ settings: undefinedSettings }), | |
| ); | |
| expect(capturedUIState).toBeTruthy(); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Provider Hierarchy', () => { | |
| it('establishes correct provider nesting order', async () => { | |
| // This tests that all the context providers are properly nested | |
| // and that the component tree can be built without circular dependencies | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Session Resumption', () => { | |
| it('handles resumed session data correctly', async () => { | |
| const mockResumedSessionData = { | |
| conversation: { | |
| sessionId: 'test-session-123', | |
| projectHash: 'test-project-hash', | |
| startTime: '2024-01-01T00:00:00Z', | |
| lastUpdated: '2024-01-01T00:00:01Z', | |
| messages: [ | |
| { | |
| id: 'msg-1', | |
| type: 'user' as const, | |
| content: 'Hello', | |
| timestamp: '2024-01-01T00:00:00Z', | |
| }, | |
| { | |
| id: 'msg-2', | |
| type: 'gemini' as const, | |
| content: 'Hi there!', | |
| role: 'model' as const, | |
| parts: [{ text: 'Hi there!' }], | |
| timestamp: '2024-01-01T00:00:01Z', | |
| }, | |
| ], | |
| }, | |
| filePath: '/tmp/test-session.json', | |
| }; | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| config: mockConfig, | |
| settings: mockSettings, | |
| version: '1.0.0', | |
| initResult: mockInitResult, | |
| resumedSessionData: mockResumedSessionData, | |
| }), | |
| ); | |
| unmount(); | |
| }); | |
| it('renders without resumed session data', async () => { | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| config: mockConfig, | |
| settings: mockSettings, | |
| version: '1.0.0', | |
| initResult: mockInitResult, | |
| resumedSessionData: undefined, | |
| }), | |
| ); | |
| unmount(); | |
| }); | |
| it('initializes chat recording service when config has it', async () => { | |
| const mockChatRecordingService = { | |
| initialize: vi.fn(), | |
| recordMessage: vi.fn(), | |
| recordMessageTokens: vi.fn(), | |
| recordToolCalls: vi.fn(), | |
| }; | |
| const mockGeminiClient = { | |
| isInitialized: vi.fn(() => true), | |
| resumeChat: vi.fn(), | |
| getUserTier: vi.fn(), | |
| getChatRecordingService: vi.fn(() => mockChatRecordingService), | |
| }; | |
| const configWithRecording = makeFakeConfig(); | |
| vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue( | |
| mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>, | |
| ); | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| config: configWithRecording, | |
| settings: mockSettings, | |
| version: '1.0.0', | |
| initResult: mockInitResult, | |
| }), | |
| ); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Session Recording Integration', () => { | |
| it('provides chat recording service configuration', async () => { | |
| const mockChatRecordingService = { | |
| initialize: vi.fn(), | |
| recordMessage: vi.fn(), | |
| recordMessageTokens: vi.fn(), | |
| recordToolCalls: vi.fn(), | |
| getSessionId: vi.fn(() => 'test-session-123'), | |
| getCurrentConversation: vi.fn(), | |
| }; | |
| const mockGeminiClient = { | |
| isInitialized: vi.fn(() => true), | |
| resumeChat: vi.fn(), | |
| getUserTier: vi.fn(), | |
| getChatRecordingService: vi.fn(() => mockChatRecordingService), | |
| setHistory: vi.fn(), | |
| }; | |
| const configWithRecording = makeFakeConfig(); | |
| vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue( | |
| mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>, | |
| ); | |
| vi.spyOn(configWithRecording, 'getSessionId').mockReturnValue( | |
| 'test-session-123', | |
| ); | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| config: configWithRecording, | |
| settings: mockSettings, | |
| version: '1.0.0', | |
| initResult: mockInitResult, | |
| }), | |
| ); | |
| // Verify the recording service structure is correct | |
| expect(configWithRecording.getGeminiClient).toBeDefined(); | |
| expect(mockGeminiClient.getChatRecordingService).toBeDefined(); | |
| expect(mockChatRecordingService.initialize).toBeDefined(); | |
| expect(mockChatRecordingService.recordMessage).toBeDefined(); | |
| unmount(); | |
| }); | |
| it('handles session recording when messages are added', async () => { | |
| const mockRecordMessage = vi.fn(); | |
| const mockRecordMessageTokens = vi.fn(); | |
| const mockChatRecordingService = { | |
| initialize: vi.fn(), | |
| recordMessage: mockRecordMessage, | |
| recordMessageTokens: mockRecordMessageTokens, | |
| recordToolCalls: vi.fn(), | |
| getSessionId: vi.fn(() => 'test-session-123'), | |
| }; | |
| const mockGeminiClient = { | |
| isInitialized: vi.fn(() => true), | |
| getChatRecordingService: vi.fn(() => mockChatRecordingService), | |
| getUserTier: vi.fn(), | |
| }; | |
| const configWithRecording = makeFakeConfig(); | |
| vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue( | |
| mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>, | |
| ); | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| config: configWithRecording, | |
| settings: mockSettings, | |
| version: '1.0.0', | |
| initResult: mockInitResult, | |
| }), | |
| ); | |
| // The actual recording happens through the useHistory hook | |
| // which would be triggered by user interactions | |
| expect(mockChatRecordingService.initialize).toBeDefined(); | |
| expect(mockChatRecordingService.recordMessage).toBeDefined(); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Session Resume Flow', () => { | |
| it('accepts resumed session data', async () => { | |
| const mockResumeChat = vi.fn(); | |
| const mockGeminiClient = { | |
| isInitialized: vi.fn(() => true), | |
| resumeChat: mockResumeChat, | |
| getUserTier: vi.fn(), | |
| getChatRecordingService: vi.fn(() => ({ | |
| initialize: vi.fn(), | |
| recordMessage: vi.fn(), | |
| recordMessageTokens: vi.fn(), | |
| recordToolCalls: vi.fn(), | |
| })), | |
| }; | |
| const configWithClient = makeFakeConfig(); | |
| vi.spyOn(configWithClient, 'getGeminiClient').mockReturnValue( | |
| mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>, | |
| ); | |
| const resumedData = { | |
| conversation: { | |
| sessionId: 'resumed-session-456', | |
| projectHash: 'project-hash', | |
| startTime: '2024-01-01T00:00:00Z', | |
| lastUpdated: '2024-01-01T00:01:00Z', | |
| messages: [ | |
| { | |
| id: 'msg-1', | |
| type: 'user' as const, | |
| content: 'Previous question', | |
| timestamp: '2024-01-01T00:00:00Z', | |
| }, | |
| { | |
| id: 'msg-2', | |
| type: 'gemini' as const, | |
| content: 'Previous answer', | |
| role: 'model' as const, | |
| parts: [{ text: 'Previous answer' }], | |
| timestamp: '2024-01-01T00:00:30Z', | |
| tokenCount: { input: 10, output: 20 }, | |
| }, | |
| ], | |
| }, | |
| filePath: '/tmp/resumed-session.json', | |
| }; | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| config: configWithClient, | |
| settings: mockSettings, | |
| version: '1.0.0', | |
| initResult: mockInitResult, | |
| resumedSessionData: resumedData, | |
| }), | |
| ); | |
| // Verify the resume functionality structure is in place | |
| expect(mockGeminiClient.resumeChat).toBeDefined(); | |
| expect(resumedData.conversation.messages).toHaveLength(2); | |
| unmount(); | |
| }); | |
| it('does not attempt resume when client is not initialized', async () => { | |
| const mockResumeChat = vi.fn(); | |
| const mockGeminiClient = { | |
| isInitialized: vi.fn(() => false), // Not initialized | |
| resumeChat: mockResumeChat, | |
| getUserTier: vi.fn(), | |
| getChatRecordingService: vi.fn(), | |
| }; | |
| const configWithClient = makeFakeConfig(); | |
| vi.spyOn(configWithClient, 'getGeminiClient').mockReturnValue( | |
| mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>, | |
| ); | |
| const resumedData = { | |
| conversation: { | |
| sessionId: 'test-session', | |
| projectHash: 'project-hash', | |
| startTime: '2024-01-01T00:00:00Z', | |
| lastUpdated: '2024-01-01T00:01:00Z', | |
| messages: [], | |
| }, | |
| filePath: '/tmp/session.json', | |
| }; | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| config: configWithClient, | |
| settings: mockSettings, | |
| version: '1.0.0', | |
| initResult: mockInitResult, | |
| resumedSessionData: resumedData, | |
| }), | |
| ); | |
| // Should not call resumeChat when client is not initialized | |
| expect(mockResumeChat).not.toHaveBeenCalled(); | |
| unmount(); | |
| }); | |
| }); | |
| describe('SessionStart Hook Rendering', () => { | |
| it('does not render systemMessage directly (avoids duplicate with HookSystemMessage event)', async () => { | |
| const mockAddItem = vi.fn(); | |
| mockedUseHistory.mockReturnValue({ | |
| history: [], | |
| addItem: mockAddItem, | |
| updateItem: vi.fn(), | |
| clearItems: vi.fn(), | |
| loadHistory: vi.fn(), | |
| }); | |
| const fireSessionStartEvent = vi.fn().mockResolvedValue({ | |
| systemMessage: 'Hello from SessionStart hook', | |
| getAdditionalContext: vi.fn(() => undefined), | |
| }); | |
| vi.spyOn(mockConfig, 'getHookSystem').mockReturnValue({ | |
| fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), | |
| fireSessionStartEvent, | |
| } as unknown as ReturnType<Config['getHookSystem']>); | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| await waitFor(() => expect(fireSessionStartEvent).toHaveBeenCalled()); | |
| // The direct-render path (the bug) would call addItem with the | |
| // systemMessage text and no `source` field. The HookSystemMessage | |
| // event-listener path (the correct one) always sets `source`. | |
| const directRenderCall = mockAddItem.mock.calls.find( | |
| ([item]) => | |
| item?.text === 'Hello from SessionStart hook' && !item?.source, | |
| ); | |
| expect(directRenderCall).toBeUndefined(); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Token Counting from Session Stats', () => { | |
| it('tracks token counts from session messages', async () => { | |
| // Session stats are provided through the SessionStatsProvider context | |
| // in the real app, not through the config directly | |
| const mockChatRecordingService = { | |
| initialize: vi.fn(), | |
| recordMessage: vi.fn(), | |
| recordMessageTokens: vi.fn(), | |
| recordToolCalls: vi.fn(), | |
| getSessionId: vi.fn(() => 'test-session-123'), | |
| getCurrentConversation: vi.fn(() => ({ | |
| sessionId: 'test-session-123', | |
| messages: [], | |
| totalInputTokens: 150, | |
| totalOutputTokens: 350, | |
| })), | |
| }; | |
| const mockGeminiClient = { | |
| isInitialized: vi.fn(() => true), | |
| getChatRecordingService: vi.fn(() => mockChatRecordingService), | |
| getUserTier: vi.fn(), | |
| }; | |
| const configWithRecording = makeFakeConfig(); | |
| vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue( | |
| mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>, | |
| ); | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| config: configWithRecording, | |
| settings: mockSettings, | |
| version: '1.0.0', | |
| initResult: mockInitResult, | |
| }), | |
| ); | |
| // In the actual app, these stats would be displayed in components | |
| // and updated as messages are processed through the recording service | |
| expect(mockChatRecordingService.recordMessageTokens).toBeDefined(); | |
| expect(mockChatRecordingService.getCurrentConversation).toBeDefined(); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Quota and Fallback Integration', () => { | |
| it('passes a null proQuotaRequest to QuotaContext by default', async () => { | |
| // The default mock from beforeEach already sets proQuotaRequest to null | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| // Assert that the context value is as expected | |
| expect(capturedQuotaState.proQuotaRequest).toBeNull(); | |
| unmount(); | |
| }); | |
| it('passes a valid proQuotaRequest to QuotaContext when provided by the hook', async () => { | |
| // Arrange: Create a mock request object that a UI dialog would receive | |
| const mockRequest = { | |
| failedModel: 'gemini-pro', | |
| fallbackModel: 'gemini-flash', | |
| resolve: vi.fn(), | |
| }; | |
| mockedUseQuotaAndFallback.mockReturnValue({ | |
| proQuotaRequest: mockRequest, | |
| handleProQuotaChoice: vi.fn(), | |
| }); | |
| // Act: Render the container | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| // Assert: The mock request is correctly passed through the context | |
| expect(capturedQuotaState.proQuotaRequest).toEqual(mockRequest); | |
| unmount(); | |
| }); | |
| it('passes the handleProQuotaChoice function to UIActionsContext', async () => { | |
| // Arrange: Create a mock handler function | |
| const mockHandler = vi.fn(); | |
| mockedUseQuotaAndFallback.mockReturnValue({ | |
| proQuotaRequest: null, | |
| handleProQuotaChoice: mockHandler, | |
| }); | |
| // Act: Render the container | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| // Assert: The action in the context is the mock handler we provided | |
| expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler); | |
| // You can even verify that the plumbed function is callable | |
| act(() => { | |
| capturedUIActions.handleProQuotaChoice('retry_later'); | |
| }); | |
| expect(mockHandler).toHaveBeenCalledWith('retry_later'); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Terminal Title Update Feature', () => { | |
| beforeEach(() => { | |
| // Reset mock stdout for each test | |
| mocks.mockStdout.write.mockClear(); | |
| (disableMouseEvents as import('vitest').Mock).mockClear(); | |
| }); | |
| it('verifies useStdout is mocked', async () => { | |
| const { useStdout } = await import('ink'); | |
| const { stdout } = useStdout(); | |
| expect(stdout).toBe(mocks.mockStdout); | |
| }); | |
| it('should update terminal title with Working… when showStatusInTitle is false', async () => { | |
| // Arrange: Set up mock settings with showStatusInTitle disabled | |
| const mockSettingsWithShowStatusFalse = createMockSettings({ | |
| ui: { | |
| showStatusInTitle: false, | |
| hideWindowTitle: false, | |
| }, | |
| }); | |
| // Mock the streaming state as Active | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| streamingState: 'responding', | |
| thought: { subject: 'Some thought' }, | |
| }); | |
| // Act: Render the container | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| settings: mockSettingsWithShowStatusFalse, | |
| }), | |
| ); | |
| // Assert: Check that title was updated with "Working…" | |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => | |
| call[0].includes('\x1b]0;'), | |
| ); | |
| expect(titleWrites).toHaveLength(1); | |
| expect(titleWrites[0][0]).toBe( | |
| `\x1b]0;${'✦ Working… (workspace)'.padEnd(80, ' ')}\x07`, | |
| ); | |
| unmount(); | |
| }); | |
| it('should use legacy terminal title when dynamicWindowTitle is false', async () => { | |
| // Arrange: Set up mock settings with dynamicWindowTitle disabled | |
| const mockSettingsWithDynamicTitleFalse = createMockSettings({ | |
| ui: { | |
| dynamicWindowTitle: false, | |
| hideWindowTitle: false, | |
| }, | |
| }); | |
| // Mock the streaming state | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| streamingState: 'responding', | |
| thought: { subject: 'Some thought' }, | |
| }); | |
| // Act: Render the container | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| settings: mockSettingsWithDynamicTitleFalse, | |
| }), | |
| ); | |
| // Assert: Check that legacy title was used | |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => | |
| call[0].includes('\x1b]0;'), | |
| ); | |
| expect(titleWrites).toHaveLength(1); | |
| expect(titleWrites[0][0]).toBe( | |
| `\x1b]0;${'Gemini CLI (workspace)'.padEnd(80, ' ')}\x07`, | |
| ); | |
| unmount(); | |
| }); | |
| it('should not update terminal title when hideWindowTitle is true', async () => { | |
| // Arrange: Set up mock settings with hideWindowTitle enabled | |
| const mockSettingsWithHideTitleTrue = createMockSettings({ | |
| ui: { | |
| showStatusInTitle: true, | |
| hideWindowTitle: true, | |
| }, | |
| }); | |
| // Act: Render the container | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| settings: mockSettingsWithHideTitleTrue, | |
| }), | |
| ); | |
| // Assert: Check that no title-related writes occurred | |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => | |
| call[0].includes('\x1b]0;'), | |
| ); | |
| expect(titleWrites).toHaveLength(0); | |
| unmount(); | |
| }); | |
| it('should update terminal title with thought subject when in active state', async () => { | |
| // Arrange: Set up mock settings with showStatusInTitle enabled | |
| const mockSettingsWithTitleEnabled = createMockSettings({ | |
| ui: { | |
| showStatusInTitle: true, | |
| hideWindowTitle: false, | |
| }, | |
| }); | |
| // Mock the streaming state and thought | |
| const thoughtSubject = 'Processing request'; | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| streamingState: 'responding', | |
| thought: { subject: thoughtSubject }, | |
| }); | |
| // Act: Render the container | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| settings: mockSettingsWithTitleEnabled, | |
| }), | |
| ); | |
| // Assert: Check that title was updated with thought subject and suffix | |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => | |
| call[0].includes('\x1b]0;'), | |
| ); | |
| expect(titleWrites).toHaveLength(1); | |
| expect(titleWrites[0][0]).toBe( | |
| `\x1b]0;${`✦ ${thoughtSubject} (workspace)`.padEnd(80, ' ')}\x07`, | |
| ); | |
| unmount(); | |
| }); | |
| it('should update terminal title with default text when in Idle state and no thought subject', async () => { | |
| // Arrange: Set up mock settings with showStatusInTitle enabled | |
| const mockSettingsWithTitleEnabled = createMockSettings({ | |
| ui: { | |
| showStatusInTitle: true, | |
| hideWindowTitle: false, | |
| }, | |
| }); | |
| // Mock the streaming state as Idle with no thought | |
| mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK); | |
| // Act: Render the container | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| settings: mockSettingsWithTitleEnabled, | |
| }), | |
| ); | |
| // Assert: Check that title was updated with default Idle text | |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => | |
| call[0].includes('\x1b]0;'), | |
| ); | |
| expect(titleWrites).toHaveLength(1); | |
| expect(titleWrites[0][0]).toBe( | |
| `\x1b]0;${'◇ Ready (workspace)'.padEnd(80, ' ')}\x07`, | |
| ); | |
| unmount(); | |
| }); | |
| it('should update terminal title when in WaitingForConfirmation state with thought subject', async () => { | |
| // Arrange: Set up mock settings with showStatusInTitle enabled | |
| const mockSettingsWithTitleEnabled = createMockSettings({ | |
| ui: { | |
| showStatusInTitle: true, | |
| hideWindowTitle: false, | |
| }, | |
| }); | |
| // Mock the streaming state and thought | |
| const thoughtSubject = 'Confirm tool execution'; | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| streamingState: 'waiting_for_confirmation', | |
| thought: { subject: thoughtSubject }, | |
| }); | |
| // Act: Render the container | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| settings: mockSettingsWithTitleEnabled, | |
| }), | |
| ); | |
| // Assert: Check that title was updated with confirmation text | |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => | |
| call[0].includes('\x1b]0;'), | |
| ); | |
| expect(titleWrites).toHaveLength(1); | |
| expect(titleWrites[0][0]).toBe( | |
| `\x1b]0;${'✋ Action Required (workspace)'.padEnd(80, ' ')}\x07`, | |
| ); | |
| unmount(); | |
| }); | |
| describe('Shell Focus Action Required', () => { | |
| beforeEach(async () => { | |
| vi.useFakeTimers(); | |
| // Use real implementation for these tests to verify title updates | |
| const actual = await vi.importActual< | |
| typeof import('./hooks/useShellInactivityStatus.js') | |
| >('./hooks/useShellInactivityStatus.js'); | |
| mockedUseShellInactivityStatus.mockImplementation( | |
| actual.useShellInactivityStatus, | |
| ); | |
| }); | |
| afterEach(() => { | |
| vi.useRealTimers(); | |
| }); | |
| it('should show Action Required in title after a delay when shell is awaiting focus', async () => { | |
| const startTime = 1000000; | |
| vi.setSystemTime(startTime); | |
| // Arrange: Set up mock settings with showStatusInTitle enabled | |
| const mockSettingsWithTitleEnabled = createMockSettings({ | |
| ui: { | |
| showStatusInTitle: true, | |
| hideWindowTitle: false, | |
| }, | |
| }); | |
| // Mock an active shell pty but not focused | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| streamingState: 'responding', | |
| thought: { subject: 'Executing shell command' }, | |
| pendingToolCalls: [], | |
| activePtyId: 'pty-1', | |
| lastOutputTime: startTime + 100, // Trigger aggressive delay | |
| retryStatus: null, | |
| }); | |
| vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); | |
| vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); | |
| // Act: Render the container (embeddedShellFocused is false by default in state) | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| settings: mockSettingsWithTitleEnabled, | |
| }), | |
| ); | |
| // Initially it should show the working status | |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => | |
| call[0].includes('\x1b]0;'), | |
| ); | |
| expect(titleWrites[titleWrites.length - 1][0]).toContain( | |
| '✦ Executing shell command', | |
| ); | |
| // Fast-forward time by 40 seconds | |
| await act(async () => { | |
| await vi.advanceTimersByTimeAsync(40000); | |
| }); | |
| // Now it should show Action Required | |
| const titleWritesDelayed = mocks.mockStdout.write.mock.calls.filter( | |
| (call) => call[0].includes('\x1b]0;'), | |
| ); | |
| const lastTitle = titleWritesDelayed[titleWritesDelayed.length - 1][0]; | |
| expect(lastTitle).toContain('✋ Action Required'); | |
| unmount(); | |
| }); | |
| it('should show Working… in title for redirected commands after 2 mins', async () => { | |
| const startTime = 1000000; | |
| vi.setSystemTime(startTime); | |
| // Arrange: Set up mock settings with showStatusInTitle enabled | |
| const mockSettingsWithTitleEnabled = createMockSettings({ | |
| ui: { | |
| showStatusInTitle: true, | |
| hideWindowTitle: false, | |
| }, | |
| }); | |
| // Mock an active shell pty with redirection active | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| streamingState: 'responding', | |
| thought: { subject: 'Executing shell command' }, | |
| pendingToolCalls: [ | |
| { | |
| request: { | |
| name: 'run_shell_command', | |
| args: { command: 'ls > out' }, | |
| }, | |
| status: CoreToolCallStatus.Executing, | |
| } as unknown as TrackedToolCall, | |
| ], | |
| activePtyId: 'pty-1', | |
| lastOutputTime: startTime, | |
| retryStatus: null, | |
| }); | |
| vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); | |
| vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| settings: mockSettingsWithTitleEnabled, | |
| }), | |
| ); | |
| // Fast-forward time by 65 seconds - should still NOT be Action Required | |
| await act(async () => { | |
| await vi.advanceTimersByTimeAsync(65000); | |
| }); | |
| const titleWritesMid = mocks.mockStdout.write.mock.calls.filter( | |
| (call) => call[0].includes('\x1b]0;'), | |
| ); | |
| expect(titleWritesMid[titleWritesMid.length - 1][0]).not.toContain( | |
| '✋ Action Required', | |
| ); | |
| // Fast-forward to 2 minutes (120000ms) | |
| await act(async () => { | |
| await vi.advanceTimersByTimeAsync(60000); | |
| }); | |
| const titleWritesEnd = mocks.mockStdout.write.mock.calls.filter( | |
| (call) => call[0].includes('\x1b]0;'), | |
| ); | |
| expect(titleWritesEnd[titleWritesEnd.length - 1][0]).toContain( | |
| '⏲ Working…', | |
| ); | |
| unmount(); | |
| }); | |
| it('should show Working… in title for silent non-redirected commands after 1 min', async () => { | |
| const startTime = 1000000; | |
| vi.setSystemTime(startTime); | |
| // Arrange: Set up mock settings with showStatusInTitle enabled | |
| const mockSettingsWithTitleEnabled = createMockSettings({ | |
| ui: { | |
| showStatusInTitle: true, | |
| hideWindowTitle: false, | |
| }, | |
| }); | |
| // Mock an active shell pty with NO output since operation started (silent) | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| streamingState: 'responding', | |
| thought: { subject: 'Executing shell command' }, | |
| pendingToolCalls: [], | |
| activePtyId: 'pty-1', | |
| lastOutputTime: startTime, // lastOutputTime <= operationStartTime | |
| retryStatus: null, | |
| }); | |
| vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); | |
| vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| settings: mockSettingsWithTitleEnabled, | |
| }), | |
| ); | |
| // Fast-forward time by 65 seconds | |
| await act(async () => { | |
| await vi.advanceTimersByTimeAsync(65000); | |
| }); | |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => | |
| call[0].includes('\x1b]0;'), | |
| ); | |
| const lastTitle = titleWrites[titleWrites.length - 1][0]; | |
| // Should show Working… (⏲) instead of Action Required (✋) | |
| expect(lastTitle).toContain('⏲ Working…'); | |
| unmount(); | |
| }); | |
| it('should NOT show Action Required in title if shell is streaming output', async () => { | |
| const startTime = 1000000; | |
| vi.setSystemTime(startTime); | |
| // Arrange: Set up mock settings with showStatusInTitle enabled | |
| const mockSettingsWithTitleEnabled = createMockSettings({ | |
| ui: { | |
| showStatusInTitle: true, | |
| hideWindowTitle: false, | |
| }, | |
| }); | |
| // Mock an active shell pty but not focused | |
| let lastOutputTime = startTime + 1000; | |
| mockedUseGeminiStream.mockImplementation(() => ({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| streamingState: 'responding', | |
| thought: { subject: 'Executing shell command' }, | |
| activePtyId: 'pty-1', | |
| lastOutputTime, | |
| })); | |
| vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); | |
| vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); | |
| // Act: Render the container | |
| const { unmount, rerender } = await act(async () => | |
| renderAppContainer({ | |
| settings: mockSettingsWithTitleEnabled, | |
| }), | |
| ); | |
| // Fast-forward time by 20 seconds | |
| await act(async () => { | |
| await vi.advanceTimersByTimeAsync(20000); | |
| }); | |
| // Update lastOutputTime to simulate new output | |
| lastOutputTime = startTime + 21000; | |
| mockedUseGeminiStream.mockImplementation(() => ({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| streamingState: 'responding', | |
| thought: { subject: 'Executing shell command' }, | |
| activePtyId: 'pty-1', | |
| lastOutputTime, | |
| })); | |
| // Rerender to propagate the new lastOutputTime | |
| await act(async () => { | |
| rerender(getAppContainer({ settings: mockSettingsWithTitleEnabled })); | |
| }); | |
| // Fast-forward time by another 20 seconds | |
| // Total time elapsed: 40s. | |
| // Time since last output: 20s. | |
| // It should NOT show Action Required yet. | |
| await act(async () => { | |
| await vi.advanceTimersByTimeAsync(20000); | |
| }); | |
| const titleWritesAfterOutput = mocks.mockStdout.write.mock.calls.filter( | |
| (call) => call[0].includes('\x1b]0;'), | |
| ); | |
| const lastTitle = | |
| titleWritesAfterOutput[titleWritesAfterOutput.length - 1][0]; | |
| expect(lastTitle).not.toContain('✋ Action Required'); | |
| expect(lastTitle).toContain('✦ Executing shell command'); | |
| // Fast-forward another 40 seconds (Total 60s since last output) | |
| await act(async () => { | |
| await vi.advanceTimersByTimeAsync(40000); | |
| }); | |
| // Now it SHOULD show Action Required | |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => | |
| call[0].includes('\x1b]0;'), | |
| ); | |
| const lastTitleFinal = titleWrites[titleWrites.length - 1][0]; | |
| expect(lastTitleFinal).toContain('✋ Action Required'); | |
| unmount(); | |
| }); | |
| }); | |
| it('should pad title to exactly 80 characters', async () => { | |
| // Arrange: Set up mock settings with showStatusInTitle enabled | |
| const mockSettingsWithTitleEnabled = createMockSettings({ | |
| ui: { | |
| showStatusInTitle: true, | |
| hideWindowTitle: false, | |
| }, | |
| }); | |
| // Mock the streaming state and thought with a short subject | |
| const shortTitle = 'Short'; | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| streamingState: 'responding', | |
| thought: { subject: shortTitle }, | |
| }); | |
| // Act: Render the container | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| settings: mockSettingsWithTitleEnabled, | |
| }), | |
| ); | |
| // Assert: Check that title is padded to exactly 80 characters | |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => | |
| call[0].includes('\x1b]0;'), | |
| ); | |
| expect(titleWrites).toHaveLength(1); | |
| const calledWith = titleWrites[0][0]; | |
| const expectedTitle = `✦ ${shortTitle} (workspace)`.padEnd(80, ' '); | |
| const expectedEscapeSequence = `\x1b]0;${expectedTitle}\x07`; | |
| expect(calledWith).toBe(expectedEscapeSequence); | |
| unmount(); | |
| }); | |
| it('should use correct ANSI escape code format', async () => { | |
| // Arrange: Set up mock settings with showStatusInTitle enabled | |
| const mockSettingsWithTitleEnabled = createMockSettings({ | |
| ui: { | |
| showStatusInTitle: true, | |
| hideWindowTitle: false, | |
| }, | |
| }); | |
| // Mock the streaming state and thought | |
| const title = 'Test Title'; | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| streamingState: 'responding', | |
| thought: { subject: title }, | |
| }); | |
| // Act: Render the container | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| settings: mockSettingsWithTitleEnabled, | |
| }), | |
| ); | |
| // Assert: Check that the correct ANSI escape sequence is used | |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => | |
| call[0].includes('\x1b]0;'), | |
| ); | |
| expect(titleWrites).toHaveLength(1); | |
| const expectedEscapeSequence = `\x1b]0;${`✦ ${title} (workspace)`.padEnd(80, ' ')}\x07`; | |
| expect(titleWrites[0][0]).toBe(expectedEscapeSequence); | |
| unmount(); | |
| }); | |
| it('should use CLI_TITLE environment variable when set', async () => { | |
| // Arrange: Set up mock settings with showStatusInTitle disabled (so it shows suffix) | |
| const mockSettingsWithTitleDisabled = createMockSettings({ | |
| ui: { | |
| showStatusInTitle: false, | |
| hideWindowTitle: false, | |
| }, | |
| }); | |
| // Mock CLI_TITLE environment variable | |
| vi.stubEnv('CLI_TITLE', 'Custom Gemini Title'); | |
| // Mock the streaming state | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| streamingState: 'responding', | |
| }); | |
| // Act: Render the container | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| settings: mockSettingsWithTitleDisabled, | |
| }), | |
| ); | |
| // Assert: Check that title was updated with CLI_TITLE value | |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => | |
| call[0].includes('\x1b]0;'), | |
| ); | |
| expect(titleWrites).toHaveLength(1); | |
| expect(titleWrites[0][0]).toBe( | |
| `\x1b]0;${'✦ Working… (Custom Gemini Title)'.padEnd(80, ' ')}\x07`, | |
| ); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Queue Error Message', () => { | |
| beforeEach(() => { | |
| vi.useFakeTimers(); | |
| }); | |
| afterEach(() => { | |
| vi.useRealTimers(); | |
| vi.restoreAllMocks(); | |
| }); | |
| it('should set and clear the queue error message after a timeout', async () => { | |
| const { rerender, unmount } = await act(async () => renderAppContainer()); | |
| await act(async () => { | |
| vi.advanceTimersByTime(0); | |
| }); | |
| expect(capturedUIState.queueErrorMessage).toBeNull(); | |
| act(() => { | |
| capturedUIActions.setQueueErrorMessage('Test error'); | |
| }); | |
| rerender(getAppContainer()); | |
| expect(capturedUIState.queueErrorMessage).toBe('Test error'); | |
| act(() => { | |
| vi.advanceTimersByTime(3000); | |
| }); | |
| rerender(getAppContainer()); | |
| expect(capturedUIState.queueErrorMessage).toBeNull(); | |
| unmount(); | |
| }); | |
| it('should reset the timer if a new error message is set', async () => { | |
| const { rerender, unmount } = await act(async () => renderAppContainer()); | |
| await act(async () => { | |
| vi.advanceTimersByTime(0); | |
| }); | |
| act(() => { | |
| capturedUIActions.setQueueErrorMessage('First error'); | |
| }); | |
| rerender(getAppContainer()); | |
| expect(capturedUIState.queueErrorMessage).toBe('First error'); | |
| act(() => { | |
| vi.advanceTimersByTime(1500); | |
| }); | |
| act(() => { | |
| capturedUIActions.setQueueErrorMessage('Second error'); | |
| }); | |
| rerender(getAppContainer()); | |
| expect(capturedUIState.queueErrorMessage).toBe('Second error'); | |
| act(() => { | |
| vi.advanceTimersByTime(2000); | |
| }); | |
| rerender(getAppContainer()); | |
| expect(capturedUIState.queueErrorMessage).toBe('Second error'); | |
| // 5. Advance time past the 3 second timeout from the second message | |
| act(() => { | |
| vi.advanceTimersByTime(1000); | |
| }); | |
| rerender(getAppContainer()); | |
| expect(capturedUIState.queueErrorMessage).toBeNull(); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => { | |
| let mockHandleSlashCommand: Mock; | |
| let mockCancelOngoingRequest: Mock; | |
| let rerender: () => void; | |
| let unmount: () => void; | |
| let stdin: Awaited<ReturnType<typeof render>>['stdin']; | |
| // Helper function to reduce boilerplate in tests | |
| const setupKeypressTest = async () => { | |
| const renderResult = await act(async () => renderAppContainer()); | |
| stdin = renderResult.stdin; | |
| await act(async () => { | |
| vi.advanceTimersByTime(0); | |
| }); | |
| rerender = () => { | |
| renderResult.rerender(getAppContainer()); | |
| }; | |
| unmount = renderResult.unmount; | |
| }; | |
| const pressKey = (sequence: string, times = 1) => { | |
| for (let i = 0; i < times; i++) { | |
| act(() => { | |
| stdin.write(sequence); | |
| }); | |
| rerender(); | |
| } | |
| }; | |
| beforeEach(() => { | |
| // Mock slash command handler | |
| mockHandleSlashCommand = vi.fn(); | |
| mockedUseSlashCommandProcessor.mockReturnValue({ | |
| handleSlashCommand: mockHandleSlashCommand, | |
| slashCommands: [], | |
| pendingHistoryItems: [], | |
| commandContext: {}, | |
| shellConfirmationRequest: null, | |
| confirmationRequest: null, | |
| }); | |
| // Mock request cancellation | |
| mockCancelOngoingRequest = vi.fn(); | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| cancelOngoingRequest: mockCancelOngoingRequest, | |
| }); | |
| // Default empty text buffer | |
| mockedUseTextBuffer.mockReturnValue({ | |
| text: '', | |
| setText: vi.fn(), | |
| lines: [''], | |
| cursor: [0, 0], | |
| handleInput: vi.fn().mockReturnValue(false), | |
| }); | |
| vi.useFakeTimers(); | |
| }); | |
| afterEach(() => { | |
| vi.useRealTimers(); | |
| vi.restoreAllMocks(); | |
| }); | |
| describe('CTRL+C', () => { | |
| it('should cancel ongoing request on first press', async () => { | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| streamingState: 'responding', | |
| cancelOngoingRequest: mockCancelOngoingRequest, | |
| }); | |
| await setupKeypressTest(); | |
| pressKey('\x03'); // Ctrl+C | |
| expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(1); | |
| expect(mockHandleSlashCommand).not.toHaveBeenCalled(); | |
| unmount(); | |
| }); | |
| it('should quit on second press', async () => { | |
| await setupKeypressTest(); | |
| pressKey('\x03', 2); // Ctrl+C | |
| expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(2); | |
| expect(mockHandleSlashCommand).toHaveBeenCalledWith( | |
| '/quit', | |
| undefined, | |
| undefined, | |
| false, | |
| ); | |
| unmount(); | |
| }); | |
| it('should reset press count after a timeout', async () => { | |
| await setupKeypressTest(); | |
| pressKey('\x03'); // Ctrl+C | |
| expect(mockHandleSlashCommand).not.toHaveBeenCalled(); | |
| // Advance timer past the reset threshold | |
| act(() => { | |
| vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1); | |
| }); | |
| pressKey('\x03'); // Ctrl+C | |
| expect(mockHandleSlashCommand).not.toHaveBeenCalled(); | |
| unmount(); | |
| }); | |
| }); | |
| describe('CTRL+D', () => { | |
| it('should quit on second press if buffer is empty', async () => { | |
| await setupKeypressTest(); | |
| pressKey('\x04', 2); // Ctrl+D | |
| expect(mockHandleSlashCommand).toHaveBeenCalledWith( | |
| '/quit', | |
| undefined, | |
| undefined, | |
| false, | |
| ); | |
| unmount(); | |
| }); | |
| it('should NOT quit if buffer is not empty', async () => { | |
| mockedUseTextBuffer.mockReturnValue({ | |
| text: 'some text', | |
| setText: vi.fn(), | |
| lines: ['some text'], | |
| cursor: [0, 9], // At the end | |
| handleInput: vi.fn().mockReturnValue(false), | |
| }); | |
| await setupKeypressTest(); | |
| pressKey('\x04'); // Ctrl+D | |
| // Should only be called once, so count is 1, not quitting yet. | |
| expect(mockHandleSlashCommand).not.toHaveBeenCalled(); | |
| pressKey('\x04'); // Ctrl+D | |
| // It should still not quit because buffer is non-empty. | |
| expect(mockHandleSlashCommand).not.toHaveBeenCalled(); | |
| unmount(); | |
| }); | |
| it('should reset press count after a timeout', async () => { | |
| await setupKeypressTest(); | |
| pressKey('\x04'); // Ctrl+D | |
| expect(mockHandleSlashCommand).not.toHaveBeenCalled(); | |
| // Advance timer past the reset threshold | |
| act(() => { | |
| vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1); | |
| }); | |
| pressKey('\x04'); // Ctrl+D | |
| expect(mockHandleSlashCommand).not.toHaveBeenCalled(); | |
| unmount(); | |
| }); | |
| }); | |
| describe('CTRL+Z', () => { | |
| it('should call handleSuspend', async () => { | |
| const handleSuspend = vi.fn(); | |
| mockedUseSuspend.mockReturnValue({ handleSuspend }); | |
| await setupKeypressTest(); | |
| pressKey('\x1A'); // Ctrl+Z | |
| expect(handleSuspend).toHaveBeenCalledTimes(1); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Focus Handling (Tab / Shift+Tab)', () => { | |
| beforeEach(() => { | |
| // Mock activePtyId to enable focus | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| activePtyId: 1, | |
| }); | |
| }); | |
| it('should focus shell input on Tab', async () => { | |
| await setupKeypressTest(); | |
| pressKey('\t'); | |
| expect(capturedUIState.embeddedShellFocused).toBe(true); | |
| unmount(); | |
| }); | |
| it('should unfocus shell input on Shift+Tab', async () => { | |
| await setupKeypressTest(); | |
| // Focus first | |
| pressKey('\t'); | |
| expect(capturedUIState.embeddedShellFocused).toBe(true); | |
| // Unfocus via Shift+Tab | |
| pressKey('\x1b[Z'); | |
| expect(capturedUIState.embeddedShellFocused).toBe(false); | |
| unmount(); | |
| }); | |
| it('should auto-unfocus when activePtyId becomes null', async () => { | |
| // Start with active pty and focused | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| activePtyId: 1, | |
| }); | |
| const renderResult = await act(async () => render(getAppContainer())); | |
| await act(async () => { | |
| vi.advanceTimersByTime(0); | |
| }); | |
| // Focus it | |
| act(() => { | |
| renderResult.stdin.write('\t'); | |
| }); | |
| expect(capturedUIState.embeddedShellFocused).toBe(true); | |
| // Now mock activePtyId becoming null | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| activePtyId: null, | |
| }); | |
| // Rerender to trigger useEffect | |
| await act(async () => { | |
| renderResult.rerender(getAppContainer()); | |
| }); | |
| expect(capturedUIState.embeddedShellFocused).toBe(false); | |
| renderResult.unmount(); | |
| }); | |
| it('should focus background shell on Tab when already visible (not toggle it off)', async () => { | |
| const mockToggleBackgroundTask = vi.fn(); | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| activePtyId: null, | |
| isBackgroundTaskVisible: true, | |
| backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]), | |
| toggleBackgroundTasks: mockToggleBackgroundTask, | |
| }); | |
| await setupKeypressTest(); | |
| // Initially not focused | |
| expect(capturedUIState.embeddedShellFocused).toBe(false); | |
| // Press Tab | |
| pressKey('\t'); | |
| // Should be focused | |
| expect(capturedUIState.embeddedShellFocused).toBe(true); | |
| // Should NOT have toggled (closed) the shell | |
| expect(mockToggleBackgroundTask).not.toHaveBeenCalled(); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Background Shell Toggling (CTRL+B)', () => { | |
| it('should toggle background shell on Ctrl+B even if visible but not focused', async () => { | |
| const mockToggleBackgroundTask = vi.fn(); | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| activePtyId: null, | |
| isBackgroundTaskVisible: true, | |
| backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]), | |
| toggleBackgroundTasks: mockToggleBackgroundTask, | |
| }); | |
| await setupKeypressTest(); | |
| // Initially not focused, but visible | |
| expect(capturedUIState.embeddedShellFocused).toBe(false); | |
| // Press Ctrl+B | |
| pressKey('\x02'); | |
| // Should have toggled (closed) the shell | |
| expect(mockToggleBackgroundTask).toHaveBeenCalled(); | |
| // Should be unfocused | |
| expect(capturedUIState.embeddedShellFocused).toBe(false); | |
| unmount(); | |
| }); | |
| it('should show and focus background shell on Ctrl+B if hidden', async () => { | |
| const mockToggleBackgroundTask = vi.fn(); | |
| const geminiStreamMock = { | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| activePtyId: null, | |
| isBackgroundTaskVisible: false, | |
| backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]), | |
| toggleBackgroundTasks: mockToggleBackgroundTask, | |
| }; | |
| mockedUseGeminiStream.mockReturnValue(geminiStreamMock); | |
| await setupKeypressTest(); | |
| // Update the mock state when toggled to simulate real behavior | |
| mockToggleBackgroundTask.mockImplementation(() => { | |
| geminiStreamMock.isBackgroundTaskVisible = true; | |
| }); | |
| // Press Ctrl+B | |
| pressKey('\x02'); | |
| // Should have toggled (shown) the shell | |
| expect(mockToggleBackgroundTask).toHaveBeenCalled(); | |
| // Should be focused | |
| expect(capturedUIState.embeddedShellFocused).toBe(true); | |
| unmount(); | |
| }); | |
| }); | |
| }); | |
| describe('Shortcuts Help Visibility', () => { | |
| let handleGlobalKeypress: (key: Key) => boolean; | |
| let mockedUseKeypress: Mock; | |
| let rerender: () => void; | |
| let unmount: () => void; | |
| const setupShortcutsVisibilityTest = async () => { | |
| const renderResult = await act(async () => renderAppContainer()); | |
| await act(async () => { | |
| vi.advanceTimersByTime(0); | |
| }); | |
| rerender = () => renderResult.rerender(getAppContainer()); | |
| unmount = renderResult.unmount; | |
| }; | |
| const pressKey = (key: Partial<Key>) => { | |
| act(() => { | |
| handleGlobalKeypress({ | |
| name: 'r', | |
| shift: false, | |
| alt: false, | |
| ctrl: false, | |
| cmd: false, | |
| insertable: false, | |
| sequence: '', | |
| ...key, | |
| } as Key); | |
| }); | |
| rerender(); | |
| }; | |
| beforeEach(() => { | |
| mockedUseKeypress = vi.spyOn(useKeypressModule, 'useKeypress') as Mock; | |
| mockedUseKeypress.mockImplementation( | |
| (callback: (key: Key) => boolean, options: { isActive: boolean }) => { | |
| // AppContainer registers multiple keypress handlers; capture only | |
| // active handlers so inactive copy-mode handler doesn't override. | |
| if (options?.isActive) { | |
| handleGlobalKeypress = callback; | |
| } | |
| }, | |
| ); | |
| vi.useFakeTimers(); | |
| }); | |
| afterEach(() => { | |
| mockedUseKeypress.mockRestore(); | |
| vi.useRealTimers(); | |
| vi.restoreAllMocks(); | |
| }); | |
| it('dismisses shortcuts help when a registered hotkey is pressed', async () => { | |
| await setupShortcutsVisibilityTest(); | |
| act(() => { | |
| capturedUIActions.setShortcutsHelpVisible(true); | |
| }); | |
| rerender(); | |
| expect(capturedUIState.shortcutsHelpVisible).toBe(true); | |
| pressKey({ name: 'r', ctrl: true, sequence: '\x12' }); // Ctrl+R | |
| expect(capturedUIState.shortcutsHelpVisible).toBe(false); | |
| unmount(); | |
| }); | |
| it('dismisses shortcuts help when streaming starts', async () => { | |
| await setupShortcutsVisibilityTest(); | |
| act(() => { | |
| capturedUIActions.setShortcutsHelpVisible(true); | |
| }); | |
| rerender(); | |
| expect(capturedUIState.shortcutsHelpVisible).toBe(true); | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| streamingState: 'responding', | |
| }); | |
| await act(async () => { | |
| rerender(); | |
| }); | |
| expect(capturedUIState.shortcutsHelpVisible).toBe(false); | |
| unmount(); | |
| }); | |
| it('dismisses shortcuts help when action-required confirmation appears', async () => { | |
| await setupShortcutsVisibilityTest(); | |
| act(() => { | |
| capturedUIActions.setShortcutsHelpVisible(true); | |
| }); | |
| rerender(); | |
| expect(capturedUIState.shortcutsHelpVisible).toBe(true); | |
| mockedUseSlashCommandProcessor.mockReturnValue({ | |
| handleSlashCommand: vi.fn(), | |
| slashCommands: [], | |
| pendingHistoryItems: [], | |
| commandContext: {}, | |
| shellConfirmationRequest: null, | |
| confirmationRequest: { | |
| prompt: 'Confirm this action?', | |
| onConfirm: vi.fn(), | |
| }, | |
| }); | |
| await act(async () => { | |
| rerender(); | |
| }); | |
| expect(capturedUIState.shortcutsHelpVisible).toBe(false); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Copy Mode (F9)', () => { | |
| let rerender: () => void; | |
| let unmount: () => void; | |
| let stdin: Awaited<ReturnType<typeof render>>['stdin']; | |
| const setupCopyModeTest = async ( | |
| isAlternateMode = false, | |
| childHandler?: Mock, | |
| ) => { | |
| vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false); | |
| vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue( | |
| isAlternateMode, | |
| ); | |
| // Update settings for this test run | |
| const testSettings = createMockSettings({ | |
| ui: { useAlternateBuffer: isAlternateMode }, | |
| }); | |
| function TestChild() { | |
| useKeypress(childHandler || (() => {}), { | |
| isActive: !!childHandler, | |
| priority: true, | |
| }); | |
| return null; | |
| } | |
| const getTree = (settings: LoadedSettings) => ( | |
| <SettingsContext.Provider value={settings}> | |
| <KeypressProvider config={mockConfig}> | |
| <OverflowProvider> | |
| <AppContainer | |
| config={mockConfig} | |
| version="1.0.0" | |
| initializationResult={mockInitResult} | |
| /> | |
| <TestChild /> | |
| </OverflowProvider> | |
| </KeypressProvider> | |
| </SettingsContext.Provider> | |
| ); | |
| const renderResult = await act(async () => render(getTree(testSettings))); | |
| stdin = renderResult.stdin; | |
| await act(async () => { | |
| vi.advanceTimersByTime(0); | |
| }); | |
| rerender = () => renderResult.rerender(getTree(testSettings)); | |
| unmount = renderResult.unmount; | |
| }; | |
| beforeEach(() => { | |
| mocks.mockStdout.write.mockClear(); | |
| (disableMouseEvents as import('vitest').Mock).mockClear(); | |
| vi.useFakeTimers(); | |
| }); | |
| afterEach(() => { | |
| vi.useRealTimers(); | |
| vi.restoreAllMocks(); | |
| }); | |
| describe.each([ | |
| { | |
| isAlternateMode: false, | |
| shouldEnable: false, | |
| modeName: 'Normal Mode', | |
| }, | |
| { | |
| isAlternateMode: true, | |
| shouldEnable: true, | |
| modeName: 'Alternate Buffer Mode', | |
| }, | |
| ])('$modeName', ({ isAlternateMode, shouldEnable }) => { | |
| it(`should ${shouldEnable ? 'toggle' : 'NOT toggle'} mouse off when F9 is pressed`, async () => { | |
| await setupCopyModeTest(isAlternateMode); | |
| mocks.mockStdout.write.mockClear(); // Clear initial enable call | |
| (disableMouseEvents as import('vitest').Mock).mockClear(); | |
| act(() => { | |
| stdin.write('\x1b[20~'); // F9 | |
| }); | |
| rerender(); | |
| if (shouldEnable) { | |
| expect(disableMouseEvents).toHaveBeenCalled(); | |
| } else { | |
| expect(disableMouseEvents).not.toHaveBeenCalled(); | |
| } | |
| unmount(); | |
| }); | |
| if (shouldEnable) { | |
| it('should toggle mouse back on when F9 is pressed again', async () => { | |
| await setupCopyModeTest(isAlternateMode); | |
| (writeToStdout as Mock).mockClear(); | |
| // Turn it on (disable mouse) | |
| act(() => { | |
| stdin.write('\x1b[20~'); // F9 | |
| }); | |
| rerender(); | |
| expect(disableMouseEvents).toHaveBeenCalled(); | |
| // Turn it off (enable mouse) | |
| act(() => { | |
| stdin.write('a'); // Any key should exit copy mode | |
| }); | |
| rerender(); | |
| expect(enableMouseEvents).toHaveBeenCalled(); | |
| unmount(); | |
| }); | |
| it('should exit copy mode on non-scroll key press', async () => { | |
| await setupCopyModeTest(isAlternateMode); | |
| // Enter copy mode | |
| act(() => { | |
| stdin.write('\x1b[20~'); // F9 | |
| }); | |
| rerender(); | |
| (writeToStdout as Mock).mockClear(); | |
| // Press any other key | |
| act(() => { | |
| stdin.write('a'); | |
| }); | |
| rerender(); | |
| // Should have re-enabled mouse | |
| expect(enableMouseEvents).toHaveBeenCalled(); | |
| unmount(); | |
| }); | |
| it('should not exit copy mode on PageDown and should pass it through', async () => { | |
| const childHandler = vi.fn().mockReturnValue(false); | |
| await setupCopyModeTest(true, childHandler); | |
| // Enter copy mode | |
| act(() => { | |
| stdin.write('\x13'); // Ctrl+S | |
| }); | |
| rerender(); | |
| expect(disableMouseEvents).toHaveBeenCalled(); | |
| childHandler.mockClear(); | |
| (enableMouseEvents as Mock).mockClear(); | |
| // PageDown should be passed through to lower-priority handlers. | |
| act(() => { | |
| stdin.write('\x1b[6~'); | |
| }); | |
| rerender(); | |
| expect(enableMouseEvents).not.toHaveBeenCalled(); | |
| expect(childHandler).toHaveBeenCalled(); | |
| expect(childHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ name: 'pagedown' }), | |
| ); | |
| unmount(); | |
| }); | |
| it('should not exit copy mode on Shift+Down and should pass it through', async () => { | |
| const childHandler = vi.fn().mockReturnValue(false); | |
| await setupCopyModeTest(true, childHandler); | |
| // Enter copy mode | |
| act(() => { | |
| stdin.write('\x13'); // Ctrl+S | |
| }); | |
| rerender(); | |
| expect(disableMouseEvents).toHaveBeenCalled(); | |
| childHandler.mockClear(); | |
| (enableMouseEvents as Mock).mockClear(); | |
| act(() => { | |
| stdin.write('\x1b[1;2B'); // Shift+Down | |
| }); | |
| rerender(); | |
| expect(enableMouseEvents).not.toHaveBeenCalled(); | |
| expect(childHandler).toHaveBeenCalled(); | |
| expect(childHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ name: 'down', shift: true }), | |
| ); | |
| unmount(); | |
| }); | |
| it('should have higher priority than other priority listeners when enabled', async () => { | |
| // 1. Initial state with a child component's priority listener (already subscribed) | |
| // It should NOT handle Ctrl+S so we can enter copy mode. | |
| const childHandler = vi.fn().mockReturnValue(false); | |
| await setupCopyModeTest(true, childHandler); | |
| // 2. Enter copy mode | |
| act(() => { | |
| stdin.write('\x1b[20~'); // F9 | |
| }); | |
| rerender(); | |
| // 3. Verify we are in copy mode | |
| expect(disableMouseEvents).toHaveBeenCalled(); | |
| // 4. Press any key | |
| childHandler.mockClear(); | |
| // Now childHandler should return true for other keys, simulating a greedy listener | |
| childHandler.mockReturnValue(true); | |
| act(() => { | |
| stdin.write('a'); | |
| }); | |
| rerender(); | |
| // 5. Verify that the exit handler took priority and childHandler was NOT called | |
| expect(childHandler).not.toHaveBeenCalled(); | |
| expect(enableMouseEvents).toHaveBeenCalled(); | |
| unmount(); | |
| }); | |
| } | |
| }); | |
| }); | |
| describe('Model Dialog Integration', () => { | |
| it('should provide isModelDialogOpen in the UIStateContext', async () => { | |
| mockedUseModelCommand.mockReturnValue({ | |
| isModelDialogOpen: true, | |
| openModelDialog: vi.fn(), | |
| closeModelDialog: vi.fn(), | |
| }); | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| expect(capturedUIState.isModelDialogOpen).toBe(true); | |
| unmount(); | |
| }); | |
| it('should provide model dialog actions in the UIActionsContext', async () => { | |
| const mockCloseModelDialog = vi.fn(); | |
| mockedUseModelCommand.mockReturnValue({ | |
| isModelDialogOpen: false, | |
| openModelDialog: vi.fn(), | |
| closeModelDialog: mockCloseModelDialog, | |
| }); | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| // Verify that the actions are correctly passed through context | |
| act(() => { | |
| capturedUIActions.closeModelDialog(); | |
| }); | |
| expect(mockCloseModelDialog).toHaveBeenCalled(); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Agent Configuration Dialog Integration', () => { | |
| it('should initialize with dialog closed and no agent selected', async () => { | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| expect(capturedUIState.isAgentConfigDialogOpen).toBe(false); | |
| expect(capturedUIState.selectedAgentName).toBeUndefined(); | |
| expect(capturedUIState.selectedAgentDisplayName).toBeUndefined(); | |
| expect(capturedUIState.selectedAgentDefinition).toBeUndefined(); | |
| unmount(); | |
| }); | |
| it('should update state when openAgentConfigDialog is called', async () => { | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| const agentDefinition = { name: 'test-agent' }; | |
| act(() => { | |
| capturedUIActions.openAgentConfigDialog( | |
| 'test-agent', | |
| 'Test Agent', | |
| agentDefinition as unknown as AgentDefinition, | |
| ); | |
| }); | |
| expect(capturedUIState.isAgentConfigDialogOpen).toBe(true); | |
| expect(capturedUIState.selectedAgentName).toBe('test-agent'); | |
| expect(capturedUIState.selectedAgentDisplayName).toBe('Test Agent'); | |
| expect(capturedUIState.selectedAgentDefinition).toEqual(agentDefinition); | |
| unmount(); | |
| }); | |
| it('should clear state when closeAgentConfigDialog is called', async () => { | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| const agentDefinition = { name: 'test-agent' }; | |
| act(() => { | |
| capturedUIActions.openAgentConfigDialog( | |
| 'test-agent', | |
| 'Test Agent', | |
| agentDefinition as unknown as AgentDefinition, | |
| ); | |
| }); | |
| expect(capturedUIState.isAgentConfigDialogOpen).toBe(true); | |
| act(() => { | |
| capturedUIActions.closeAgentConfigDialog(); | |
| }); | |
| expect(capturedUIState.isAgentConfigDialogOpen).toBe(false); | |
| expect(capturedUIState.selectedAgentName).toBeUndefined(); | |
| expect(capturedUIState.selectedAgentDisplayName).toBeUndefined(); | |
| expect(capturedUIState.selectedAgentDefinition).toBeUndefined(); | |
| unmount(); | |
| }); | |
| }); | |
| describe('CoreEvents Integration', () => { | |
| it('subscribes to UserFeedback and drains backlog on mount', async () => { | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| expect(mockCoreEvents.on).toHaveBeenCalledWith( | |
| CoreEvent.UserFeedback, | |
| expect.any(Function), | |
| ); | |
| expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1); | |
| unmount(); | |
| }); | |
| it('unsubscribes from UserFeedback on unmount', async () => { | |
| let unmount: () => void; | |
| await act(async () => { | |
| const result = await renderAppContainer(); | |
| unmount = result.unmount; | |
| }); | |
| await waitFor(() => expect(capturedUIState).toBeTruthy()); | |
| unmount!(); | |
| expect(mockCoreEvents.off).toHaveBeenCalledWith( | |
| CoreEvent.UserFeedback, | |
| expect.any(Function), | |
| ); | |
| }); | |
| it('adds history item when UserFeedback event is received', async () => { | |
| let unmount: () => void; | |
| await act(async () => { | |
| const result = await renderAppContainer(); | |
| unmount = result.unmount; | |
| }); | |
| await waitFor(() => expect(capturedUIState).toBeTruthy()); | |
| // Get the registered handler | |
| const handler = mockCoreEvents.on.mock.calls.find( | |
| (call: unknown[]) => call[0] === CoreEvent.UserFeedback, | |
| )?.[1]; | |
| expect(handler).toBeDefined(); | |
| // Simulate an event | |
| const payload: UserFeedbackPayload = { | |
| severity: 'error', | |
| message: 'Test error message', | |
| }; | |
| act(() => { | |
| handler(payload); | |
| }); | |
| expect(mockedUseHistory().addItem).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| type: 'error', | |
| text: 'Test error message', | |
| }), | |
| expect.any(Number), | |
| ); | |
| unmount!(); | |
| }); | |
| it('calls app.rerender() when ExternalEditorClosed event is received in terminalBuffer mode', async () => { | |
| vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(true); | |
| vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(false); | |
| vi.spyOn(mockConfig, 'getScreenReader').mockReturnValue(false); | |
| mocks.mockRerender.mockClear(); | |
| let unmount: () => void; | |
| await act(async () => { | |
| const result = await renderAppContainer(); | |
| unmount = result.unmount; | |
| }); | |
| await waitFor(() => expect(capturedUIState).toBeTruthy()); | |
| const handler = mockCoreEvents.on.mock.calls.find( | |
| (call: unknown[]) => call[0] === CoreEvent.ExternalEditorClosed, | |
| )?.[1]; | |
| expect(handler).toBeDefined(); | |
| act(() => { | |
| handler(); | |
| }); | |
| expect(mocks.mockRerender).toHaveBeenCalledTimes(1); | |
| unmount!(); | |
| }); | |
| it('updates currentModel when ModelChanged event is received', async () => { | |
| // Arrange: Mock initial model | |
| vi.spyOn(mockConfig, 'getModel').mockReturnValue('initial-model'); | |
| let unmount: () => void; | |
| await act(async () => { | |
| const result = await renderAppContainer(); | |
| unmount = result.unmount; | |
| }); | |
| await waitFor(() => { | |
| expect(capturedUIState?.currentModel).toBe('initial-model'); | |
| }); | |
| // Get the registered handler for ModelChanged | |
| const handler = mockCoreEvents.on.mock.calls.find( | |
| (call: unknown[]) => call[0] === CoreEvent.ModelChanged, | |
| )?.[1]; | |
| expect(handler).toBeDefined(); | |
| // Act: Simulate ModelChanged event | |
| // Update config mock to return new model since the handler reads from config | |
| vi.spyOn(mockConfig, 'getModel').mockReturnValue('new-model'); | |
| act(() => { | |
| handler({ model: 'new-model' }); | |
| }); | |
| // Assert: Verify model is updated | |
| await waitFor(() => { | |
| expect(capturedUIState.currentModel).toBe('new-model'); | |
| }); | |
| unmount!(); | |
| }); | |
| it('provides activeHooks from useHookDisplayState', async () => { | |
| const mockHooks = [{ name: 'hook1', eventName: 'event1' }]; | |
| mockedUseHookDisplayState.mockReturnValue(mockHooks); | |
| let unmount: () => void; | |
| await act(async () => { | |
| const result = await renderAppContainer(); | |
| unmount = result.unmount; | |
| }); | |
| await waitFor(() => expect(capturedUIState).toBeTruthy()); | |
| expect(capturedUIState.activeHooks).toEqual(mockHooks); | |
| unmount!(); | |
| }); | |
| it('handles consent request events', async () => { | |
| let unmount: () => void; | |
| await act(async () => { | |
| const result = await renderAppContainer(); | |
| unmount = result.unmount; | |
| }); | |
| await waitFor(() => expect(capturedUIState).toBeTruthy()); | |
| const handler = mockCoreEvents.on.mock.calls.find( | |
| (call: unknown[]) => call[0] === CoreEvent.ConsentRequest, | |
| )?.[1]; | |
| expect(handler).toBeDefined(); | |
| const onConfirm = vi.fn(); | |
| const payload = { | |
| prompt: 'Do you consent?', | |
| onConfirm, | |
| }; | |
| act(() => { | |
| handler(payload); | |
| }); | |
| expect(capturedUIState.authConsentRequest).toBeDefined(); | |
| expect(capturedUIState.authConsentRequest?.prompt).toBe( | |
| 'Do you consent?', | |
| ); | |
| act(() => { | |
| capturedUIState.authConsentRequest?.onConfirm(true); | |
| }); | |
| expect(onConfirm).toHaveBeenCalledWith(true); | |
| expect(capturedUIState.authConsentRequest).toBeNull(); | |
| unmount!(); | |
| }); | |
| it('unsubscribes from ConsentRequest on unmount', async () => { | |
| let unmount: () => void; | |
| await act(async () => { | |
| const result = await renderAppContainer(); | |
| unmount = result.unmount; | |
| }); | |
| await waitFor(() => expect(capturedUIState).toBeTruthy()); | |
| unmount!(); | |
| expect(mockCoreEvents.off).toHaveBeenCalledWith( | |
| CoreEvent.ConsentRequest, | |
| expect.any(Function), | |
| ); | |
| }); | |
| }); | |
| describe('Banner Text', () => { | |
| it('should render placeholder banner text for USE_GEMINI auth type', async () => { | |
| const config = makeFakeConfig(); | |
| vi.spyOn(config, 'getContentGeneratorConfig').mockReturnValue({ | |
| authType: AuthType.USE_GEMINI, | |
| apiKey: 'fake-key', | |
| }); | |
| let unmount: () => void; | |
| await act(async () => { | |
| const result = await renderAppContainer(); | |
| unmount = result.unmount; | |
| }); | |
| await waitFor(() => { | |
| expect(capturedUIState.bannerData.defaultText).toBeDefined(); | |
| unmount!(); | |
| }); | |
| }); | |
| }); | |
| describe('onCancelSubmit Behavior', () => { | |
| let mockSetText: Mock; | |
| // Helper to extract arguments from the useGeminiStream hook call | |
| // This isolates the positional argument dependency to a single location | |
| const extractUseGeminiStreamArgs = (args: unknown[]) => ({ | |
| onCancelSubmit: args[13] as (shouldRestorePrompt?: boolean) => void, | |
| }); | |
| beforeEach(() => { | |
| mockSetText = vi.fn(); | |
| mockedUseTextBuffer.mockReturnValue({ | |
| text: '', | |
| setText: mockSetText, | |
| }); | |
| }); | |
| it('preserves buffer when cancelling, even if empty (user is in control)', async () => { | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| const { onCancelSubmit } = extractUseGeminiStreamArgs( | |
| mockedUseGeminiStream.mock.lastCall!, | |
| ); | |
| act(() => { | |
| onCancelSubmit(false); | |
| }); | |
| // Should NOT modify buffer when cancelling - user is in control | |
| expect(mockSetText).not.toHaveBeenCalled(); | |
| unmount(); | |
| }); | |
| it('preserves prompt text when cancelling streaming, even if same as last message (regression test for issue #13387)', async () => { | |
| // Mock buffer with text that user typed while streaming (same as last message) | |
| const promptText = 'What is Python?'; | |
| mockedUseTextBuffer.mockReturnValue({ | |
| text: promptText, | |
| setText: mockSetText, | |
| }); | |
| // Mock input history with same message | |
| mockedUseInputHistoryStore.mockReturnValue({ | |
| inputHistory: [promptText], | |
| addInput: vi.fn(), | |
| initializeFromLogger: vi.fn(), | |
| }); | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| const { onCancelSubmit } = extractUseGeminiStreamArgs( | |
| mockedUseGeminiStream.mock.lastCall!, | |
| ); | |
| act(() => { | |
| // Simulate Escape key cancelling streaming (shouldRestorePrompt=false) | |
| onCancelSubmit(false); | |
| }); | |
| // Should NOT call setText - prompt should be preserved regardless of content | |
| expect(mockSetText).not.toHaveBeenCalled(); | |
| unmount(); | |
| }); | |
| it('restores the prompt when onCancelSubmit is called with shouldRestorePrompt=true (or undefined)', async () => { | |
| // Mock useInputHistoryStore to provide input history | |
| mockedUseInputHistoryStore.mockReturnValue({ | |
| inputHistory: ['previous message'], | |
| addInput: vi.fn(), | |
| initializeFromLogger: vi.fn(), | |
| }); | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| expect(capturedInputState.userMessages).toContain('previous message'); | |
| const { onCancelSubmit } = extractUseGeminiStreamArgs( | |
| mockedUseGeminiStream.mock.lastCall!, | |
| ); | |
| await act(async () => { | |
| onCancelSubmit(true); | |
| }); | |
| expect(mockSetText).toHaveBeenCalledWith('previous message'); | |
| unmount(); | |
| }); | |
| it('input history is independent from conversation history (survives /clear)', async () => { | |
| // This test verifies that input history (used for up-arrow navigation) is maintained | |
| // separately from conversation history and survives /clear operations. | |
| const mockAddInput = vi.fn(); | |
| mockedUseInputHistoryStore.mockReturnValue({ | |
| inputHistory: ['first prompt', 'second prompt'], | |
| addInput: mockAddInput, | |
| initializeFromLogger: vi.fn(), | |
| }); | |
| const { rerender, unmount } = await act(async () => renderAppContainer()); | |
| // Verify userMessages is populated from inputHistory | |
| expect(capturedInputState.userMessages).toContain('first prompt'); | |
| expect(capturedInputState.userMessages).toContain('second prompt'); | |
| // Clear the conversation history (simulating /clear command) | |
| const mockClearItems = vi.fn(); | |
| mockedUseHistory.mockReturnValue({ | |
| history: [], | |
| addItem: vi.fn(), | |
| updateItem: vi.fn(), | |
| clearItems: mockClearItems, | |
| loadHistory: vi.fn(), | |
| }); | |
| await act(async () => { | |
| // Rerender to apply the new mock. | |
| rerender(getAppContainer()); | |
| }); | |
| // Verify that userMessages still contains the input history | |
| // (it should not be affected by clearing conversation history) | |
| expect(capturedInputState.userMessages).toContain('first prompt'); | |
| expect(capturedInputState.userMessages).toContain('second prompt'); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Regression Tests', () => { | |
| it('does not refresh static on startup if banner text is empty', async () => { | |
| // Mock banner text to be empty strings | |
| vi.spyOn(mockConfig, 'getBannerTextNoCapacityIssues').mockResolvedValue( | |
| '', | |
| ); | |
| vi.spyOn(mockConfig, 'getBannerTextCapacityIssues').mockResolvedValue(''); | |
| // Clear previous calls | |
| mocks.mockStdout.write.mockClear(); | |
| (disableMouseEvents as import('vitest').Mock).mockClear(); | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| // Allow async effects to run | |
| expect(capturedUIState).toBeTruthy(); | |
| // Wait for fetchBannerTexts to complete | |
| await act(async () => { | |
| await new Promise((resolve) => setTimeout(resolve, 100)); | |
| }); | |
| // Check that clearTerminal was NOT written to stdout | |
| const clearTerminalCalls = mocks.mockStdout.write.mock.calls.filter( | |
| (call: unknown[]) => call[0] === ansiEscapes.clearTerminal, | |
| ); | |
| expect(clearTerminalCalls).toHaveLength(0); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Submission Handling', () => { | |
| it('resets expansion state on submission when not in alternate buffer', async () => { | |
| const { checkPermissions } = await import( | |
| './hooks/atCommandProcessor.js' | |
| ); | |
| vi.mocked(checkPermissions).mockResolvedValue([]); | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| settings: createMockSettings({ ui: { useAlternateBuffer: false } }), | |
| }), | |
| ); | |
| expect(capturedUIActions).toBeTruthy(); | |
| // Expand first | |
| act(() => capturedUIActions.setConstrainHeight(false)); | |
| expect(capturedUIState.constrainHeight).toBe(false); | |
| // Reset mock stdout to clear any initial writes | |
| mocks.mockStdout.write.mockClear(); | |
| (disableMouseEvents as import('vitest').Mock).mockClear(); | |
| // Submit | |
| await act(async () => capturedUIActions.handleFinalSubmit('test prompt')); | |
| // Should be reset | |
| expect(capturedUIState.constrainHeight).toBe(true); | |
| unmount(); | |
| }); | |
| it('resets expansion state on submission when in alternate buffer without clearing terminal', async () => { | |
| const { checkPermissions } = await import( | |
| './hooks/atCommandProcessor.js' | |
| ); | |
| vi.mocked(checkPermissions).mockResolvedValue([]); | |
| vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false); | |
| vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| settings: createMockSettings({ ui: { useAlternateBuffer: true } }), | |
| }), | |
| ); | |
| expect(capturedUIActions).toBeTruthy(); | |
| // Expand first | |
| act(() => capturedUIActions.setConstrainHeight(false)); | |
| expect(capturedUIState.constrainHeight).toBe(false); | |
| // Reset mock stdout | |
| mocks.mockStdout.write.mockClear(); | |
| (disableMouseEvents as import('vitest').Mock).mockClear(); | |
| // Submit | |
| await act(async () => capturedUIActions.handleFinalSubmit('test prompt')); | |
| // Should be reset | |
| expect(capturedUIState.constrainHeight).toBe(true); | |
| // Should NOT refresh static's clearTerminal in alternate buffer | |
| expect(mocks.mockStdout.write).not.toHaveBeenCalledWith( | |
| ansiEscapes.clearTerminal, | |
| ); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Overflow Hint Handling', () => { | |
| beforeEach(() => { | |
| vi.useFakeTimers(); | |
| }); | |
| afterEach(() => { | |
| vi.useRealTimers(); | |
| }); | |
| it('should set showIsExpandableHint when overflow occurs in Standard Mode and hides after 10s', async () => { | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); | |
| // Trigger overflow | |
| act(() => { | |
| capturedOverflowActions.addOverflowingId('test-id'); | |
| }); | |
| await waitFor(() => { | |
| // Should show hint because we are in Standard Mode (default settings) and have overflow | |
| expect(capturedUIState.showIsExpandableHint).toBe(true); | |
| }); | |
| // Advance just before the timeout | |
| act(() => { | |
| vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100); | |
| }); | |
| expect(capturedUIState.showIsExpandableHint).toBe(true); | |
| // Advance to hit the timeout mark | |
| act(() => { | |
| vi.advanceTimersByTime(100); | |
| }); | |
| await waitFor(() => { | |
| expect(capturedUIState.showIsExpandableHint).toBe(false); | |
| }); | |
| unmount(); | |
| }); | |
| it('resets the hint timer when a new component overflows (overflowingIdsSize increases)', async () => { | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); | |
| // 1. Trigger first overflow | |
| act(() => { | |
| capturedOverflowActions.addOverflowingId('test-id-1'); | |
| }); | |
| await waitFor(() => { | |
| expect(capturedUIState.showIsExpandableHint).toBe(true); | |
| }); | |
| // 2. Advance half the duration | |
| act(() => { | |
| vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); | |
| }); | |
| expect(capturedUIState.showIsExpandableHint).toBe(true); | |
| // 3. Trigger second overflow (this should reset the timer) | |
| act(() => { | |
| capturedOverflowActions.addOverflowingId('test-id-2'); | |
| }); | |
| // Advance by 1ms to allow the OverflowProvider's 0ms batching timeout to fire | |
| // and flush the state update to AppContainer, triggering the reset. | |
| act(() => { | |
| vi.advanceTimersByTime(1); | |
| }); | |
| await waitFor(() => { | |
| expect(capturedUIState.showIsExpandableHint).toBe(true); | |
| }); | |
| // 4. Advance enough that the ORIGINAL timer would have expired | |
| // Subtracting 1ms since we advanced it above to flush the state. | |
| act(() => { | |
| vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 100 - 1); | |
| }); | |
| // The hint should STILL be visible because the timer reset at step 3 | |
| expect(capturedUIState.showIsExpandableHint).toBe(true); | |
| // 5. Advance to the end of the NEW timer | |
| act(() => { | |
| vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 100); | |
| }); | |
| await waitFor(() => { | |
| expect(capturedUIState.showIsExpandableHint).toBe(false); | |
| }); | |
| unmount(); | |
| }); | |
| it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => { | |
| const { stdin, unmount } = await act(async () => renderAppContainer()); | |
| await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); | |
| // Initial state is constrainHeight = true | |
| expect(capturedUIState.constrainHeight).toBe(true); | |
| // Trigger overflow so the hint starts showing | |
| act(() => { | |
| capturedOverflowActions.addOverflowingId('test-id'); | |
| }); | |
| await waitFor(() => { | |
| expect(capturedUIState.showIsExpandableHint).toBe(true); | |
| }); | |
| // Advance half the duration | |
| act(() => { | |
| vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); | |
| }); | |
| expect(capturedUIState.showIsExpandableHint).toBe(true); | |
| // Simulate Ctrl+O | |
| act(() => { | |
| stdin.write('\x0f'); // \x0f is Ctrl+O | |
| }); | |
| // constrainHeight should toggle | |
| expect(capturedUIState.constrainHeight).toBe(false); | |
| // Advance enough that the original timer would have expired if it hadn't reset | |
| act(() => { | |
| vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 1000); | |
| }); | |
| // We expect it to still be true because Ctrl+O should have reset the timer | |
| expect(capturedUIState.showIsExpandableHint).toBe(true); | |
| // Advance remaining time to reach the new timeout | |
| act(() => { | |
| vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 1000); | |
| }); | |
| await waitFor(() => { | |
| expect(capturedUIState.showIsExpandableHint).toBe(false); | |
| }); | |
| unmount(); | |
| }); | |
| it('toggles Ctrl+O multiple times and verifies the hint disappears exactly after the last toggle', async () => { | |
| const { stdin, unmount } = await act(async () => renderAppContainer()); | |
| await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); | |
| // Initial state is constrainHeight = true | |
| expect(capturedUIState.constrainHeight).toBe(true); | |
| // Trigger overflow so the hint starts showing | |
| act(() => { | |
| capturedOverflowActions.addOverflowingId('test-id'); | |
| }); | |
| await waitFor(() => { | |
| expect(capturedUIState.showIsExpandableHint).toBe(true); | |
| }); | |
| // Advance half the duration | |
| act(() => { | |
| vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); | |
| }); | |
| expect(capturedUIState.showIsExpandableHint).toBe(true); | |
| // First toggle 'on' (expanded) | |
| act(() => { | |
| stdin.write('\x0f'); // Ctrl+O | |
| }); | |
| expect(capturedUIState.constrainHeight).toBe(false); | |
| // Wait 1 second | |
| act(() => { | |
| vi.advanceTimersByTime(1000); | |
| }); | |
| expect(capturedUIState.showIsExpandableHint).toBe(true); | |
| // Second toggle 'off' (collapsed) | |
| act(() => { | |
| stdin.write('\x0f'); // Ctrl+O | |
| }); | |
| expect(capturedUIState.constrainHeight).toBe(true); | |
| // Wait 1 second | |
| act(() => { | |
| vi.advanceTimersByTime(1000); | |
| }); | |
| expect(capturedUIState.showIsExpandableHint).toBe(true); | |
| // Third toggle 'on' (expanded) | |
| act(() => { | |
| stdin.write('\x0f'); // Ctrl+O | |
| }); | |
| expect(capturedUIState.constrainHeight).toBe(false); | |
| // Now we wait just before the timeout from the LAST toggle. | |
| // It should still be true. | |
| act(() => { | |
| vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100); | |
| }); | |
| expect(capturedUIState.showIsExpandableHint).toBe(true); | |
| // Wait 0.1s more to hit exactly the timeout since the last toggle. | |
| // It should hide now. | |
| act(() => { | |
| vi.advanceTimersByTime(100); | |
| }); | |
| await waitFor(() => { | |
| expect(capturedUIState.showIsExpandableHint).toBe(false); | |
| }); | |
| unmount(); | |
| }); | |
| it('DOES set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => { | |
| const settingsWithAlternateBuffer = createMockSettings({ | |
| ui: { useAlternateBuffer: true }, | |
| }); | |
| vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false); | |
| vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); | |
| const { unmount } = await act(async () => | |
| renderAppContainer({ | |
| settings: settingsWithAlternateBuffer, | |
| }), | |
| ); | |
| await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); | |
| // Trigger overflow | |
| act(() => { | |
| capturedOverflowActions.addOverflowingId('test-id'); | |
| }); | |
| // Should NOW show hint because we are in Alternate Buffer Mode | |
| await waitFor(() => { | |
| expect(capturedUIState.showIsExpandableHint).toBe(true); | |
| }); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Permission Handling', () => { | |
| it('shows permission dialog when checkPermissions returns paths', async () => { | |
| const { checkPermissions } = await import( | |
| './hooks/atCommandProcessor.js' | |
| ); | |
| vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']); | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| expect(capturedUIActions).toBeTruthy(); | |
| await act(async () => | |
| capturedUIActions.handleFinalSubmit('read @file.txt'), | |
| ); | |
| expect(capturedUIState.permissionConfirmationRequest).not.toBeNull(); | |
| expect(capturedUIState.permissionConfirmationRequest?.files).toEqual([ | |
| '/test/file.txt', | |
| ]); | |
| unmount(); | |
| }); | |
| it.each([true, false])( | |
| 'handles permissions when allowed is %s', | |
| async (allowed) => { | |
| const { checkPermissions } = await import( | |
| './hooks/atCommandProcessor.js' | |
| ); | |
| vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']); | |
| const addReadOnlyPathSpy = vi.spyOn( | |
| mockConfig.getWorkspaceContext(), | |
| 'addReadOnlyPath', | |
| ); | |
| const { submitQuery } = mockedUseGeminiStream(); | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| expect(capturedUIActions).toBeTruthy(); | |
| await act(async () => | |
| capturedUIActions.handleFinalSubmit('read @file.txt'), | |
| ); | |
| await act(async () => | |
| capturedUIState.permissionConfirmationRequest?.onComplete({ | |
| allowed, | |
| }), | |
| ); | |
| if (allowed) { | |
| expect(addReadOnlyPathSpy).toHaveBeenCalledWith('/test/file.txt'); | |
| } else { | |
| expect(addReadOnlyPathSpy).not.toHaveBeenCalled(); | |
| } | |
| expect(submitQuery).toHaveBeenCalledWith('read @file.txt'); | |
| expect(capturedUIState.permissionConfirmationRequest).toBeNull(); | |
| unmount(); | |
| }, | |
| ); | |
| }); | |
| describe('Plan Mode Availability', () => { | |
| it('should allow plan mode when enabled and idle', async () => { | |
| vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(true); | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| pendingHistoryItems: [], | |
| }); | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| expect(capturedUIState).toBeTruthy(); | |
| expect(capturedUIState.allowPlanMode).toBe(true); | |
| unmount(); | |
| }); | |
| it('should NOT allow plan mode when disabled in config', async () => { | |
| vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(false); | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| pendingHistoryItems: [], | |
| }); | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| expect(capturedUIState).toBeTruthy(); | |
| expect(capturedUIState.allowPlanMode).toBe(false); | |
| unmount(); | |
| }); | |
| it('should NOT allow plan mode when streaming', async () => { | |
| vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(true); | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| streamingState: StreamingState.Responding, | |
| pendingHistoryItems: [], | |
| }); | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| expect(capturedUIState).toBeTruthy(); | |
| expect(capturedUIState.allowPlanMode).toBe(false); | |
| unmount(); | |
| }); | |
| it('should NOT allow plan mode when a tool is awaiting confirmation', async () => { | |
| vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(true); | |
| mockedUseGeminiStream.mockReturnValue({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| streamingState: StreamingState.Idle, | |
| pendingHistoryItems: [ | |
| { | |
| type: 'tool_group', | |
| tools: [ | |
| { | |
| name: 'test_tool', | |
| status: CoreToolCallStatus.AwaitingApproval, | |
| }, | |
| ], | |
| }, | |
| ], | |
| }); | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| expect(capturedUIState).toBeTruthy(); | |
| expect(capturedUIState.allowPlanMode).toBe(false); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Compression Queuing', () => { | |
| beforeEach(async () => { | |
| const { checkPermissions } = await import( | |
| './hooks/atCommandProcessor.js' | |
| ); | |
| vi.mocked(checkPermissions).mockResolvedValue([]); | |
| vi.spyOn(mockConfig, 'isModelSteeringEnabled').mockReturnValue(true); | |
| const actual = await vi.importActual('./hooks/useMessageQueue.js'); | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| const { useMessageQueue: realUseMessageQueue } = actual as any; | |
| mockedUseMessageQueue.mockImplementation(realUseMessageQueue); | |
| // Start compression by mocking pendingHistoryItems to include a pending compression | |
| mockedUseGeminiStream.mockImplementation(() => ({ | |
| ...DEFAULT_GEMINI_STREAM_MOCK, | |
| pendingHistoryItems: [ | |
| { | |
| type: MessageType.COMPRESSION, | |
| compression: { | |
| isPending: true, | |
| originalTokenCount: null, | |
| newTokenCount: null, | |
| compressionStatus: null, | |
| }, | |
| }, | |
| ], | |
| })); | |
| }); | |
| it('queues messages during compression instead of handling as steering hints', async () => { | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| // Verify state isolation | |
| expect(capturedUIState.streamingState).toBe(StreamingState.Idle); | |
| // Submit a message | |
| await act(async () => | |
| capturedUIActions.handleFinalSubmit('follow up message'), | |
| ); | |
| // Verify it was queued, not submitted as steering hint | |
| expect(capturedUIState.messageQueue).toContain('follow up message'); | |
| unmount(); | |
| }); | |
| it('executes slash commands immediately during compression', async () => { | |
| const { unmount } = await act(async () => renderAppContainer()); | |
| // Submit a slash command | |
| await act(async () => capturedUIActions.handleFinalSubmit('/help')); | |
| // Verify it was NOT queued | |
| expect(capturedUIState.messageQueue).not.toContain('/help'); | |
| unmount(); | |
| }); | |
| }); | |
| }); | |