Files
gemini-cli/packages/cli/src/ui/AppContainer.test.tsx

2983 lines
93 KiB
TypeScript

/**
* @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, persistentStateMock } from '../test-utils/render.js';
import { waitFor } from '../test-utils/async.js';
import { cleanup } from 'ink-testing-library';
import { act, useContext, type ReactElement } 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,
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
const mocks = vi.hoisted(() => ({
mockStdout: { write: vi.fn() },
}));
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 { mergeSettings, type LoadedSettings } from '../config/settings.js';
import type { InitializationResult } from '../core/initializer.js';
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
import {
UIActionsContext,
type UIActions,
} from './contexts/UIActionsContext.js';
import { KeypressProvider } from './contexts/KeypressContext.js';
// Mock useStdout to capture terminal title writes
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
useStdout: () => ({ stdout: mocks.mockStdout }),
measureElement: vi.fn(),
};
});
// Helper component will read the context values provided by AppContainer
// so we can assert against them in our tests.
let capturedUIState: UIState;
let capturedUIActions: UIActions;
function TestContextConsumer() {
capturedUIState = useContext(UIStateContext)!;
capturedUIActions = useContext(UIActionsContext)!;
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('./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('./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('./hooks/useTerminalTheme.js', () => ({
useTerminalTheme: vi.fn(),
}));
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.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 { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
import { useModelCommand } from './hooks/useModelCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useConsoleMessages } 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 { 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 { measureElement } from 'ink';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import {
ShellExecutionService,
writeToStdout,
enableMouseEvents,
disableMouseEvents,
} from '@google/gemini-cli-core';
import { type ExtensionManager } from '../config/extension-manager.js';
import { WARNING_PROMPT_DURATION_MS } from './constants.js';
describe('AppContainer State Management', () => {
let mockConfig: Config;
let mockSettings: LoadedSettings;
let mockInitResult: InitializationResult;
let mockExtensionManager: MockedObject<ExtensionManager>;
// Helper to generate the AppContainer JSX for render and rerender
const getAppContainer = ({
settings = mockSettings,
config = mockConfig,
version = '1.0.0',
initResult = mockInitResult,
startupWarnings,
resumedSessionData,
}: {
settings?: LoadedSettings;
config?: Config;
version?: string;
initResult?: InitializationResult;
startupWarnings?: string[];
resumedSessionData?: ResumedSessionData;
} = {}) => (
<SettingsContext.Provider value={settings}>
<KeypressProvider config={config}>
<AppContainer
config={config}
version={version}
initializationResult={initResult}
startupWarnings={startupWarnings}
resumedSessionData={resumedSessionData}
/>
</KeypressProvider>
</SettingsContext.Provider>
);
// Helper to render the AppContainer
const renderAppContainer = (props?: Parameters<typeof getAppContainer>[0]) =>
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 = useConsoleMessages 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 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 DEFAULT_GEMINI_STREAM_MOCK = {
streamingState: 'idle',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
handleApprovalModeChange: vi.fn(),
activePtyId: null,
loopDetectionConfirmationRequest: null,
backgroundShellCount: 0,
isBackgroundShellVisible: false,
toggleBackgroundShell: vi.fn(),
backgroundCurrentShell: vi.fn(),
backgroundShells: new Map(),
registerBackgroundShell: vi.fn(),
dismissBackgroundShell: vi.fn(),
};
beforeEach(() => {
persistentStateMock.reset();
vi.clearAllMocks();
mockIdeClient.getInstance.mockReturnValue(new Promise(() => {}));
// Initialize mock stdout for terminal title tests
mocks.mockStdout.write.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({
consoleMessages: [],
handleNewMessage: vi.fn(),
clearConsoleMessages: 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',
});
// Mock Config
mockConfig = makeFakeConfig();
// 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 = vi.mockObject({
getExtensions: vi.fn().mockReturnValue([]),
setRequestConsent: vi.fn(),
setRequestSetting: vi.fn(),
start: vi.fn(),
} as unknown as ExtensionManager);
vi.spyOn(mockConfig, 'getExtensionLoader').mockReturnValue(
mockExtensionManager,
);
// Mock LoadedSettings
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
mockSettings = {
merged: {
...defaultMergedSettings,
hideBanner: false,
hideFooter: false,
hideTips: false,
showMemoryUsage: false,
theme: 'default',
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: false,
hideWindowTitle: false,
useAlternateBuffer: false,
},
},
} as unknown as LoadedSettings;
// 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 () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
});
it('renders with startup warnings', async () => {
const startupWarnings = ['Warning 1', 'Warning 2'];
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ startupWarnings });
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
});
it('shows full UI details by default', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => {
expect(capturedUIState.cleanUiDetailsVisible).toBe(true);
});
unmount!();
});
it('starts in minimal UI mode when Focus UI preference is persisted', async () => {
persistentStateMock.get.mockReturnValueOnce(true);
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({
settings: mockSettings,
});
unmount = result.unmount;
});
await waitFor(() => {
expect(capturedUIState.cleanUiDetailsVisible).toBe(false);
});
expect(persistentStateMock.get).toHaveBeenCalledWith('focusUiEnabled');
unmount!();
});
});
describe('State Initialization', () => {
it('initializes with theme error from initialization result', async () => {
const initResultWithError = {
...mockInitResult,
themeError: 'Failed to load theme',
};
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({
initResult: initResultWithError,
});
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
});
it('handles debug mode state', () => {
const debugConfig = makeFakeConfig();
vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true);
expect(() => {
renderAppContainer({ config: debugConfig });
}).not.toThrow();
});
});
describe('Context Providers', () => {
it('provides AppContext with correct values', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ version: '2.0.0' });
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
// Should render and unmount cleanly
expect(() => unmount!()).not.toThrow();
});
it('provides UIStateContext with state management', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
});
it('provides UIActionsContext with action handlers', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
});
it('provides ConfigContext with config object', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
});
});
describe('Settings Integration', () => {
it('handles settings with all display options disabled', async () => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const settingsAllHidden = {
merged: {
...defaultMergedSettings,
hideBanner: true,
hideFooter: true,
hideTips: true,
showMemoryUsage: false,
},
} as unknown as LoadedSettings;
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ settings: settingsAllHidden });
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
});
it('handles settings with memory usage enabled', async () => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const settingsWithMemory = {
merged: {
...defaultMergedSettings,
hideBanner: false,
hideFooter: false,
hideTips: false,
showMemoryUsage: true,
},
} as unknown as LoadedSettings;
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ settings: settingsWithMemory });
unmount = result.unmount;
});
await waitFor(() => 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) => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ version });
unmount = result.unmount;
});
await waitFor(() => 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 } = renderAppContainer({ config: errorConfig });
unmount();
});
it('handles undefined settings gracefully', async () => {
const undefinedSettings = {
merged: mergeSettings({}, {}, {}, {}, true),
} as LoadedSettings;
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ settings: undefinedSettings });
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
});
});
describe('Provider Hierarchy', () => {
it('establishes correct provider nesting order', () => {
// This tests that all the context providers are properly nested
// and that the component tree can be built without circular dependencies
const { unmount } = renderAppContainer();
expect(() => unmount()).not.toThrow();
});
});
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',
};
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({
config: mockConfig,
settings: mockSettings,
version: '1.0.0',
initResult: mockInitResult,
resumedSessionData: mockResumedSessionData,
});
unmount = result.unmount;
});
await act(async () => {
unmount();
});
});
it('renders without resumed session data', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({
config: mockConfig,
settings: mockSettings,
version: '1.0.0',
initResult: mockInitResult,
resumedSessionData: undefined,
});
unmount = result.unmount;
});
await act(async () => {
unmount();
});
});
it('initializes chat recording service when config has it', () => {
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']>,
);
expect(() => {
renderAppContainer({
config: configWithRecording,
settings: mockSettings,
version: '1.0.0',
initResult: mockInitResult,
});
}).not.toThrow();
});
});
describe('Session Recording Integration', () => {
it('provides chat recording service configuration', () => {
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',
);
expect(() => {
renderAppContainer({
config: configWithRecording,
settings: mockSettings,
version: '1.0.0',
initResult: mockInitResult,
});
}).not.toThrow();
// Verify the recording service structure is correct
expect(configWithRecording.getGeminiClient).toBeDefined();
expect(mockGeminiClient.getChatRecordingService).toBeDefined();
expect(mockChatRecordingService.initialize).toBeDefined();
expect(mockChatRecordingService.recordMessage).toBeDefined();
});
it('handles session recording when messages are added', () => {
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']>,
);
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();
});
});
describe('Session Resume Flow', () => {
it('accepts resumed session data', () => {
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',
};
expect(() => {
renderAppContainer({
config: configWithClient,
settings: mockSettings,
version: '1.0.0',
initResult: mockInitResult,
resumedSessionData: resumedData,
});
}).not.toThrow();
// Verify the resume functionality structure is in place
expect(mockGeminiClient.resumeChat).toBeDefined();
expect(resumedData.conversation.messages).toHaveLength(2);
});
it('does not attempt resume when client is not initialized', () => {
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',
};
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();
});
});
describe('Token Counting from Session Stats', () => {
it('tracks token counts from session messages', () => {
// 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']>,
);
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();
});
});
describe('Quota and Fallback Integration', () => {
it('passes a null proQuotaRequest to UIStateContext by default', async () => {
// The default mock from beforeEach already sets proQuotaRequest to null
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => {
// Assert that the context value is as expected
expect(capturedUIState.quota.proQuotaRequest).toBeNull();
});
unmount!();
});
it('passes a valid proQuotaRequest to UIStateContext 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
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => {
// Assert: The mock request is correctly passed through the context
expect(capturedUIState.quota.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
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => {
// 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();
});
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', () => {
// Arrange: Set up mock settings with showStatusInTitle disabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithShowStatusFalse = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: false,
hideWindowTitle: false,
},
},
} as unknown as LoadedSettings;
// Mock the streaming state as Active
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
thought: { subject: 'Some thought' },
});
// Act: Render the container
const { unmount } = 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', () => {
// Arrange: Set up mock settings with dynamicWindowTitle disabled
const mockSettingsWithDynamicTitleFalse = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
dynamicWindowTitle: false,
hideWindowTitle: false,
},
},
} as unknown as LoadedSettings;
// Mock the streaming state
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
thought: { subject: 'Some thought' },
});
// Act: Render the container
const { unmount } = 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', () => {
// Arrange: Set up mock settings with hideWindowTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithHideTitleTrue = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: true,
},
},
} as unknown as LoadedSettings;
// Act: Render the container
const { unmount } = 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', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
} as unknown as LoadedSettings;
// 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 } = 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', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
} as unknown as LoadedSettings;
// Mock the streaming state as Idle with no thought
mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK);
// Act: Render the container
const { unmount } = 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 defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
} as unknown as LoadedSettings;
// 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
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({
settings: mockSettingsWithTitleEnabled,
});
unmount = result.unmount;
});
// 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 = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
} as unknown as LoadedSettings;
// 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 } = 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 = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
} as unknown as LoadedSettings;
// 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 } = 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 = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
} as unknown as LoadedSettings;
// 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 } = 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 = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
} as unknown as LoadedSettings;
// 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 } = 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', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
} as unknown as LoadedSettings;
// 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 } = 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', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
} as unknown as LoadedSettings;
// 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 } = 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', () => {
// Arrange: Set up mock settings with showStatusInTitle disabled (so it shows suffix)
const mockSettingsWithTitleDisabled = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
showStatusInTitle: false,
hideWindowTitle: false,
},
},
} as unknown as LoadedSettings;
// 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 } = 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 } = 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 } = 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('Terminal Height Calculation', () => {
const mockedMeasureElement = measureElement as Mock;
const mockedUseTerminalSize = useTerminalSize as Mock;
it('should prevent terminal height from being less than 1', async () => {
const resizePtySpy = vi.spyOn(ShellExecutionService, 'resizePty');
// Arrange: Simulate a small terminal and a large footer
mockedUseTerminalSize.mockReturnValue({ columns: 80, rows: 5 });
mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: 'some-id',
});
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(resizePtySpy).toHaveBeenCalled());
const lastCall =
resizePtySpy.mock.calls[resizePtySpy.mock.calls.length - 1];
// Check the height argument specifically
expect(lastCall[2]).toBe(1);
unmount!();
});
});
describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => {
let mockHandleSlashCommand: Mock;
let mockCancelOngoingRequest: Mock;
let rerender: () => void;
let unmount: () => void;
let stdin: ReturnType<typeof render>['stdin'];
// Helper function to reduce boilerplate in tests
const setupKeypressTest = async () => {
const renderResult = 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
// Now count is 2, it should quit.
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
'/quit',
undefined,
undefined,
false,
);
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 = 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 mockToggleBackgroundShell = vi.fn();
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundShellVisible: true,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
});
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(mockToggleBackgroundShell).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 mockToggleBackgroundShell = vi.fn();
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundShellVisible: true,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
});
await setupKeypressTest();
// Initially not focused, but visible
expect(capturedUIState.embeddedShellFocused).toBe(false);
// Press Ctrl+B
pressKey('\x02');
// Should have toggled (closed) the shell
expect(mockToggleBackgroundShell).toHaveBeenCalled();
// Should be unfocused
expect(capturedUIState.embeddedShellFocused).toBe(false);
unmount();
});
it('should show and focus background shell on Ctrl+B if hidden', async () => {
const mockToggleBackgroundShell = vi.fn();
const geminiStreamMock = {
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundShellVisible: false,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
};
mockedUseGeminiStream.mockReturnValue(geminiStreamMock);
await setupKeypressTest();
// Update the mock state when toggled to simulate real behavior
mockToggleBackgroundShell.mockImplementation(() => {
geminiStreamMock.isBackgroundShellVisible = true;
});
// Press Ctrl+B
pressKey('\x02');
// Should have toggled (shown) the shell
expect(mockToggleBackgroundShell).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 = 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();
});
await waitFor(() => {
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();
});
await waitFor(() => {
expect(capturedUIState.shortcutsHelpVisible).toBe(false);
});
unmount();
});
});
describe('Copy Mode (CTRL+S)', () => {
let rerender: () => void;
let unmount: () => void;
let stdin: ReturnType<typeof render>['stdin'];
const setupCopyModeTest = async (
isAlternateMode = false,
childHandler?: Mock,
) => {
// Update settings for this test run
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const testSettings = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
useAlternateBuffer: isAlternateMode,
},
},
} as unknown as LoadedSettings;
function TestChild() {
useKeypress(childHandler || (() => {}), {
isActive: !!childHandler,
priority: true,
});
return null;
}
const getTree = (settings: LoadedSettings) => (
<SettingsContext.Provider value={settings}>
<KeypressProvider config={mockConfig}>
<AppContainer
config={mockConfig}
version="1.0.0"
initializationResult={mockInitResult}
/>
<TestChild />
</KeypressProvider>
</SettingsContext.Provider>
);
const renderResult = 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();
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 Ctrl+S is pressed`, async () => {
await setupCopyModeTest(isAlternateMode);
mocks.mockStdout.write.mockClear(); // Clear initial enable call
act(() => {
stdin.write('\x13'); // Ctrl+S
});
rerender();
if (shouldEnable) {
expect(disableMouseEvents).toHaveBeenCalled();
} else {
expect(disableMouseEvents).not.toHaveBeenCalled();
}
unmount();
});
if (shouldEnable) {
it('should toggle mouse back on when Ctrl+S is pressed again', async () => {
await setupCopyModeTest(isAlternateMode);
(writeToStdout as Mock).mockClear();
// Turn it on (disable mouse)
act(() => {
stdin.write('\x13'); // Ctrl+S
});
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 any key press', async () => {
await setupCopyModeTest(isAlternateMode);
// Enter copy mode
act(() => {
stdin.write('\x13'); // Ctrl+S
});
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 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('\x13'); // Ctrl+S
});
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(),
});
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
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,
});
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
// 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 () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
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 () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
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 () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
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 () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
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 = 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 = 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('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 = 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 = 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 = 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 = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
expect(mockCoreEvents.off).toHaveBeenCalledWith(
CoreEvent.ConsentRequest,
expect.any(Function),
);
});
});
describe('Shell Interaction', () => {
it('should not crash if resizing the pty fails', async () => {
const resizePtySpy = vi
.spyOn(ShellExecutionService, 'resizePty')
.mockImplementation(() => {
throw new Error('Cannot resize a pty that has already exited');
});
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: 'some-pty-id', // Make sure activePtyId is set
});
// The main assertion is that the render does not throw.
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(resizePtySpy).toHaveBeenCalled());
unmount!();
});
});
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 = 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('clears the prompt when onCancelSubmit is called with shouldRestorePrompt=false', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
const { onCancelSubmit } = extractUseGeminiStreamArgs(
mockedUseGeminiStream.mock.lastCall!,
);
act(() => {
onCancelSubmit(false);
});
expect(mockSetText).toHaveBeenCalledWith('');
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(),
});
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() =>
expect(capturedUIState.userMessages).toContain('previous message'),
);
const { onCancelSubmit } = extractUseGeminiStreamArgs(
mockedUseGeminiStream.mock.lastCall!,
);
await act(async () => {
onCancelSubmit(true);
});
await waitFor(() => {
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(),
});
let rerender: (tree: ReactElement) => void;
let unmount;
await act(async () => {
const result = renderAppContainer();
rerender = result.rerender;
unmount = result.unmount;
});
// Verify userMessages is populated from inputHistory
await waitFor(() =>
expect(capturedUIState.userMessages).toContain('first prompt'),
);
expect(capturedUIState.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(capturedUIState.userMessages).toContain('first prompt');
expect(capturedUIState.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();
let compUnmount: () => void = () => {};
await act(async () => {
const { unmount } = renderAppContainer();
compUnmount = unmount;
});
// Allow async effects to run
await waitFor(() => 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);
compUnmount();
});
});
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']);
let unmount: () => void;
await act(async () => (unmount = renderAppContainer().unmount));
await waitFor(() => expect(capturedUIActions).toBeTruthy());
await act(async () =>
capturedUIActions.handleFinalSubmit('read @file.txt'),
);
expect(capturedUIState.permissionConfirmationRequest).not.toBeNull();
expect(capturedUIState.permissionConfirmationRequest?.files).toEqual([
'/test/file.txt',
]);
await act(async () => 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();
let unmount: () => void;
await act(async () => (unmount = renderAppContainer().unmount));
await waitFor(() => 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();
await act(async () => unmount!());
},
);
});
});