diff --git a/docs/mermaid/context.mmd b/docs/mermaid/context.mmd
new file mode 100644
index 0000000000..ebe4fbee11
--- /dev/null
+++ b/docs/mermaid/context.mmd
@@ -0,0 +1,103 @@
+graph LR
+ %% --- Style Definitions ---
+ classDef new fill:#98fb98,color:#000
+ classDef changed fill:#add8e6,color:#000
+ classDef unchanged fill:#f0f0f0,color:#000
+
+ %% --- Subgraphs ---
+ subgraph "Context Providers"
+ direction TB
+ A["gemini.tsx"]
+ B["AppContainer.tsx"]
+ end
+
+ subgraph "Contexts"
+ direction TB
+ CtxSession["SessionContext"]
+ CtxVim["VimModeContext"]
+ CtxSettings["SettingsContext"]
+ CtxApp["AppContext"]
+ CtxConfig["ConfigContext"]
+ CtxUIState["UIStateContext"]
+ CtxUIActions["UIActionsContext"]
+ end
+
+ subgraph "Component Consumers"
+ direction TB
+ ConsumerApp["App"]
+ ConsumerAppContainer["AppContainer"]
+ ConsumerAppHeader["AppHeader"]
+ ConsumerDialogManager["DialogManager"]
+ ConsumerHistoryItem["HistoryItemDisplay"]
+ ConsumerComposer["Composer"]
+ ConsumerMainContent["MainContent"]
+ ConsumerNotifications["Notifications"]
+ end
+
+ %% --- Provider -> Context Connections ---
+ A -.-> CtxSession
+ A -.-> CtxVim
+ A -.-> CtxSettings
+
+ B -.-> CtxApp
+ B -.-> CtxConfig
+ B -.-> CtxUIState
+ B -.-> CtxUIActions
+ B -.-> CtxSettings
+
+ %% --- Context -> Consumer Connections ---
+ CtxSession -.-> ConsumerAppContainer
+ CtxSession -.-> ConsumerApp
+
+ CtxVim -.-> ConsumerAppContainer
+ CtxVim -.-> ConsumerComposer
+ CtxVim -.-> ConsumerApp
+
+ CtxSettings -.-> ConsumerAppContainer
+ CtxSettings -.-> ConsumerAppHeader
+ CtxSettings -.-> ConsumerDialogManager
+ CtxSettings -.-> ConsumerApp
+
+ CtxApp -.-> ConsumerAppHeader
+ CtxApp -.-> ConsumerNotifications
+
+ CtxConfig -.-> ConsumerAppHeader
+ CtxConfig -.-> ConsumerHistoryItem
+ CtxConfig -.-> ConsumerComposer
+ CtxConfig -.-> ConsumerDialogManager
+
+
+
+ CtxUIState -.-> ConsumerApp
+ CtxUIState -.-> ConsumerMainContent
+ CtxUIState -.-> ConsumerComposer
+ CtxUIState -.-> ConsumerDialogManager
+
+ CtxUIActions -.-> ConsumerComposer
+ CtxUIActions -.-> ConsumerDialogManager
+
+ %% --- Apply Styles ---
+ %% New Elements (Green)
+ class B,CtxApp,CtxConfig,CtxUIState,CtxUIActions,ConsumerAppHeader,ConsumerDialogManager,ConsumerComposer,ConsumerMainContent,ConsumerNotifications new
+
+ %% Heavily Changed Elements (Blue)
+ class A,ConsumerApp,ConsumerAppContainer,ConsumerHistoryItem changed
+
+ %% Mostly Unchanged Elements (Gray)
+ class CtxSession,CtxVim,CtxSettings unchanged
+
+ %% --- Link Styles ---
+ %% CtxSession (Red)
+ linkStyle 0,8,9 stroke:#e57373,stroke-width:2px
+ %% CtxVim (Orange)
+ linkStyle 1,10,11,12 stroke:#ffb74d,stroke-width:2px
+ %% CtxSettings (Yellow)
+ linkStyle 2,7,13,14,15,16 stroke:#fff176,stroke-width:2px
+ %% CtxApp (Green)
+ linkStyle 3,17,18 stroke:#81c784,stroke-width:2px
+ %% CtxConfig (Blue)
+ linkStyle 4,19,20,21,22 stroke:#64b5f6,stroke-width:2px
+ %% CtxUIState (Indigo)
+ linkStyle 5,23,24,25,26 stroke:#7986cb,stroke-width:2px
+ %% CtxUIActions (Violet)
+ linkStyle 6,27,28 stroke:#ba68c8,stroke-width:2px
diff --git a/docs/mermaid/render-path.mmd b/docs/mermaid/render-path.mmd
new file mode 100644
index 0000000000..5f4c620443
--- /dev/null
+++ b/docs/mermaid/render-path.mmd
@@ -0,0 +1,64 @@
+graph TD
+ %% --- Style Definitions ---
+ classDef new fill:#98fb98,color:#000
+ classDef changed fill:#add8e6,color:#000
+ classDef unchanged fill:#f0f0f0,color:#000
+ classDef dispatcher fill:#f9e79f,color:#000,stroke:#333,stroke-width:1px
+ classDef container fill:#f5f5f5,color:#000,stroke:#ccc
+
+ %% --- Component Tree ---
+ subgraph "Entry Point"
+ A["gemini.tsx"]
+ end
+
+ subgraph "State & Logic Wrapper"
+ B["AppContainer.tsx"]
+ end
+
+ subgraph "Primary Layout"
+ C["App.tsx"]
+ end
+
+ A -.-> B
+ B -.-> C
+
+ subgraph "UI Containers"
+ direction LR
+ C -.-> D["MainContent"]
+ C -.-> G["Composer"]
+ C -.-> F["DialogManager"]
+ C -.-> E["Notifications"]
+ end
+
+ subgraph "MainContent"
+ direction TB
+ D -.-> H["AppHeader"]
+ D -.-> I["HistoryItemDisplay"]:::dispatcher
+ D -.-> L["ShowMoreLines"]
+ end
+
+ subgraph "Composer"
+ direction TB
+ G -.-> K_Prompt["InputPrompt"]
+ G -.-> K_Footer["Footer"]
+ end
+
+ subgraph "DialogManager"
+ F -.-> J["Various Dialogs
(Auth, Theme, Settings, etc.)"]
+ end
+
+ %% --- Apply Styles ---
+ class B,D,E,F,G,H,J,K_Prompt,L new
+ class A,C,I changed
+ class K_Footer unchanged
+
+ %% --- Link Styles ---
+ %% MainContent Branch (Blue)
+ linkStyle 2,6,7,8 stroke:#64b5f6,stroke-width:2px
+ %% Composer Branch (Green)
+ linkStyle 3,9,10 stroke:#81c784,stroke-width:2px
+ %% DialogManager Branch (Orange)
+ linkStyle 4,11 stroke:#ffb74d,stroke-width:2px
+ %% Notifications Branch (Violet)
+ linkStyle 5 stroke:#ba68c8,stroke-width:2px
+
diff --git a/package-lock.json b/package-lock.json
index c832f8ac22..541297e2fa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1210,13 +1210,13 @@
}
},
"node_modules/@eslint/plugin-kit": {
- "version": "0.3.3",
- "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz",
- "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==",
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
+ "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/core": "^0.15.1",
+ "@eslint/core": "^0.15.2",
"levn": "^0.4.1"
},
"engines": {
@@ -1224,9 +1224,9 @@
}
},
"node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
- "version": "0.15.1",
- "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
- "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
+ "version": "0.15.2",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
+ "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -16619,6 +16619,7 @@
"@types/minimatch": "^5.1.2",
"@types/picomatch": "^4.0.1",
"@types/ws": "^8.5.10",
+ "msw": "^2.3.4",
"typescript": "^5.3.3",
"vitest": "^3.1.1"
},
diff --git a/packages/cli/src/core/auth.ts b/packages/cli/src/core/auth.ts
new file mode 100644
index 0000000000..f4f4963bc7
--- /dev/null
+++ b/packages/cli/src/core/auth.ts
@@ -0,0 +1,36 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ type AuthType,
+ type Config,
+ getErrorMessage,
+} from '@google/gemini-cli-core';
+
+/**
+ * Handles the initial authentication flow.
+ * @param config The application config.
+ * @param authType The selected auth type.
+ * @returns An error message if authentication fails, otherwise null.
+ */
+export async function performInitialAuth(
+ config: Config,
+ authType: AuthType | undefined,
+): Promise {
+ if (!authType) {
+ return null;
+ }
+
+ try {
+ await config.refreshAuth(authType);
+ // The console.log is intentionally left out here.
+ // We can add a dedicated startup message later if needed.
+ } catch (e) {
+ return `Failed to login. Message: ${getErrorMessage(e)}`;
+ }
+
+ return null;
+}
diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts
new file mode 100644
index 0000000000..f304cfcec9
--- /dev/null
+++ b/packages/cli/src/core/initializer.ts
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { type Config } from '@google/gemini-cli-core';
+import { type LoadedSettings } from '../config/settings.js';
+import { performInitialAuth } from './auth.js';
+import { validateTheme } from './theme.js';
+
+export interface InitializationResult {
+ authError: string | null;
+ themeError: string | null;
+ shouldOpenAuthDialog: boolean;
+ geminiMdFileCount: number;
+}
+
+/**
+ * Orchestrates the application's startup initialization.
+ * This runs BEFORE the React UI is rendered.
+ * @param config The application config.
+ * @param settings The loaded application settings.
+ * @returns The results of the initialization.
+ */
+export async function initializeApp(
+ config: Config,
+ settings: LoadedSettings,
+): Promise {
+ const authError = await performInitialAuth(
+ config,
+ settings.merged.security?.auth?.selectedType,
+ );
+ const themeError = validateTheme(settings);
+
+ const shouldOpenAuthDialog =
+ settings.merged.security?.auth?.selectedType === undefined || !!authError;
+
+ return {
+ authError,
+ themeError,
+ shouldOpenAuthDialog,
+ geminiMdFileCount: config.getGeminiMdFileCount(),
+ };
+}
diff --git a/packages/cli/src/core/theme.ts b/packages/cli/src/core/theme.ts
new file mode 100644
index 0000000000..ed2805a5ab
--- /dev/null
+++ b/packages/cli/src/core/theme.ts
@@ -0,0 +1,21 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { themeManager } from '../ui/themes/theme-manager.js';
+import { type LoadedSettings } from '../config/settings.js';
+
+/**
+ * Validates the configured theme.
+ * @param settings The loaded application settings.
+ * @returns An error message if the theme is not found, otherwise null.
+ */
+export function validateTheme(settings: LoadedSettings): string | null {
+ const effectiveTheme = settings.merged.ui?.theme;
+ if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) {
+ return `Theme "${effectiveTheme}" not found.`;
+ }
+ return null;
+}
diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx
index 90cea8bbc0..78c0589f79 100644
--- a/packages/cli/src/gemini.test.tsx
+++ b/packages/cli/src/gemini.test.tsx
@@ -195,6 +195,7 @@ describe('gemini.tsx main function kitty protocol', () => {
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
+ getGeminiMdFileCount: () => 0,
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
@@ -323,11 +324,19 @@ describe('startInteractiveUI', () => {
const { render } = await import('ink');
const renderSpy = vi.mocked(render);
+ const mockInitializationResult = {
+ authError: null,
+ themeError: null,
+ shouldOpenAuthDialog: false,
+ geminiMdFileCount: 0,
+ };
+
await startInteractiveUI(
mockConfig,
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
+ mockInitializationResult,
);
// Verify render was called with correct options
@@ -349,11 +358,19 @@ describe('startInteractiveUI', () => {
const { checkForUpdates } = await import('./ui/utils/updateCheck.js');
const { registerCleanup } = await import('./utils/cleanup.js');
+ const mockInitializationResult = {
+ authError: null,
+ themeError: null,
+ shouldOpenAuthDialog: false,
+ geminiMdFileCount: 0,
+ };
+
await startInteractiveUI(
mockConfig,
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
+ mockInitializationResult,
);
// Verify all startup tasks were called
diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx
index b6ffda16d1..d7b77ef19a 100644
--- a/packages/cli/src/gemini.tsx
+++ b/packages/cli/src/gemini.tsx
@@ -7,7 +7,7 @@
import React, { useState, useEffect } from 'react';
import { render, Box, Text } from 'ink';
import Spinner from 'ink-spinner';
-import { AppWrapper } from './ui/App.js';
+import { AppContainer } from './ui/AppContainer.js';
import { loadCliConfig, parseArguments } from './config/config.js';
import { readStdin } from './utils/readStdin.js';
import { basename } from 'node:path';
@@ -38,6 +38,10 @@ import {
getOauthClient,
uiTelemetryService,
} from '@google/gemini-cli-core';
+import {
+ initializeApp,
+ type InitializationResult,
+} from './core/initializer.js';
import { validateAuthMethod } from './config/auth.js';
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
@@ -47,6 +51,10 @@ import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
import { appEvents, AppEvent } from './utils/events.js';
import { SettingsContext } from './ui/contexts/SettingsContext.js';
import { writeFileSync } from 'node:fs';
+import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
+import { VimModeProvider } from './ui/contexts/VimModeContext.js';
+import { KeypressProvider } from './ui/contexts/KeypressContext.js';
+import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
export function validateDnsResolutionOrder(
order: string | undefined,
@@ -170,21 +178,45 @@ export async function startInteractiveUI(
settings: LoadedSettings,
startupWarnings: string[],
workspaceRoot: string = process.cwd(),
+ initializationResult: InitializationResult,
) {
const version = await getCliVersion();
setWindowTitle(basename(workspaceRoot), settings);
+
+ // Create wrapper component to use hooks inside render
+ const AppWrapper = () => {
+ const kittyProtocolStatus = useKittyKeyboardProtocol();
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ };
+
const instance = render(
-
-
-
+
,
- { exitOnCtrlC: false, isScreenReaderEnabled: config.getScreenReader() },
+ {
+ exitOnCtrlC: false,
+ isScreenReaderEnabled: config.getScreenReader(),
+ },
);
checkForUpdates()
@@ -308,11 +340,13 @@ export async function main() {
if (settings.merged.ui?.theme) {
if (!themeManager.setActiveTheme(settings.merged.ui?.theme)) {
// If the theme is not found during initial load, log a warning and continue.
- // The useThemeCommand hook in App.tsx will handle opening the dialog.
+ // The useThemeCommand hook in AppContainer.tsx will handle opening the dialog.
console.warn(`Warning: Theme "${settings.merged.ui?.theme}" not found.`);
}
}
+ const initializationResult = await initializeApp(config, settings);
+
// hop into sandbox if we are outside and sandboxing is enabled
if (!process.env['SANDBOX']) {
const memoryArgs = settings.merged.advanced?.autoConfigureMemory
@@ -403,7 +437,13 @@ export async function main() {
if (config.isInteractive()) {
// Need kitty detection to be complete before we can start the interactive UI.
await kittyProtocolDetectionComplete;
- await startInteractiveUI(config, settings, startupWarnings);
+ await startInteractiveUI(
+ config,
+ settings,
+ startupWarnings,
+ process.cwd(),
+ initializationResult,
+ );
return;
}
// If not a TTY, read from stdin
diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts
index 9a1993fece..ee80afbd73 100644
--- a/packages/cli/src/services/BuiltinCommandLoader.test.ts
+++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts
@@ -15,7 +15,16 @@ vi.mock('../ui/commands/aboutCommand.js', async () => {
};
});
-vi.mock('../ui/commands/ideCommand.js', () => ({ ideCommand: vi.fn() }));
+vi.mock('../ui/commands/ideCommand.js', async () => {
+ const { CommandKind } = await import('../ui/commands/types.js');
+ return {
+ ideCommand: vi.fn().mockResolvedValue({
+ name: 'ide',
+ description: 'IDE command',
+ kind: CommandKind.BUILT_IN,
+ }),
+ };
+});
vi.mock('../ui/commands/restoreCommand.js', () => ({
restoreCommand: vi.fn(),
}));
@@ -25,7 +34,6 @@ import { BuiltinCommandLoader } from './BuiltinCommandLoader.js';
import type { Config } from '@google/gemini-cli-core';
import { CommandKind } from '../ui/commands/types.js';
-import { ideCommand } from '../ui/commands/ideCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} }));
@@ -57,18 +65,12 @@ vi.mock('../ui/commands/mcpCommand.js', () => ({
describe('BuiltinCommandLoader', () => {
let mockConfig: Config;
- const ideCommandMock = ideCommand as Mock;
const restoreCommandMock = restoreCommand as Mock;
beforeEach(() => {
vi.clearAllMocks();
mockConfig = { some: 'config' } as unknown as Config;
- ideCommandMock.mockResolvedValue({
- name: 'ide',
- description: 'IDE command',
- kind: CommandKind.BUILT_IN,
- });
restoreCommandMock.mockReturnValue({
name: 'restore',
description: 'Restore command',
@@ -76,25 +78,23 @@ describe('BuiltinCommandLoader', () => {
});
});
- it('should correctly pass the config object to command factory functions', async () => {
+ it('should correctly pass the config object to restore command factory', async () => {
const loader = new BuiltinCommandLoader(mockConfig);
await loader.loadCommands(new AbortController().signal);
- expect(ideCommandMock).toHaveBeenCalledTimes(1);
- expect(ideCommandMock).toHaveBeenCalledWith();
+ // ideCommand is now a constant, no longer needs config
expect(restoreCommandMock).toHaveBeenCalledTimes(1);
expect(restoreCommandMock).toHaveBeenCalledWith(mockConfig);
});
it('should filter out null command definitions returned by factories', async () => {
- // Override the mock's behavior for this specific test.
- ideCommandMock.mockReturnValue(null);
+ // ideCommand is now a constant SlashCommand
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
- // The 'ide' command should be filtered out.
+ // The 'ide' command should be present.
const ideCmd = commands.find((c) => c.name === 'ide');
- expect(ideCmd).toBeUndefined();
+ expect(ideCmd).toBeDefined();
// Other commands should still be present.
const aboutCmd = commands.find((c) => c.name === 'about');
@@ -104,8 +104,7 @@ describe('BuiltinCommandLoader', () => {
it('should handle a null config gracefully when calling factories', async () => {
const loader = new BuiltinCommandLoader(null);
await loader.loadCommands(new AbortController().signal);
- expect(ideCommandMock).toHaveBeenCalledTimes(1);
- expect(ideCommandMock).toHaveBeenCalledWith();
+ // ideCommand is now a constant, no longer needs config
expect(restoreCommandMock).toHaveBeenCalledTimes(1);
expect(restoreCommandMock).toHaveBeenCalledWith(null);
});
diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx
index 9b9cb84b81..1f4d803b3a 100644
--- a/packages/cli/src/ui/App.test.tsx
+++ b/packages/cli/src/ui/App.test.tsx
@@ -4,1685 +4,83 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import type { Mock } from 'vitest';
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { waitFor } from '@testing-library/react';
-import { renderWithProviders } from '../test-utils/render.js';
-import { AppWrapper as App } from './App.js';
-import type {
- AccessibilitySettings,
- MCPServerConfig,
- ToolRegistry,
- SandboxConfig,
- GeminiClient,
-} from '@google/gemini-cli-core';
-import {
- ApprovalMode,
- ideContext,
- Config as ServerConfig,
-} from '@google/gemini-cli-core';
-import type { SettingsFile, Settings } from '../config/settings.js';
-import { LoadedSettings } from '../config/settings.js';
-import process from 'node:process';
-import { useGeminiStream } from './hooks/useGeminiStream.js';
-import { useConsoleMessages } from './hooks/useConsoleMessages.js';
-import type { ConsoleMessageItem } from './types.js';
+import { describe, it, expect, vi } from 'vitest';
+import { render } from 'ink-testing-library';
+import { Text } from 'ink';
+import { App } from './App.js';
+import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
import { StreamingState } from './types.js';
-import { Tips } from './components/Tips.js';
-import type { UpdateObject } from './utils/updateCheck.js';
-import { checkForUpdates } from './utils/updateCheck.js';
-import { EventEmitter } from 'node:events';
-import { updateEventEmitter } from '../utils/updateEventEmitter.js';
-import * as useTerminalSize from './hooks/useTerminalSize.js';
-// Define a more complete mock server config based on actual Config
-interface MockServerConfig {
- apiKey: string;
- model: string;
- sandbox?: SandboxConfig;
- targetDir: string;
- debugMode: boolean;
- question?: string;
- fullContext: boolean;
- coreTools?: string[];
- toolDiscoveryCommand?: string;
- toolCallCommand?: string;
- mcpServerCommand?: string;
- mcpServers?: Record; // Use imported MCPServerConfig
- userAgent: string;
- userMemory: string;
- geminiMdFileCount: number;
- approvalMode: ApprovalMode;
- vertexai?: boolean;
- showMemoryUsage?: boolean;
- accessibility?: AccessibilitySettings;
- embeddingModel: string;
+// Mock components to isolate App component testing
+vi.mock('./components/MainContent.js', () => ({
+ MainContent: () => MainContent,
+}));
- getApiKey: Mock<() => string>;
- getModel: Mock<() => string>;
- getSandbox: Mock<() => SandboxConfig | undefined>;
- getTargetDir: Mock<() => string>;
- getToolRegistry: Mock<() => ToolRegistry>; // Use imported ToolRegistry type
- getDebugMode: Mock<() => boolean>;
- getQuestion: Mock<() => string | undefined>;
- getFullContext: Mock<() => boolean>;
- getCoreTools: Mock<() => string[] | undefined>;
- getToolDiscoveryCommand: Mock<() => string | undefined>;
- getToolCallCommand: Mock<() => string | undefined>;
- getMcpServerCommand: Mock<() => string | undefined>;
- getMcpServers: Mock<() => Record | undefined>;
- getExtensions: Mock<
- () => Array<{ name: string; version: string; isActive: boolean }>
- >;
- getBlockedMcpServers: Mock<
- () => Array<{ name: string; extensionName: string }>
- >;
- getUserAgent: Mock<() => string>;
- getUserMemory: Mock<() => string>;
- setUserMemory: Mock<(newUserMemory: string) => void>;
- getGeminiMdFileCount: Mock<() => number>;
- setGeminiMdFileCount: Mock<(count: number) => void>;
- getApprovalMode: Mock<() => ApprovalMode>;
- setApprovalMode: Mock<(skip: ApprovalMode) => void>;
- getVertexAI: Mock<() => boolean | undefined>;
- getShowMemoryUsage: Mock<() => boolean>;
- getAccessibility: Mock<() => AccessibilitySettings>;
- getProjectRoot: Mock<() => string | undefined>;
- getAllGeminiMdFilenames: Mock<() => string[]>;
- getGeminiClient: Mock<() => GeminiClient | undefined>;
- getUserTier: Mock<() => Promise>;
- getScreenReader: Mock<() => boolean>;
-}
+vi.mock('./components/DialogManager.js', () => ({
+ DialogManager: () => DialogManager,
+}));
-// Mock @google/gemini-cli-core and its Config class
-vi.mock('@google/gemini-cli-core', async (importOriginal) => {
- const actualCore =
- await importOriginal();
- const ConfigClassMock = vi
- .fn()
- .mockImplementation((optionsPassedToConstructor) => {
- const opts = { ...optionsPassedToConstructor }; // Clone
- // Basic mock structure, will be extended by the instance in tests
- return {
- apiKey: opts.apiKey || 'test-key',
- model: opts.model || 'test-model-in-mock-factory',
- sandbox: opts.sandbox,
- targetDir: opts.targetDir || '/test/dir',
- debugMode: opts.debugMode || false,
- question: opts.question,
- fullContext: opts.fullContext ?? false,
- coreTools: opts.coreTools,
- toolDiscoveryCommand: opts.toolDiscoveryCommand,
- toolCallCommand: opts.toolCallCommand,
- mcpServerCommand: opts.mcpServerCommand,
- mcpServers: opts.mcpServers,
- userAgent: opts.userAgent || 'test-agent',
- userMemory: opts.userMemory || '',
- geminiMdFileCount: opts.geminiMdFileCount || 0,
- approvalMode: opts.approvalMode ?? ApprovalMode.DEFAULT,
- vertexai: opts.vertexai,
- showMemoryUsage: opts.showMemoryUsage ?? false,
- accessibility: opts.accessibility ?? {},
- embeddingModel: opts.embeddingModel || 'test-embedding-model',
+vi.mock('./components/Composer.js', () => ({
+ Composer: () => Composer,
+}));
- getApiKey: vi.fn(() => opts.apiKey || 'test-key'),
- getModel: vi.fn(() => opts.model || 'test-model-in-mock-factory'),
- getSandbox: vi.fn(() => opts.sandbox),
- getTargetDir: vi.fn(() => opts.targetDir || '/test/dir'),
- getToolRegistry: vi.fn(() => ({}) as ToolRegistry), // Simple mock
- getDebugMode: vi.fn(() => opts.debugMode || false),
- getQuestion: vi.fn(() => opts.question),
- getFullContext: vi.fn(() => opts.fullContext ?? false),
- getCoreTools: vi.fn(() => opts.coreTools),
- getToolDiscoveryCommand: vi.fn(() => opts.toolDiscoveryCommand),
- getToolCallCommand: vi.fn(() => opts.toolCallCommand),
- getMcpServerCommand: vi.fn(() => opts.mcpServerCommand),
- getMcpServers: vi.fn(() => opts.mcpServers),
- getPromptRegistry: vi.fn(),
- getExtensions: vi.fn(() => []),
- getBlockedMcpServers: vi.fn(() => []),
- getUserAgent: vi.fn(() => opts.userAgent || 'test-agent'),
- getUserMemory: vi.fn(() => opts.userMemory || ''),
- setUserMemory: vi.fn(),
- getGeminiMdFileCount: vi.fn(() => opts.geminiMdFileCount || 0),
- setGeminiMdFileCount: vi.fn(),
- getApprovalMode: vi.fn(() => opts.approvalMode ?? ApprovalMode.DEFAULT),
- setApprovalMode: vi.fn(),
- getVertexAI: vi.fn(() => opts.vertexai),
- getShowMemoryUsage: vi.fn(() => opts.showMemoryUsage ?? false),
- getAccessibility: vi.fn(() => opts.accessibility ?? {}),
- getProjectRoot: vi.fn(() => opts.targetDir),
- getEnablePromptCompletion: vi.fn(() => false),
- getGeminiClient: vi.fn(() => ({
- isInitialized: vi.fn(() => true),
- getUserTier: vi.fn(),
- getChatRecordingService: vi.fn(() => ({
- initialize: vi.fn(),
- recordMessage: vi.fn(),
- recordMessageTokens: vi.fn(),
- recordToolCalls: vi.fn(),
- })),
- getChat: vi.fn(() => ({
- getChatRecordingService: vi.fn(() => ({
- initialize: vi.fn(),
- recordMessage: vi.fn(),
- recordMessageTokens: vi.fn(),
- recordToolCalls: vi.fn(),
- })),
- })),
- })),
- getCheckpointingEnabled: vi.fn(() => opts.checkpointing ?? true),
- getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
- setFlashFallbackHandler: vi.fn(),
- getSessionId: vi.fn(() => 'test-session-id'),
- getUserTier: vi.fn().mockResolvedValue(undefined),
- getIdeMode: vi.fn(() => true),
- getWorkspaceContext: vi.fn(() => ({
- getDirectories: vi.fn(() => []),
- })),
- isTrustedFolder: vi.fn(() => true),
- getScreenReader: vi.fn(() => false),
- getFolderTrustFeature: vi.fn(() => false),
- getFolderTrust: vi.fn(() => false),
- };
- });
+vi.mock('./components/Notifications.js', () => ({
+ Notifications: () => Notifications,
+}));
- const ideContextMock = {
- getIdeContext: vi.fn(),
- subscribeToIdeContext: vi.fn(() => vi.fn()), // subscribe returns an unsubscribe function
+vi.mock('./components/QuittingDisplay.js', () => ({
+ QuittingDisplay: () => Quitting...,
+}));
+
+describe('App', () => {
+ const mockUIState: Partial = {
+ streamingState: StreamingState.Idle,
+ quittingMessages: null,
+ dialogsVisible: false,
+ mainControlsRef: { current: null },
};
- return {
- ...actualCore,
- Config: ConfigClassMock,
- MCPServerConfig: actualCore.MCPServerConfig,
- getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
- ideContext: ideContextMock,
- IdeClient: {
- getInstance: vi.fn().mockResolvedValue({
- getCurrentIde: vi.fn(() => 'vscode'),
- getDetectedIdeDisplayName: vi.fn(() => 'VSCode'),
- addStatusChangeListener: vi.fn(),
- removeStatusChangeListener: vi.fn(),
- getConnectionStatus: vi.fn(() => 'connected'),
- }),
- },
- isGitRepository: vi.fn(),
- };
-});
-
-// Mock heavy dependencies or those with side effects
-vi.mock('./hooks/useGeminiStream', () => ({
- useGeminiStream: vi.fn(() => ({
- streamingState: 'Idle',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
- thought: null,
- })),
-}));
-
-vi.mock('./auth/useAuth.js', () => ({
- useAuthCommand: vi.fn(() => ({
- authState: 'authenticated',
- setAuthState: vi.fn(),
- authError: null,
- onAuthError: vi.fn(),
- })),
-}));
-
-vi.mock('./hooks/useFolderTrust', () => ({
- useFolderTrust: vi.fn(() => ({
- isFolderTrustDialogOpen: false,
- handleFolderTrustSelect: vi.fn(),
- isRestarting: false,
- })),
-}));
-
-vi.mock('./hooks/useIdeTrustListener', () => ({
- useIdeTrustListener: vi.fn(() => ({
- needsRestart: false,
- })),
-}));
-
-vi.mock('./hooks/useLogger', () => ({
- useLogger: vi.fn(() => ({
- getPreviousUserMessages: vi.fn().mockResolvedValue([]),
- })),
-}));
-
-vi.mock('./hooks/useInputHistoryStore.js', () => ({
- useInputHistoryStore: vi.fn(() => ({
- inputHistory: [],
- addInput: vi.fn(),
- initializeFromLogger: vi.fn(),
- })),
-}));
-
-vi.mock('./hooks/useConsoleMessages.js', () => ({
- useConsoleMessages: vi.fn(() => ({
- consoleMessages: [],
- handleNewMessage: vi.fn(),
- clearConsoleMessages: vi.fn(),
- })),
-}));
-
-vi.mock('../config/config.js', async (importOriginal) => {
- const actual = await importOriginal();
- return {
- // @ts-expect-error - this is fine
- ...actual,
- loadHierarchicalGeminiMemory: vi
- .fn()
- .mockResolvedValue({ memoryContent: '', fileCount: 0 }),
- };
-});
-
-vi.mock('./components/Tips.js', () => ({
- Tips: vi.fn(() => null),
-}));
-
-vi.mock('./components/Header.js', () => ({
- Header: vi.fn(() => null),
-}));
-
-vi.mock('./utils/updateCheck.js', () => ({
- checkForUpdates: vi.fn(),
-}));
-
-vi.mock('../config/auth.js', () => ({
- validateAuthMethod: vi.fn(),
-}));
-
-vi.mock('../hooks/useTerminalSize.js', () => ({
- useTerminalSize: vi.fn(),
-}));
-
-const mockedCheckForUpdates = vi.mocked(checkForUpdates);
-const { isGitRepository: mockedIsGitRepository } = vi.mocked(
- await import('@google/gemini-cli-core'),
-);
-
-vi.mock('node:child_process');
-
-describe('App UI', () => {
- let mockConfig: MockServerConfig;
- let mockSettings: LoadedSettings;
- let mockVersion: string;
- let currentUnmount: (() => void) | undefined;
-
- const createMockSettings = (
- settings: {
- system?: Partial;
- user?: Partial;
- workspace?: Partial;
- } = {},
- ): LoadedSettings => {
- const systemSettingsFile: SettingsFile = {
- path: '/system/settings.json',
- settings: settings.system || {},
- };
- const systemDefaultsFile: SettingsFile = {
- path: '/system/system-defaults.json',
- settings: {},
- };
- const userSettingsFile: SettingsFile = {
- path: '/user/settings.json',
- settings: settings.user || {},
- };
- const workspaceSettingsFile: SettingsFile = {
- path: '/workspace/.gemini/settings.json',
- settings: settings.workspace || {},
- };
- return new LoadedSettings(
- systemSettingsFile,
- systemDefaultsFile,
- userSettingsFile,
- workspaceSettingsFile,
- true,
- new Set(),
+ it('should render main content and composer when not quitting', () => {
+ const { lastFrame } = render(
+
+
+ ,
);
- };
- beforeEach(() => {
- vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
- columns: 120,
- rows: 24,
- });
-
- const ServerConfigMocked = vi.mocked(ServerConfig, true);
- mockConfig = new ServerConfigMocked({
- embeddingModel: 'test-embedding-model',
- sandbox: undefined,
- targetDir: '/test/dir',
- debugMode: false,
- userMemory: '',
- geminiMdFileCount: 0,
- showMemoryUsage: false,
- sessionId: 'test-session-id',
- cwd: '/tmp',
- model: 'model',
- }) as unknown as MockServerConfig;
- mockVersion = '0.0.0-test';
-
- // Ensure the getShowMemoryUsage mock function is specifically set up if not covered by constructor mock
- if (!mockConfig.getShowMemoryUsage) {
- mockConfig.getShowMemoryUsage = vi.fn(() => false);
- }
- mockConfig.getShowMemoryUsage.mockReturnValue(false); // Default for most tests
-
- // Ensure a theme is set so the theme dialog does not appear.
- mockSettings = createMockSettings({ workspace: { theme: 'Default' } });
-
- // Ensure getWorkspaceContext is available if not added by the constructor
- if (!mockConfig.getWorkspaceContext) {
- mockConfig.getWorkspaceContext = vi.fn(() => ({
- getDirectories: vi.fn(() => ['/test/dir']),
- }));
- }
- vi.mocked(ideContext.getIdeContext).mockReturnValue(undefined);
+ expect(lastFrame()).toContain('MainContent');
+ expect(lastFrame()).toContain('Notifications');
+ expect(lastFrame()).toContain('Composer');
});
- afterEach(() => {
- if (currentUnmount) {
- currentUnmount();
- currentUnmount = undefined;
- }
- vi.clearAllMocks(); // Clear mocks after each test
- });
+ it('should render quitting display when quittingMessages is set', () => {
+ const quittingUIState = {
+ ...mockUIState,
+ quittingMessages: [{ id: 1, type: 'user', text: 'test' }],
+ } as UIState;
- describe('handleAutoUpdate', () => {
- let spawnEmitter: EventEmitter;
-
- beforeEach(async () => {
- const { spawn } = await import('node:child_process');
- spawnEmitter = new EventEmitter();
- spawnEmitter.stdout = new EventEmitter();
- spawnEmitter.stderr = new EventEmitter();
- (spawn as vi.Mock).mockReturnValue(spawnEmitter);
- });
-
- afterEach(() => {
- delete process.env.GEMINI_CLI_DISABLE_AUTOUPDATER;
- });
-
- it('should not start the update process when running from git', async () => {
- mockedIsGitRepository.mockResolvedValue(true);
- const info: UpdateObject = {
- update: {
- name: '@google/gemini-cli',
- latest: '1.1.0',
- current: '1.0.0',
- },
- message: 'Gemini CLI update available!',
- };
- mockedCheckForUpdates.mockResolvedValue(info);
- const { spawn } = await import('node:child_process');
-
- const { unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- // Wait for any potential async operations to complete
- await waitFor(() => {
- expect(spawn).not.toHaveBeenCalled();
- });
- });
-
- it('should show a success message when update succeeds', async () => {
- mockedIsGitRepository.mockResolvedValue(false);
- const info: UpdateObject = {
- update: {
- name: '@google/gemini-cli',
- latest: '1.1.0',
- current: '1.0.0',
- },
- message: 'Update available',
- };
- mockedCheckForUpdates.mockResolvedValue(info);
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- updateEventEmitter.emit('update-success', info);
-
- // Wait for the success message to appear
- await waitFor(() => {
- expect(lastFrame()).toContain(
- 'Update successful! The new version will be used on your next run.',
- );
- });
- });
-
- it('should show an error message when update fails', async () => {
- mockedIsGitRepository.mockResolvedValue(false);
- const info: UpdateObject = {
- update: {
- name: '@google/gemini-cli',
- latest: '1.1.0',
- current: '1.0.0',
- },
- message: 'Update available',
- };
- mockedCheckForUpdates.mockResolvedValue(info);
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- updateEventEmitter.emit('update-failed', info);
-
- // Wait for the error message to appear
- await waitFor(() => {
- expect(lastFrame()).toContain(
- 'Automatic update failed. Please try updating manually',
- );
- });
- });
-
- it('should show an error message when spawn fails', async () => {
- mockedIsGitRepository.mockResolvedValue(false);
- const info: UpdateObject = {
- update: {
- name: '@google/gemini-cli',
- latest: '1.1.0',
- current: '1.0.0',
- },
- message: 'Update available',
- };
- mockedCheckForUpdates.mockResolvedValue(info);
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- // We are testing the App's reaction to an `update-failed` event,
- // which is what should be emitted when a spawn error occurs elsewhere.
- updateEventEmitter.emit('update-failed', info);
-
- // Wait for the error message to appear
- await waitFor(() => {
- expect(lastFrame()).toContain(
- 'Automatic update failed. Please try updating manually',
- );
- });
- });
-
- it('should not auto-update if GEMINI_CLI_DISABLE_AUTOUPDATER is true', async () => {
- mockedIsGitRepository.mockResolvedValue(false);
- process.env.GEMINI_CLI_DISABLE_AUTOUPDATER = 'true';
- const info: UpdateObject = {
- update: {
- name: '@google/gemini-cli',
- latest: '1.1.0',
- current: '1.0.0',
- },
- message: 'Update available',
- };
- mockedCheckForUpdates.mockResolvedValue(info);
- const { spawn } = await import('node:child_process');
-
- const { unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- // Wait for any potential async operations to complete
- await waitFor(() => {
- expect(spawn).not.toHaveBeenCalled();
- });
- });
- });
-
- it('should display active file when available', async () => {
- vi.mocked(ideContext.getIdeContext).mockReturnValue({
- workspaceState: {
- openFiles: [
- {
- path: '/path/to/my-file.ts',
- isActive: true,
- selectedText: 'hello',
- timestamp: 0,
- },
- ],
- },
- });
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
+ const { lastFrame } = render(
+
+
+ ,
);
- currentUnmount = unmount;
- await Promise.resolve();
- expect(lastFrame()).toContain('1 open file (ctrl+g to view)');
+
+ expect(lastFrame()).toContain('Quitting...');
});
- it('should not display any files when not available', async () => {
- vi.mocked(ideContext.getIdeContext).mockReturnValue({
- workspaceState: {
- openFiles: [],
- },
- });
+ it('should render dialog manager when dialogs are visible', () => {
+ const dialogUIState = {
+ ...mockUIState,
+ dialogsVisible: true,
+ } as UIState;
- const { lastFrame, unmount } = renderWithProviders(
- ,
+ const { lastFrame } = render(
+
+
+ ,
);
- currentUnmount = unmount;
- await Promise.resolve();
- expect(lastFrame()).not.toContain('Open File');
- });
- it('should display active file and other open files', async () => {
- vi.mocked(ideContext.getIdeContext).mockReturnValue({
- workspaceState: {
- openFiles: [
- {
- path: '/path/to/my-file.ts',
- isActive: true,
- selectedText: 'hello',
- timestamp: 0,
- },
- {
- path: '/path/to/another-file.ts',
- isActive: false,
- timestamp: 1,
- },
- {
- path: '/path/to/third-file.ts',
- isActive: false,
- timestamp: 2,
- },
- ],
- },
- });
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- expect(lastFrame()).toContain('3 open files (ctrl+g to view)');
- });
-
- it('should display active file and other context', async () => {
- vi.mocked(ideContext.getIdeContext).mockReturnValue({
- workspaceState: {
- openFiles: [
- {
- path: '/path/to/my-file.ts',
- isActive: true,
- selectedText: 'hello',
- timestamp: 0,
- },
- ],
- },
- });
- mockConfig.getGeminiMdFileCount.mockReturnValue(1);
- mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- expect(lastFrame()).toContain(
- 'Using: 1 open file (ctrl+g to view) | 1 GEMINI.md file',
- );
- });
-
- it('should not display context summary when hideContextSummary is true', async () => {
- mockSettings = createMockSettings({
- workspace: {
- ui: { hideContextSummary: true },
- },
- });
- vi.mocked(ideContext.getIdeContext).mockReturnValue({
- workspaceState: {
- openFiles: [
- {
- path: '/path/to/my-file.ts',
- isActive: true,
- selectedText: 'hello',
- timestamp: 0,
- },
- ],
- },
- });
- mockConfig.getGeminiMdFileCount.mockReturnValue(1);
- mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- const output = lastFrame();
- expect(output).not.toContain('Using:');
- expect(output).not.toContain('open file');
- expect(output).not.toContain('GEMINI.md file');
- });
-
- it('should display default "GEMINI.md" in footer when contextFileName is not set and count is 1', async () => {
- mockConfig.getGeminiMdFileCount.mockReturnValue(1);
- mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
- // For this test, ensure showMemoryUsage is false or debugMode is false if it relies on that
- mockConfig.getDebugMode.mockReturnValue(false);
- mockConfig.getShowMemoryUsage.mockReturnValue(false);
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve(); // Wait for any async updates
- expect(lastFrame()).toContain('Using: 1 GEMINI.md file');
- });
-
- it('should display default "GEMINI.md" with plural when contextFileName is not set and count is > 1', async () => {
- mockConfig.getGeminiMdFileCount.mockReturnValue(2);
- mockConfig.getAllGeminiMdFilenames.mockReturnValue([
- 'GEMINI.md',
- 'GEMINI.md',
- ]);
- mockConfig.getDebugMode.mockReturnValue(false);
- mockConfig.getShowMemoryUsage.mockReturnValue(false);
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- expect(lastFrame()).toContain('Using: 2 GEMINI.md files');
- });
-
- it('should display custom contextFileName in footer when set and count is 1', async () => {
- mockSettings = createMockSettings({
- workspace: {
- context: { fileName: 'AGENTS.md' },
- ui: { theme: 'Default' },
- },
- });
- mockConfig.getGeminiMdFileCount.mockReturnValue(1);
- mockConfig.getAllGeminiMdFilenames.mockReturnValue(['AGENTS.md']);
- mockConfig.getDebugMode.mockReturnValue(false);
- mockConfig.getShowMemoryUsage.mockReturnValue(false);
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- expect(lastFrame()).toContain('Using: 1 AGENTS.md file');
- });
-
- it('should display a generic message when multiple context files with different names are provided', async () => {
- mockSettings = createMockSettings({
- workspace: {
- context: { fileName: ['AGENTS.md', 'CONTEXT.md'] },
- ui: { theme: 'Default' },
- },
- });
- mockConfig.getGeminiMdFileCount.mockReturnValue(2);
- mockConfig.getAllGeminiMdFilenames.mockReturnValue([
- 'AGENTS.md',
- 'CONTEXT.md',
- ]);
- mockConfig.getDebugMode.mockReturnValue(false);
- mockConfig.getShowMemoryUsage.mockReturnValue(false);
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- expect(lastFrame()).toContain('Using: 2 context files');
- });
-
- it('should display custom contextFileName with plural when set and count is > 1', async () => {
- mockSettings = createMockSettings({
- workspace: {
- context: { fileName: 'MY_NOTES.TXT' },
- ui: { theme: 'Default' },
- },
- });
- mockConfig.getGeminiMdFileCount.mockReturnValue(3);
- mockConfig.getAllGeminiMdFilenames.mockReturnValue([
- 'MY_NOTES.TXT',
- 'MY_NOTES.TXT',
- 'MY_NOTES.TXT',
- ]);
- mockConfig.getDebugMode.mockReturnValue(false);
- mockConfig.getShowMemoryUsage.mockReturnValue(false);
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- expect(lastFrame()).toContain('Using: 3 MY_NOTES.TXT files');
- });
-
- it('should not display context file message if count is 0, even if contextFileName is set', async () => {
- mockSettings = createMockSettings({
- workspace: {
- context: { fileName: 'ANY_FILE.MD' },
- ui: { theme: 'Default' },
- },
- });
- mockConfig.getGeminiMdFileCount.mockReturnValue(0);
- mockConfig.getAllGeminiMdFilenames.mockReturnValue([]);
- mockConfig.getDebugMode.mockReturnValue(false);
- mockConfig.getShowMemoryUsage.mockReturnValue(false);
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- expect(lastFrame()).not.toContain('ANY_FILE.MD');
- });
-
- it('should display GEMINI.md and MCP server count when both are present', async () => {
- mockConfig.getGeminiMdFileCount.mockReturnValue(2);
- mockConfig.getAllGeminiMdFilenames.mockReturnValue([
- 'GEMINI.md',
- 'GEMINI.md',
- ]);
- mockConfig.getMcpServers.mockReturnValue({
- server1: {} as MCPServerConfig,
- });
- mockConfig.getDebugMode.mockReturnValue(false);
- mockConfig.getShowMemoryUsage.mockReturnValue(false);
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- expect(lastFrame()).toContain('1 MCP server');
- });
-
- it('should display only MCP server count when GEMINI.md count is 0', async () => {
- mockConfig.getGeminiMdFileCount.mockReturnValue(0);
- mockConfig.getAllGeminiMdFilenames.mockReturnValue([]);
- mockConfig.getMcpServers.mockReturnValue({
- server1: {} as MCPServerConfig,
- server2: {} as MCPServerConfig,
- });
- mockConfig.getDebugMode.mockReturnValue(false);
- mockConfig.getShowMemoryUsage.mockReturnValue(false);
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- expect(lastFrame()).toContain('Using: 2 MCP servers (ctrl+t to view)');
- });
-
- it('should display Tips component by default', async () => {
- const { unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- expect(vi.mocked(Tips)).toHaveBeenCalled();
- });
-
- it('should not display Tips component when hideTips is true', async () => {
- mockSettings = createMockSettings({
- workspace: {
- ui: { hideTips: true },
- },
- });
-
- const { unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- expect(vi.mocked(Tips)).not.toHaveBeenCalled();
- });
-
- it('should display Header component by default', async () => {
- const { Header } = await import('./components/Header.js');
- const { unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- expect(vi.mocked(Header)).toHaveBeenCalled();
- });
-
- it('should not display Header component when hideBanner is true', async () => {
- const { Header } = await import('./components/Header.js');
- mockSettings = createMockSettings({
- user: { ui: { hideBanner: true } },
- });
-
- const { unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- expect(vi.mocked(Header)).not.toHaveBeenCalled();
- });
-
- it('should display Footer component by default', async () => {
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- // Footer should render - look for target directory which is always shown
- expect(lastFrame()).toContain('/test/dir');
- });
-
- it('should not display Footer component when hideFooter is true', async () => {
- mockSettings = createMockSettings({
- user: { ui: { hideFooter: true } },
- });
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- // Footer should not render - target directory should not appear
- expect(lastFrame()).not.toContain('/test/dir');
- });
-
- it('should show footer if system says show, but workspace and user settings say hide', async () => {
- mockSettings = createMockSettings({
- system: { ui: { hideFooter: false } },
- user: { ui: { hideFooter: true } },
- workspace: { ui: { hideFooter: true } },
- });
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- // Footer should render because system overrides - look for target directory
- expect(lastFrame()).toContain('/test/dir');
- });
-
- it('should show tips if system says show, but workspace and user settings say hide', async () => {
- mockSettings = createMockSettings({
- system: { ui: { hideTips: false } },
- user: { ui: { hideTips: true } },
- workspace: { ui: { hideTips: true } },
- });
-
- const { unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- expect(vi.mocked(Tips)).toHaveBeenCalled();
- });
-
- describe('when no theme is set', () => {
- let originalNoColor: string | undefined;
-
- beforeEach(() => {
- originalNoColor = process.env.NO_COLOR;
- // Ensure no theme is set for these tests
- mockSettings = createMockSettings({});
- mockConfig.getDebugMode.mockReturnValue(false);
- mockConfig.getShowMemoryUsage.mockReturnValue(false);
- });
-
- afterEach(() => {
- process.env.NO_COLOR = originalNoColor;
- });
-
- it('should display theme dialog if NO_COLOR is not set', async () => {
- delete process.env.NO_COLOR;
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- expect(lastFrame()).toContain('(esc to cancel');
- });
-
- it('should display a message if NO_COLOR is set', async () => {
- process.env.NO_COLOR = 'true';
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- expect(lastFrame()).toContain('(esc to cancel');
- expect(lastFrame()).not.toContain('Select Theme');
- });
- });
-
- it('should render the initial UI correctly', () => {
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- expect(lastFrame()).toMatchSnapshot();
- });
-
- it('should render correctly with the prompt input box', () => {
- vi.mocked(useGeminiStream).mockReturnValue({
- streamingState: StreamingState.Idle,
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
- thought: null,
- });
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- expect(lastFrame()).toMatchSnapshot();
- });
-
- describe('with initial prompt from --prompt-interactive', () => {
- it('should submit the initial prompt automatically', async () => {
- const mockSubmitQuery = vi.fn();
-
- mockConfig.getQuestion = vi.fn(() => 'hello from prompt-interactive');
-
- vi.mocked(useGeminiStream).mockReturnValue({
- streamingState: StreamingState.Idle,
- submitQuery: mockSubmitQuery,
- initError: null,
- pendingHistoryItems: [],
- thought: null,
- });
-
- mockConfig.getGeminiClient.mockReturnValue({
- isInitialized: vi.fn(() => true),
- getUserTier: vi.fn(),
- } as unknown as GeminiClient);
-
- const { unmount, rerender } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- // Force a re-render to trigger useEffect
- rerender(
- ,
- );
-
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- expect(mockSubmitQuery).toHaveBeenCalledWith(
- 'hello from prompt-interactive',
- );
- });
- });
-
- describe('errorCount', () => {
- it('should correctly sum the counts of error messages', async () => {
- const mockConsoleMessages: ConsoleMessageItem[] = [
- { type: 'error', content: 'First error', count: 1 },
- { type: 'log', content: 'some log', count: 1 },
- { type: 'error', content: 'Second error', count: 3 },
- { type: 'warn', content: 'a warning', count: 1 },
- { type: 'error', content: 'Third error', count: 1 },
- ];
-
- vi.mocked(useConsoleMessages).mockReturnValue({
- consoleMessages: mockConsoleMessages,
- handleNewMessage: vi.fn(),
- clearConsoleMessages: vi.fn(),
- });
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
-
- // Total error count should be 1 + 3 + 1 = 5
- expect(lastFrame()).toContain('5 errors');
- });
- });
-
- describe('when in a narrow terminal', () => {
- it('should render with a column layout', () => {
- vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
- columns: 60,
- rows: 24,
- });
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- expect(lastFrame()).toMatchSnapshot();
- });
- });
-
- describe('NO_COLOR smoke test', () => {
- let originalNoColor: string | undefined;
-
- beforeEach(() => {
- originalNoColor = process.env.NO_COLOR;
- });
-
- afterEach(() => {
- process.env.NO_COLOR = originalNoColor;
- });
-
- it('should render without errors when NO_COLOR is set', async () => {
- process.env.NO_COLOR = 'true';
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- expect(lastFrame()).toBeTruthy();
- expect(lastFrame()).toContain('Type your message or @path/to/file');
- });
- });
-
- describe('FolderTrustDialog', () => {
- it('should display the folder trust dialog when isFolderTrustDialogOpen is true', async () => {
- const { useFolderTrust } = await import('./hooks/useFolderTrust.js');
- vi.mocked(useFolderTrust).mockReturnValue({
- isFolderTrustDialogOpen: true,
- handleFolderTrustSelect: vi.fn(),
- });
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- expect(lastFrame()).toContain('Do you trust this folder?');
- });
-
- it('should display the folder trust dialog when the feature is enabled but the folder is not trusted', async () => {
- const { useFolderTrust } = await import('./hooks/useFolderTrust.js');
- vi.mocked(useFolderTrust).mockReturnValue({
- isFolderTrustDialogOpen: true,
- handleFolderTrustSelect: vi.fn(),
- });
- mockConfig.isTrustedFolder.mockReturnValue(false);
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- expect(lastFrame()).toContain('Do you trust this folder?');
- });
-
- it('should not display the folder trust dialog when the feature is disabled', async () => {
- const { useFolderTrust } = await import('./hooks/useFolderTrust.js');
- vi.mocked(useFolderTrust).mockReturnValue({
- isFolderTrustDialogOpen: false,
- handleFolderTrustSelect: vi.fn(),
- });
- mockConfig.isTrustedFolder.mockReturnValue(false);
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
- await Promise.resolve();
- expect(lastFrame()).not.toContain('Do you trust this folder?');
- });
- });
-
- describe('Message Queuing', () => {
- let mockSubmitQuery: typeof vi.fn;
-
- beforeEach(() => {
- mockSubmitQuery = vi.fn();
- vi.useFakeTimers();
- });
-
- afterEach(() => {
- vi.useRealTimers();
- });
-
- it('should queue messages when handleFinalSubmit is called during streaming', () => {
- vi.mocked(useGeminiStream).mockReturnValue({
- streamingState: StreamingState.Responding,
- submitQuery: mockSubmitQuery,
- initError: null,
- pendingHistoryItems: [],
- thought: null,
- });
-
- const { unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- // The message should not be sent immediately during streaming
- expect(mockSubmitQuery).not.toHaveBeenCalled();
- });
-
- it('should auto-send queued messages when transitioning from Responding to Idle', async () => {
- const mockSubmitQueryFn = vi.fn();
-
- // Start with Responding state
- vi.mocked(useGeminiStream).mockReturnValue({
- streamingState: StreamingState.Responding,
- submitQuery: mockSubmitQueryFn,
- initError: null,
- pendingHistoryItems: [],
- thought: null,
- });
-
- const { unmount, rerender } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- // Simulate the hook returning Idle state (streaming completed)
- vi.mocked(useGeminiStream).mockReturnValue({
- streamingState: StreamingState.Idle,
- submitQuery: mockSubmitQueryFn,
- initError: null,
- pendingHistoryItems: [],
- thought: null,
- });
-
- // Rerender to trigger the useEffect with new state
- rerender(
- ,
- );
-
- // The effect uses setTimeout(100ms) before sending
- await vi.advanceTimersByTimeAsync(100);
-
- // Note: In the actual implementation, messages would be queued first
- // This test verifies the auto-send mechanism works when state transitions
- });
-
- it('should display queued messages with dimmed color', () => {
- // This test would require being able to simulate handleFinalSubmit
- // and then checking the rendered output for the queued messages
- // with the ▸ prefix and dimColor styling
-
- vi.mocked(useGeminiStream).mockReturnValue({
- streamingState: StreamingState.Responding,
- submitQuery: mockSubmitQuery,
- initError: null,
- pendingHistoryItems: [],
- thought: 'Processing...',
- });
-
- const { unmount, lastFrame } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- // The actual queued messages display is tested visually
- // since we need to trigger handleFinalSubmit which is internal
- const output = lastFrame();
- expect(output).toBeDefined();
- });
-
- it('should clear message queue after sending', async () => {
- const mockSubmitQueryFn = vi.fn();
-
- // Start with idle to allow message queue to process
- vi.mocked(useGeminiStream).mockReturnValue({
- streamingState: StreamingState.Idle,
- submitQuery: mockSubmitQueryFn,
- initError: null,
- pendingHistoryItems: [],
- thought: null,
- });
-
- const { unmount, lastFrame } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- // After sending, the queue should be cleared
- // This is handled internally by setMessageQueue([]) in the useEffect
- await vi.advanceTimersByTimeAsync(100);
-
- // Verify the component renders without errors
- expect(lastFrame()).toBeDefined();
- });
-
- it('should handle empty messages by filtering them out', () => {
- // The handleFinalSubmit function trims and checks if length > 0
- // before adding to queue, so empty messages are filtered
-
- vi.mocked(useGeminiStream).mockReturnValue({
- streamingState: StreamingState.Idle,
- submitQuery: mockSubmitQuery,
- initError: null,
- pendingHistoryItems: [],
- thought: null,
- });
-
- const { unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- // Empty or whitespace-only messages won't be added to queue
- // This is enforced by the trimmedValue.length > 0 check
- expect(mockSubmitQuery).not.toHaveBeenCalled();
- });
-
- it('should combine multiple queued messages with double newlines', async () => {
- // This test verifies that when multiple messages are queued,
- // they are combined with '\n\n' as the separator
-
- const mockSubmitQueryFn = vi.fn();
-
- vi.mocked(useGeminiStream).mockReturnValue({
- streamingState: StreamingState.Idle,
- submitQuery: mockSubmitQueryFn,
- initError: null,
- pendingHistoryItems: [],
- thought: null,
- });
-
- const { unmount, lastFrame } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- // The combining logic uses messageQueue.join('\n\n')
- // This is tested by the implementation in the useEffect
- await vi.advanceTimersByTimeAsync(100);
-
- expect(lastFrame()).toBeDefined();
- });
-
- it('should limit displayed messages to MAX_DISPLAYED_QUEUED_MESSAGES', () => {
- // This test verifies the display logic handles multiple messages correctly
- // by checking that the MAX_DISPLAYED_QUEUED_MESSAGES constant is respected
-
- vi.mocked(useGeminiStream).mockReturnValue({
- streamingState: StreamingState.Responding,
- submitQuery: mockSubmitQuery,
- initError: null,
- pendingHistoryItems: [],
- thought: 'Processing...',
- });
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- const output = lastFrame();
-
- // Verify the display logic exists and can handle multiple messages
- // The actual queue behavior is tested in the useMessageQueue hook tests
- expect(output).toBeDefined();
-
- // Check that the component renders without errors when there are messages to display
- expect(output).not.toContain('Error');
- });
-
- it('should render message queue display without errors', () => {
- // Test that the message queue display logic renders correctly
- // This verifies the UI changes for performance improvements work
-
- vi.mocked(useGeminiStream).mockReturnValue({
- streamingState: StreamingState.Responding,
- submitQuery: mockSubmitQuery,
- initError: null,
- pendingHistoryItems: [],
- thought: 'Processing...',
- });
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- const output = lastFrame();
-
- // Verify component renders without errors
- expect(output).toBeDefined();
- expect(output).not.toContain('Error');
-
- // Verify the component structure is intact (loading indicator should be present)
- expect(output).toContain('esc to cancel');
- });
- });
-
- describe('debug keystroke logging', () => {
- let consoleLogSpy: ReturnType;
-
- beforeEach(() => {
- consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
- });
-
- afterEach(() => {
- consoleLogSpy.mockRestore();
- });
-
- it('should pass debugKeystrokeLogging setting to KeypressProvider', () => {
- const mockSettingsWithDebug = createMockSettings({
- workspace: {
- ui: { theme: 'Default' },
- advanced: { debugKeystrokeLogging: true },
- },
- });
-
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- const output = lastFrame();
-
- expect(output).toBeDefined();
- expect(mockSettingsWithDebug.merged.advanced?.debugKeystrokeLogging).toBe(
- true,
- );
- });
-
- it('should use default false value when debugKeystrokeLogging is not set', () => {
- const { lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- const output = lastFrame();
-
- expect(output).toBeDefined();
- expect(
- mockSettings.merged.advanced?.debugKeystrokeLogging,
- ).toBeUndefined();
- });
- });
-
- describe('Ctrl+C behavior', () => {
- it('should call cancel but only clear the prompt when a tool is executing', async () => {
- const mockCancel = vi.fn();
- let onCancelSubmitCallback = () => {};
-
- // Simulate a tool in the "Executing" state.
- vi.mocked(useGeminiStream).mockImplementation(
- (
- _client,
- _history,
- _addItem,
- _config,
- _settings,
- _onDebugMessage,
- _handleSlashCommand,
- _shellModeActive,
- _getPreferredEditor,
- _onAuthError,
- _performMemoryRefresh,
- _modelSwitchedFromQuotaError,
- _setModelSwitchedFromQuotaError,
- _onEditorClose,
- onCancelSubmit, // Capture the cancel callback from App.tsx
- ) => {
- onCancelSubmitCallback = onCancelSubmit;
- return {
- streamingState: StreamingState.Responding,
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [
- {
- type: 'tool_group',
- tools: [
- {
- name: 'test_tool',
- status: 'Executing',
- result: '',
- args: {},
- },
- ],
- },
- ],
- thought: null,
- cancelOngoingRequest: () => {
- mockCancel();
- onCancelSubmitCallback(); // <--- This is the key change
- },
- };
- },
- );
-
- const { stdin, lastFrame, unmount } = renderWithProviders(
- ,
- );
- currentUnmount = unmount;
-
- // Simulate user typing something into the prompt while a tool is running.
- stdin.write('some text');
- await new Promise((resolve) => setTimeout(resolve, 100));
-
- // Verify the text is in the prompt.
- expect(lastFrame()).toContain('some text');
-
- // Simulate Ctrl+C.
- stdin.write('\x03');
- await new Promise((resolve) => setTimeout(resolve, 100));
-
- // The main cancellation handler SHOULD be called.
- expect(mockCancel).toHaveBeenCalled();
-
- // The prompt should now be empty as a result of the cancellation handler's logic.
- // We can't directly test the buffer's state, but we can see the rendered output.
- await waitFor(() => {
- expect(lastFrame()).not.toContain('some text');
- });
- });
+ expect(lastFrame()).toContain('MainContent');
+ expect(lastFrame()).toContain('Notifications');
+ expect(lastFrame()).toContain('DialogManager');
});
});
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index 28bf124895..65ffd9f5bc 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -4,1408 +4,31 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
-import {
- Box,
- type DOMElement,
- measureElement,
- Static,
- Text,
- useStdin,
- useStdout,
-} from 'ink';
-import {
- AuthState,
- StreamingState,
- type HistoryItem,
- MessageType,
- ToolCallStatus,
- type HistoryItemWithoutId,
-} from './types.js';
-import { useTerminalSize } from './hooks/useTerminalSize.js';
-import { useGeminiStream } from './hooks/useGeminiStream.js';
-import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
-import { useThemeCommand } from './hooks/useThemeCommand.js';
-import { useAuthCommand } from './auth/useAuth.js';
-import { useFolderTrust } from './hooks/useFolderTrust.js';
-import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
-import { useEditorSettings } from './hooks/useEditorSettings.js';
-import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
-import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
-import { useMessageQueue } from './hooks/useMessageQueue.js';
-import { useConsoleMessages } from './hooks/useConsoleMessages.js';
-import { Header } from './components/Header.js';
-import { LoadingIndicator } from './components/LoadingIndicator.js';
-import { AutoAcceptIndicator } from './components/AutoAcceptIndicator.js';
-import { ShellModeIndicator } from './components/ShellModeIndicator.js';
-import { InputPrompt } from './components/InputPrompt.js';
-import { Footer } from './components/Footer.js';
-import { ThemeDialog } from './components/ThemeDialog.js';
-import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
-import { FolderTrustDialog } from './components/FolderTrustDialog.js';
-import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js';
-import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js';
-import { Colors } from './colors.js';
-import { loadHierarchicalGeminiMemory } from '../config/config.js';
-import type { LoadedSettings } from '../config/settings.js';
-import { SettingScope } from '../config/settings.js';
-import { Tips } from './components/Tips.js';
-import { ConsolePatcher } from './utils/ConsolePatcher.js';
-import { registerCleanup } from '../utils/cleanup.js';
-import { DetailedMessagesDisplay } from './components/DetailedMessagesDisplay.js';
-import { HistoryItemDisplay } from './components/HistoryItemDisplay.js';
-import { ContextSummaryDisplay } from './components/ContextSummaryDisplay.js';
-import { useHistory } from './hooks/useHistoryManager.js';
-import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
-import process from 'node:process';
-import type {
- EditorType,
- Config,
- IdeContext,
- DetectedIde,
-} from '@google/gemini-cli-core';
-import {
- ApprovalMode,
- getAllGeminiMdFilenames,
- isEditorAvailable,
- getErrorMessage,
- AuthType,
- logFlashFallback,
- FlashFallbackEvent,
- ideContext,
- isProQuotaExceededError,
- isGenericQuotaExceededError,
- UserTierId,
- DEFAULT_GEMINI_FLASH_MODEL,
- IdeClient,
-} from '@google/gemini-cli-core';
-import type { IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
-import { IdeIntegrationNudge } from './IdeIntegrationNudge.js';
-import { useLogger } from './hooks/useLogger.js';
+import { Box } from 'ink';
import { StreamingContext } from './contexts/StreamingContext.js';
-import {
- SessionStatsProvider,
- useSessionStats,
-} from './contexts/SessionContext.js';
-import { useGitBranchName } from './hooks/useGitBranchName.js';
-import { useFocus } from './hooks/useFocus.js';
-import { useBracketedPaste } from './hooks/useBracketedPaste.js';
-import { useTextBuffer } from './components/shared/text-buffer.js';
-import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js';
-import { useVim } from './hooks/vim.js';
-import type { Key } from './hooks/useKeypress.js';
-import { useKeypress } from './hooks/useKeypress.js';
-import { KeypressProvider } from './contexts/KeypressContext.js';
-import { useKittyKeyboardProtocol } from './hooks/useKittyKeyboardProtocol.js';
-import { keyMatchers, Command } from './keyMatchers.js';
-import * as fs from 'node:fs';
-import { UpdateNotification } from './components/UpdateNotification.js';
-import type { UpdateObject } from './utils/updateCheck.js';
-import ansiEscapes from 'ansi-escapes';
-import { OverflowProvider } from './contexts/OverflowContext.js';
-import { ShowMoreLines } from './components/ShowMoreLines.js';
-import { PrivacyNotice } from './privacy/PrivacyNotice.js';
-import { useSettingsCommand } from './hooks/useSettingsCommand.js';
-import { SettingsDialog } from './components/SettingsDialog.js';
-import { ProQuotaDialog } from './components/ProQuotaDialog.js';
-import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
-import { appEvents, AppEvent } from '../utils/events.js';
-import { isNarrowWidth } from './utils/isNarrowWidth.js';
-import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js';
-import { WorkspaceMigrationDialog } from './components/WorkspaceMigrationDialog.js';
-import { isWorkspaceTrusted } from '../config/trustedFolders.js';
-import { AuthInProgress } from './auth/AuthInProgress.js';
-import { AuthDialog } from './auth/AuthDialog.js';
-
-const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
-// Maximum number of queued messages to display in UI to prevent performance issues
-const MAX_DISPLAYED_QUEUED_MESSAGES = 3;
-
-interface AppProps {
- config: Config;
- settings: LoadedSettings;
- startupWarnings?: string[];
- version: string;
-}
-
-function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
- return pendingHistoryItems.some((item) => {
- if (item && item.type === 'tool_group') {
- return item.tools.some(
- (tool) => ToolCallStatus.Executing === tool.status,
- );
- }
- return false;
- });
-}
-
-export const AppWrapper = (props: AppProps) => {
- const kittyProtocolStatus = useKittyKeyboardProtocol();
- return (
-
-
-
-
-
-
-
- );
-};
-
-const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
- const isFocused = useFocus();
- useBracketedPaste();
- const [updateInfo, setUpdateInfo] = useState(null);
- const { stdout } = useStdout();
- const nightly = version.includes('nightly');
- const { history, addItem, clearItems, loadHistory } = useHistory();
-
- const [idePromptAnswered, setIdePromptAnswered] = useState(false);
- const [currentIDE, setCurrentIDE] = useState();
-
- useEffect(() => {
- (async () => {
- const ideClient = await IdeClient.getInstance();
- setCurrentIDE(ideClient.getCurrentIde());
- })();
- registerCleanup(async () => {
- const ideClient = await IdeClient.getInstance();
- ideClient.disconnect();
- });
- }, [config]);
-
- const shouldShowIdePrompt =
- currentIDE &&
- !config.getIdeMode() &&
- !settings.merged.ide?.hasSeenNudge &&
- !idePromptAnswered;
-
- useEffect(() => {
- const cleanup = setUpdateHandler(addItem, setUpdateInfo);
- return cleanup;
- }, [addItem]);
-
- const {
- consoleMessages,
- handleNewMessage,
- clearConsoleMessages: clearConsoleMessagesState,
- } = useConsoleMessages();
-
- useEffect(() => {
- const consolePatcher = new ConsolePatcher({
- onNewMessage: handleNewMessage,
- debugMode: config.getDebugMode(),
- });
- consolePatcher.patch();
- registerCleanup(consolePatcher.cleanup);
- }, [handleNewMessage, config]);
-
- const { stats: sessionStats } = useSessionStats();
- const [staticNeedsRefresh, setStaticNeedsRefresh] = useState(false);
- const [staticKey, setStaticKey] = useState(0);
- const refreshStatic = useCallback(() => {
- stdout.write(ansiEscapes.clearTerminal);
- setStaticKey((prev) => prev + 1);
- }, [setStaticKey, stdout]);
-
- const [geminiMdFileCount, setGeminiMdFileCount] = useState(0);
- const [debugMessage, setDebugMessage] = useState('');
- const [themeError, setThemeError] = useState(null);
-
- const [editorError, setEditorError] = useState(null);
- const [footerHeight, setFooterHeight] = useState(0);
- const [corgiMode, setCorgiMode] = useState(false);
- const [isTrustedFolderState, setIsTrustedFolder] = useState(
- isWorkspaceTrusted(settings.merged),
- );
- const [currentModel, setCurrentModel] = useState(config.getModel());
- const [shellModeActive, setShellModeActive] = useState(false);
- const [showErrorDetails, setShowErrorDetails] = useState(false);
- const [showToolDescriptions, setShowToolDescriptions] =
- useState(false);
-
- const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false);
- const [quittingMessages, setQuittingMessages] = useState<
- HistoryItem[] | null
- >(null);
- const ctrlCTimerRef = useRef(null);
- const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false);
- const ctrlDTimerRef = useRef(null);
- const [constrainHeight, setConstrainHeight] = useState(true);
- const [showPrivacyNotice, setShowPrivacyNotice] = useState(false);
- const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
- useState(false);
- const [userTier, setUserTier] = useState(undefined);
- const [ideContextState, setIdeContextState] = useState<
- IdeContext | undefined
- >();
- const [showEscapePrompt, setShowEscapePrompt] = useState(false);
- const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false);
- const [isProcessing, setIsProcessing] = useState(false);
-
- const {
- showWorkspaceMigrationDialog,
- workspaceExtensions,
- onWorkspaceMigrationDialogOpen,
- onWorkspaceMigrationDialogClose,
- } = useWorkspaceMigration(settings);
-
- const [isProQuotaDialogOpen, setIsProQuotaDialogOpen] = useState(false);
- const [proQuotaDialogResolver, setProQuotaDialogResolver] = useState<
- ((value: boolean) => void) | null
- >(null);
-
- useEffect(() => {
- const unsubscribe = ideContext.subscribeToIdeContext(setIdeContextState);
- // Set the initial value
- setIdeContextState(ideContext.getIdeContext());
- return unsubscribe;
- }, []);
-
- useEffect(() => {
- const openDebugConsole = () => {
- setShowErrorDetails(true);
- setConstrainHeight(false); // Make sure the user sees the full message.
- };
- appEvents.on(AppEvent.OpenDebugConsole, openDebugConsole);
-
- const logErrorHandler = (errorMessage: unknown) => {
- handleNewMessage({
- type: 'error',
- content: String(errorMessage),
- count: 1,
- });
- };
- appEvents.on(AppEvent.LogError, logErrorHandler);
-
- return () => {
- appEvents.off(AppEvent.OpenDebugConsole, openDebugConsole);
- appEvents.off(AppEvent.LogError, logErrorHandler);
- };
- }, [handleNewMessage]);
-
- const openPrivacyNotice = useCallback(() => {
- setShowPrivacyNotice(true);
- }, []);
-
- const handleEscapePromptChange = useCallback((showPrompt: boolean) => {
- setShowEscapePrompt(showPrompt);
- }, []);
-
- const initialPromptSubmitted = useRef(false);
-
- const errorCount = useMemo(
- () =>
- consoleMessages
- .filter((msg) => msg.type === 'error')
- .reduce((total, msg) => total + msg.count, 0),
- [consoleMessages],
- );
-
- const {
- isThemeDialogOpen,
- openThemeDialog,
- handleThemeSelect,
- handleThemeHighlight,
- } = useThemeCommand(settings, setThemeError, addItem);
-
- const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
- useSettingsCommand();
-
- const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
- useFolderTrust(settings, setIsTrustedFolder);
-
- const { needsRestart: ideNeedsRestart } = useIdeTrustListener();
- useEffect(() => {
- if (ideNeedsRestart) {
- // IDE trust changed, force a restart.
- setShowIdeRestartPrompt(true);
- }
- }, [ideNeedsRestart]);
-
- useKeypress(
- (key) => {
- if (key.name === 'r' || key.name === 'R') {
- process.exit(0);
- }
- },
- { isActive: showIdeRestartPrompt },
- );
-
- const { authState, setAuthState, authError, onAuthError } = useAuthCommand(
- settings,
- config,
- );
-
- // Sync user tier from config when authentication changes
- useEffect(() => {
- // Only sync when not currently authenticating
- if (authState === AuthState.Authenticated) {
- setUserTier(config.getGeminiClient()?.getUserTier());
- }
- }, [config, authState]);
-
- const {
- isEditorDialogOpen,
- openEditorDialog,
- handleEditorSelect,
- exitEditorDialog,
- } = useEditorSettings(settings, setEditorError, addItem);
-
- const toggleCorgiMode = useCallback(() => {
- setCorgiMode((prev) => !prev);
- }, []);
-
- const performMemoryRefresh = useCallback(async () => {
- addItem(
- {
- type: MessageType.INFO,
- text: 'Refreshing hierarchical memory (GEMINI.md or other context files)...',
- },
- Date.now(),
- );
- try {
- const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
- process.cwd(),
- settings.merged.context?.loadMemoryFromIncludeDirectories
- ? config.getWorkspaceContext().getDirectories()
- : [],
- config.getDebugMode(),
- config.getFileService(),
- settings.merged,
- config.getExtensionContextFilePaths(),
- config.getFolderTrust(),
- settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree'
- config.getFileFilteringOptions(),
- );
-
- config.setUserMemory(memoryContent);
- config.setGeminiMdFileCount(fileCount);
- setGeminiMdFileCount(fileCount);
-
- addItem(
- {
- type: MessageType.INFO,
- text: `Memory refreshed successfully. ${memoryContent.length > 0 ? `Loaded ${memoryContent.length} characters from ${fileCount} file(s).` : 'No memory content found.'}`,
- },
- Date.now(),
- );
- if (config.getDebugMode()) {
- console.log(
- `[DEBUG] Refreshed memory content in config: ${memoryContent.substring(0, 200)}...`,
- );
- }
- } catch (error) {
- const errorMessage = getErrorMessage(error);
- addItem(
- {
- type: MessageType.ERROR,
- text: `Error refreshing memory: ${errorMessage}`,
- },
- Date.now(),
- );
- console.error('Error refreshing memory:', error);
- }
- }, [config, addItem, settings.merged]);
-
- // Watch for model changes (e.g., from Flash fallback)
- useEffect(() => {
- const checkModelChange = () => {
- const configModel = config.getModel();
- if (configModel !== currentModel) {
- setCurrentModel(configModel);
- }
- };
-
- // Check immediately and then periodically
- checkModelChange();
- const interval = setInterval(checkModelChange, 1000); // Check every second
-
- return () => clearInterval(interval);
- }, [config, currentModel]);
-
- // Set up Flash fallback handler
- useEffect(() => {
- const flashFallbackHandler = async (
- currentModel: string,
- fallbackModel: string,
- error?: unknown,
- ): Promise => {
- // Check if we've already switched to the fallback model
- if (config.isInFallbackMode()) {
- // If we're already in fallback mode, don't show the dialog again
- return false;
- }
-
- let message: string;
-
- if (
- config.getContentGeneratorConfig().authType ===
- AuthType.LOGIN_WITH_GOOGLE
- ) {
- // Use actual user tier if available; otherwise, default to FREE tier behavior (safe default)
- const isPaidTier =
- userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD;
-
- // Check if this is a Pro quota exceeded error
- if (error && isProQuotaExceededError(error)) {
- if (isPaidTier) {
- message = `⚡ You have reached your daily ${currentModel} quota limit.
-⚡ You can choose to authenticate with a paid API key or continue with the fallback model.
-⚡ To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
- } else {
- message = `⚡ You have reached your daily ${currentModel} quota limit.
-⚡ You can choose to authenticate with a paid API key or continue with the fallback model.
-⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
-⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
-⚡ You can switch authentication methods by typing /auth`;
- }
- } else if (error && isGenericQuotaExceededError(error)) {
- if (isPaidTier) {
- message = `⚡ You have reached your daily quota limit.
-⚡ Automatically switching from ${currentModel} to ${fallbackModel} for the remainder of this session.
-⚡ To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
- } else {
- message = `⚡ You have reached your daily quota limit.
-⚡ Automatically switching from ${currentModel} to ${fallbackModel} for the remainder of this session.
-⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
-⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
-⚡ You can switch authentication methods by typing /auth`;
- }
- } else {
- if (isPaidTier) {
- // Default fallback message for other cases (like consecutive 429s)
- message = `⚡ Automatically switching from ${currentModel} to ${fallbackModel} for faster responses for the remainder of this session.
-⚡ Possible reasons for this are that you have received multiple consecutive capacity errors or you have reached your daily ${currentModel} quota limit
-⚡ To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
- } else {
- // Default fallback message for other cases (like consecutive 429s)
- message = `⚡ Automatically switching from ${currentModel} to ${fallbackModel} for faster responses for the remainder of this session.
-⚡ Possible reasons for this are that you have received multiple consecutive capacity errors or you have reached your daily ${currentModel} quota limit
-⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
-⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
-⚡ You can switch authentication methods by typing /auth`;
- }
- }
-
- // Add message to UI history
- addItem(
- {
- type: MessageType.INFO,
- text: message,
- },
- Date.now(),
- );
-
- // For Pro quota errors, show the dialog and wait for user's choice
- if (error && isProQuotaExceededError(error)) {
- // Set the flag to prevent tool continuation
- setModelSwitchedFromQuotaError(true);
- // Set global quota error flag to prevent Flash model calls
- config.setQuotaErrorOccurred(true);
-
- // Show the ProQuotaDialog and wait for user's choice
- const shouldContinueWithFallback = await new Promise(
- (resolve) => {
- setIsProQuotaDialogOpen(true);
- setProQuotaDialogResolver(() => resolve);
- },
- );
-
- // If user chose to continue with fallback, we don't need to stop the current prompt
- if (shouldContinueWithFallback) {
- // Switch to fallback model for future use
- config.setModel(fallbackModel);
- config.setFallbackMode(true);
- logFlashFallback(
- config,
- new FlashFallbackEvent(
- config.getContentGeneratorConfig().authType!,
- ),
- );
- return true; // Continue with current prompt using fallback model
- }
-
- // If user chose to authenticate, stop current prompt
- return false;
- }
-
- // For other quota errors, automatically switch to fallback model
- // Set the flag to prevent tool continuation
- setModelSwitchedFromQuotaError(true);
- // Set global quota error flag to prevent Flash model calls
- config.setQuotaErrorOccurred(true);
- }
-
- // Switch model for future use but return false to stop current retry
- config.setModel(fallbackModel);
- config.setFallbackMode(true);
- logFlashFallback(
- config,
- new FlashFallbackEvent(config.getContentGeneratorConfig().authType!),
- );
- return false; // Don't continue with current prompt
- };
-
- config.setFlashFallbackHandler(flashFallbackHandler);
- }, [config, addItem, userTier]);
-
- // Terminal and UI setup
- const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
- const isNarrow = isNarrowWidth(terminalWidth);
- const { stdin, setRawMode } = useStdin();
- const isInitialMount = useRef(true);
-
- const widthFraction = 0.9;
- const inputWidth = Math.max(
- 20,
- Math.floor(terminalWidth * widthFraction) - 3,
- );
- const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 0.8));
-
- // Utility callbacks
- const isValidPath = useCallback((filePath: string): boolean => {
- try {
- return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
- } catch (_e) {
- return false;
- }
- }, []);
-
- const getPreferredEditor = useCallback(() => {
- const editorType = settings.merged.general?.preferredEditor;
- const isValidEditor = isEditorAvailable(editorType);
- if (!isValidEditor) {
- openEditorDialog();
- return;
- }
- return editorType as EditorType;
- }, [settings, openEditorDialog]);
-
- // Core hooks and processors
- const {
- vimEnabled: vimModeEnabled,
- vimMode,
- toggleVimEnabled,
- } = useVimMode();
-
- const {
- handleSlashCommand,
- slashCommands,
- pendingHistoryItems: pendingSlashCommandHistoryItems,
- commandContext,
- shellConfirmationRequest,
- confirmationRequest,
- } = useSlashCommandProcessor(
- config,
- settings,
- addItem,
- clearItems,
- loadHistory,
- refreshStatic,
- setDebugMessage,
- openThemeDialog,
- setAuthState,
- openEditorDialog,
- toggleCorgiMode,
- setQuittingMessages,
- openPrivacyNotice,
- openSettingsDialog,
- toggleVimEnabled,
- setIsProcessing,
- setGeminiMdFileCount,
- );
-
- const buffer = useTextBuffer({
- initialText: '',
- viewport: { height: 10, width: inputWidth },
- stdin,
- setRawMode,
- isValidPath,
- shellModeActive,
- });
-
- // Independent input history management (unaffected by /clear)
- const inputHistoryStore = useInputHistoryStore();
-
- // Stable reference for cancel handler to avoid circular dependency
- const cancelHandlerRef = useRef<() => void>(() => {});
-
- const {
- streamingState,
- submitQuery,
- initError,
- pendingHistoryItems: pendingGeminiHistoryItems,
- thought,
- cancelOngoingRequest,
- } = useGeminiStream(
- config.getGeminiClient(),
- history,
- addItem,
- config,
- settings,
- setDebugMessage,
- handleSlashCommand,
- shellModeActive,
- getPreferredEditor,
- onAuthError,
- performMemoryRefresh,
- modelSwitchedFromQuotaError,
- setModelSwitchedFromQuotaError,
- refreshStatic,
- () => cancelHandlerRef.current(),
- );
-
- const pendingHistoryItems = useMemo(
- () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
- [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
- );
-
- // Message queue for handling input during streaming
- const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } =
- useMessageQueue({
- streamingState,
- submitQuery,
- });
-
- // Update the cancel handler with message queue support
- cancelHandlerRef.current = useCallback(() => {
- if (isToolExecuting(pendingHistoryItems)) {
- buffer.setText(''); // Just clear the prompt
- return;
- }
-
- const lastUserMessage = inputHistoryStore.inputHistory.at(-1);
- let textToSet = lastUserMessage || '';
-
- // Append queued messages if any exist
- const queuedText = getQueuedMessagesText();
- if (queuedText) {
- textToSet = textToSet ? `${textToSet}\n\n${queuedText}` : queuedText;
- clearQueue();
- }
-
- if (textToSet) {
- buffer.setText(textToSet);
- }
- }, [
- buffer,
- inputHistoryStore.inputHistory,
- getQueuedMessagesText,
- clearQueue,
- pendingHistoryItems,
- ]);
-
- // Input handling - queue messages for processing
- const handleFinalSubmit = useCallback(
- (submittedValue: string) => {
- const trimmedValue = submittedValue.trim();
- if (trimmedValue.length > 0) {
- // Add to independent input history
- inputHistoryStore.addInput(trimmedValue);
- }
- // Always add to message queue
- addMessage(submittedValue);
- },
- [addMessage, inputHistoryStore],
- );
-
- const handleIdePromptComplete = useCallback(
- (result: IdeIntegrationNudgeResult) => {
- if (result.userSelection === 'yes') {
- if (result.isExtensionPreInstalled) {
- handleSlashCommand('/ide enable');
- } else {
- handleSlashCommand('/ide install');
- }
- settings.setValue(
- SettingScope.User,
- 'hasSeenIdeIntegrationNudge',
- true,
- );
- } else if (result.userSelection === 'dismiss') {
- settings.setValue(
- SettingScope.User,
- 'hasSeenIdeIntegrationNudge',
- true,
- );
- }
- setIdePromptAnswered(true);
- },
- [handleSlashCommand, settings],
- );
-
- const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
-
- const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(
- streamingState,
- settings.merged.ui?.customWittyPhrases,
- );
- const showAutoAcceptIndicator = useAutoAcceptIndicator({ config, addItem });
-
- const handleExit = useCallback(
- (
- pressedOnce: boolean,
- setPressedOnce: (value: boolean) => void,
- timerRef: ReturnType>,
- ) => {
- if (pressedOnce) {
- if (timerRef.current) {
- clearTimeout(timerRef.current);
- }
- // Directly invoke the central command handler.
- handleSlashCommand('/quit');
- } else {
- setPressedOnce(true);
- timerRef.current = setTimeout(() => {
- setPressedOnce(false);
- timerRef.current = null;
- }, CTRL_EXIT_PROMPT_DURATION_MS);
- }
- },
- [handleSlashCommand],
- );
-
- const handleGlobalKeypress = useCallback(
- (key: Key) => {
- // Debug log keystrokes if enabled
- if (settings.merged.general?.debugKeystrokeLogging) {
- console.log('[DEBUG] Keystroke:', JSON.stringify(key));
- }
-
- let enteringConstrainHeightMode = false;
- if (!constrainHeight) {
- enteringConstrainHeightMode = true;
- setConstrainHeight(true);
- }
-
- if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
- setShowErrorDetails((prev) => !prev);
- } else if (keyMatchers[Command.TOGGLE_TOOL_DESCRIPTIONS](key)) {
- const newValue = !showToolDescriptions;
- setShowToolDescriptions(newValue);
-
- const mcpServers = config.getMcpServers();
- if (Object.keys(mcpServers || {}).length > 0) {
- handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc');
- }
- } else if (
- keyMatchers[Command.TOGGLE_IDE_CONTEXT_DETAIL](key) &&
- config.getIdeMode() &&
- ideContextState
- ) {
- // Show IDE status when in IDE mode and context is available.
- handleSlashCommand('/ide status');
- } else if (keyMatchers[Command.QUIT](key)) {
- // When authenticating, let AuthInProgress component handle Ctrl+C.
- if (authState === AuthState.Unauthenticated) {
- return;
- }
- if (!ctrlCPressedOnce) {
- cancelOngoingRequest?.();
- }
- handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
- } else if (keyMatchers[Command.EXIT](key)) {
- if (buffer.text.length > 0) {
- return;
- }
- handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
- } else if (
- keyMatchers[Command.SHOW_MORE_LINES](key) &&
- !enteringConstrainHeightMode
- ) {
- setConstrainHeight(false);
- }
- },
- [
- constrainHeight,
- setConstrainHeight,
- setShowErrorDetails,
- showToolDescriptions,
- setShowToolDescriptions,
- config,
- ideContextState,
- handleExit,
- ctrlCPressedOnce,
- setCtrlCPressedOnce,
- ctrlCTimerRef,
- buffer.text.length,
- ctrlDPressedOnce,
- setCtrlDPressedOnce,
- ctrlDTimerRef,
- handleSlashCommand,
- authState,
- cancelOngoingRequest,
- settings.merged.general?.debugKeystrokeLogging,
- ],
- );
-
- useKeypress(handleGlobalKeypress, {
- isActive: true,
- });
-
- useEffect(() => {
- if (config) {
- setGeminiMdFileCount(config.getGeminiMdFileCount());
- }
- }, [config, config.getGeminiMdFileCount]);
-
- const logger = useLogger(config.storage);
-
- // Initialize independent input history from logger
- useEffect(() => {
- inputHistoryStore.initializeFromLogger(logger);
- }, [logger, inputHistoryStore]);
-
- const isInputActive =
- (streamingState === StreamingState.Idle ||
- streamingState === StreamingState.Responding) &&
- !initError &&
- !isProcessing &&
- !isProQuotaDialogOpen;
-
- const handleClearScreen = useCallback(() => {
- clearItems();
- clearConsoleMessagesState();
- console.clear();
- refreshStatic();
- }, [clearItems, clearConsoleMessagesState, refreshStatic]);
-
- const mainControlsRef = useRef(null);
- const pendingHistoryItemRef = useRef(null);
-
- useEffect(() => {
- if (mainControlsRef.current) {
- const fullFooterMeasurement = measureElement(mainControlsRef.current);
- setFooterHeight(fullFooterMeasurement.height);
- }
- }, [terminalHeight, consoleMessages, showErrorDetails]);
-
- const staticExtraHeight = /* margins and padding */ 3;
- const availableTerminalHeight = useMemo(
- () => terminalHeight - footerHeight - staticExtraHeight,
- [terminalHeight, footerHeight],
- );
-
- useEffect(() => {
- // skip refreshing Static during first mount
- if (isInitialMount.current) {
- isInitialMount.current = false;
- return;
- }
-
- // debounce so it doesn't fire up too often during resize
- const handler = setTimeout(() => {
- setStaticNeedsRefresh(false);
- refreshStatic();
- }, 300);
-
- return () => {
- clearTimeout(handler);
- };
- }, [terminalWidth, terminalHeight, refreshStatic]);
-
- useEffect(() => {
- if (streamingState === StreamingState.Idle && staticNeedsRefresh) {
- setStaticNeedsRefresh(false);
- refreshStatic();
- }
- }, [streamingState, refreshStatic, staticNeedsRefresh]);
-
- const filteredConsoleMessages = useMemo(() => {
- if (config.getDebugMode()) {
- return consoleMessages;
- }
- return consoleMessages.filter((msg) => msg.type !== 'debug');
- }, [consoleMessages, config]);
-
- const branchName = useGitBranchName(config.getTargetDir());
-
- const contextFileNames = useMemo(() => {
- const fromSettings = settings.merged.context?.fileName;
- if (fromSettings) {
- return Array.isArray(fromSettings) ? fromSettings : [fromSettings];
- }
- return getAllGeminiMdFilenames();
- }, [settings.merged.context?.fileName]);
-
- const initialPrompt = useMemo(() => config.getQuestion(), [config]);
- const geminiClient = config.getGeminiClient();
-
- useEffect(() => {
- if (
- initialPrompt &&
- !initialPromptSubmitted.current &&
- authState === AuthState.Authenticated &&
- !isThemeDialogOpen &&
- !isEditorDialogOpen &&
- !showPrivacyNotice &&
- geminiClient?.isInitialized?.()
- ) {
- submitQuery(initialPrompt);
- initialPromptSubmitted.current = true;
- }
- }, [
- initialPrompt,
- submitQuery,
- authState,
- isThemeDialogOpen,
- isEditorDialogOpen,
- showPrivacyNotice,
- geminiClient,
- ]);
-
- if (quittingMessages) {
- return (
-
- {quittingMessages.map((item) => (
-
- ))}
-
- );
+import { Notifications } from './components/Notifications.js';
+import { MainContent } from './components/MainContent.js';
+import { DialogManager } from './components/DialogManager.js';
+import { Composer } from './components/Composer.js';
+import { useUIState } from './contexts/UIStateContext.js';
+import { QuittingDisplay } from './components/QuittingDisplay.js';
+
+export const App = () => {
+ const uiState = useUIState();
+
+ if (uiState.quittingMessages) {
+ return ;
}
- const mainAreaWidth = Math.floor(terminalWidth * 0.9);
- const debugConsoleMaxHeight = Math.floor(Math.max(terminalHeight * 0.2, 5));
- // Arbitrary threshold to ensure that items in the static area are large
- // enough but not too large to make the terminal hard to use.
- const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
- const placeholder = vimModeEnabled
- ? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode."
- : ' Type your message or @path/to/file';
-
- const hideContextSummary = settings.merged.ui?.hideContextSummary ?? false;
-
return (
-
+
- {/*
- * The Static component is an Ink intrinsic in which there can only be 1 per application.
- * Because of this restriction we're hacking it slightly by having a 'header' item here to
- * ensure that it's statically rendered.
- *
- * Background on the Static Item: Anything in the Static component is written a single time
- * to the console. Think of it like doing a console.log and then never using ANSI codes to
- * clear that content ever again. Effectively it has a moving frame that every time new static
- * content is set it'll flush content to the terminal and move the area which it's "clearing"
- * down a notch. Without Static the area which gets erased and redrawn continuously grows.
- */}
-
- {!(
- settings.merged.ui?.hideBanner || config.getScreenReader()
- ) && }
- {!(settings.merged.ui?.hideTips || config.getScreenReader()) && (
-
- )}
- ,
- ...history.map((h) => (
-
- )),
- ]}
- >
- {(item) => item}
-
-
-
- {pendingHistoryItems.map((item, i) => (
-
- ))}
-
-
-
+
-
- {/* Move UpdateNotification to render update notification above input area */}
- {updateInfo && }
- {startupWarnings.length > 0 && (
-
- {startupWarnings.map((warning, index) => (
-
- {warning}
-
- ))}
-
- )}
- {showWorkspaceMigrationDialog ? (
-
- ) : shouldShowIdePrompt && currentIDE ? (
-
- ) : isProQuotaDialogOpen ? (
- {
- setIsProQuotaDialogOpen(false);
- if (!proQuotaDialogResolver) return;
+
+
- const resolveValue = choice !== 'auth';
- proQuotaDialogResolver(resolveValue);
- setProQuotaDialogResolver(null);
-
- if (choice === 'auth') {
- cancelOngoingRequest?.();
- setAuthState(AuthState.Updating);
- } else {
- addItem(
- {
- type: MessageType.INFO,
- text: 'Switched to fallback model. Tip: Press Ctrl+P to recall your previous prompt and submit it again if you wish.',
- },
- Date.now(),
- );
- }
- }}
- />
- ) : showIdeRestartPrompt ? (
-
-
- Workspace trust has changed. Press 'r' to restart
- Gemini to apply the changes.
-
-
- ) : isFolderTrustDialogOpen ? (
-
- ) : shellConfirmationRequest ? (
-
- ) : confirmationRequest ? (
-
- {confirmationRequest.prompt}
-
- {
- confirmationRequest.onConfirm(value);
- }}
- />
-
-
- ) : isThemeDialogOpen ? (
-
- {themeError && (
-
- {themeError}
-
- )}
-
-
- ) : isSettingsDialogOpen ? (
-
- closeSettingsDialog()}
- onRestartRequest={() => process.exit(0)}
- />
-
- ) : authState === AuthState.Unauthenticated ? (
- <>
- {
- onAuthError('Authentication timed out. Please try again.');
- }}
- />
- {showErrorDetails && (
-
-
-
-
-
-
- )}
- >
- ) : authState === AuthState.Updating ? (
-
-
-
- ) : isEditorDialogOpen ? (
-
- {editorError && (
-
- {editorError}
-
- )}
-
-
- ) : showPrivacyNotice ? (
- setShowPrivacyNotice(false)}
- config={config}
- />
- ) : (
- <>
-
- {/* Display queued messages below loading indicator */}
- {messageQueue.length > 0 && (
-
- {messageQueue
- .slice(0, MAX_DISPLAYED_QUEUED_MESSAGES)
- .map((message, index) => {
- // Ensure multi-line messages are collapsed for the preview.
- // Replace all whitespace (including newlines) with a single space.
- const preview = message.replace(/\s+/g, ' ');
-
- return (
- // Ensure the Box takes full width so truncation calculates correctly
-
- {/* Use wrap="truncate" to ensure it fits the terminal width and doesn't wrap */}
-
- {preview}
-
-
- );
- })}
- {messageQueue.length > MAX_DISPLAYED_QUEUED_MESSAGES && (
-
-
- ... (+
- {messageQueue.length - MAX_DISPLAYED_QUEUED_MESSAGES}
- more)
-
-
- )}
-
- )}
-
-
- {process.env['GEMINI_SYSTEM_MD'] && (
- |⌐■_■|
- )}
- {ctrlCPressedOnce ? (
-
- Press Ctrl+C again to exit.
-
- ) : ctrlDPressedOnce ? (
-
- Press Ctrl+D again to exit.
-
- ) : showEscapePrompt ? (
- Press Esc again to clear.
- ) : !hideContextSummary ? (
-
- ) : null}
-
-
- {showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
- !shellModeActive && (
-
- )}
- {shellModeActive && }
-
-
- {showErrorDetails && (
-
-
-
-
-
-
- )}
- {isInputActive && (
-
- )}
- >
- )}
-
- {initError && streamingState !== StreamingState.Responding && (
-
- {history.find(
- (item) =>
- item.type === 'error' && item.text?.includes(initError),
- )?.text ? (
-
- {
- history.find(
- (item) =>
- item.type === 'error' && item.text?.includes(initError),
- )?.text
- }
-
- ) : (
- <>
-
- Initialization Error: {initError}
-
-
- {' '}
- Please check API key and configuration.
-
- >
- )}
-
- )}
- {!settings.merged.ui?.hideFooter && (
-
- )}
+ {uiState.dialogsVisible ? : }
diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx
new file mode 100644
index 0000000000..1f6024d469
--- /dev/null
+++ b/packages/cli/src/ui/AppContainer.test.tsx
@@ -0,0 +1,331 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, cleanup } from 'ink-testing-library';
+import { AppContainer } from './AppContainer.js';
+import { type Config, makeFakeConfig } from '@google/gemini-cli-core';
+import type { LoadedSettings } from '../config/settings.js';
+import type { InitializationResult } from '../core/initializer.js';
+
+// Mock App component to isolate AppContainer testing
+vi.mock('./App.js', () => ({
+ App: () => 'App Component',
+}));
+
+// Mock all the hooks and utilities
+vi.mock('./hooks/useHistory.js');
+vi.mock('./hooks/useThemeCommand.js');
+vi.mock('./hooks/useAuthCommand.js');
+vi.mock('./hooks/useEditorSettings.js');
+vi.mock('./hooks/useSettingsCommand.js');
+vi.mock('./hooks/useSlashCommandProcessor.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/useVim.js');
+vi.mock('./hooks/useFocus.js');
+vi.mock('./hooks/useBracketedPaste.js');
+vi.mock('./hooks/useKeypress.js');
+vi.mock('./hooks/useLoadingIndicator.js');
+vi.mock('./hooks/useFolderTrust.js');
+vi.mock('./hooks/useMessageQueue.js');
+vi.mock('./hooks/useAutoAcceptIndicator.js');
+vi.mock('./hooks/useWorkspaceMigration.js');
+vi.mock('./hooks/useGitBranchName.js');
+vi.mock('./contexts/VimModeContext.js');
+vi.mock('./contexts/SessionContext.js');
+vi.mock('./hooks/useTextBuffer.js');
+vi.mock('./hooks/useLogger.js');
+
+// Mock external utilities
+vi.mock('../utils/events.js');
+vi.mock('../utils/handleAutoUpdate.js');
+vi.mock('./utils/ConsolePatcher.js');
+vi.mock('../utils/cleanup.js');
+
+describe('AppContainer State Management', () => {
+ let mockConfig: Config;
+ let mockSettings: LoadedSettings;
+ let mockInitResult: InitializationResult;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Mock Config
+ mockConfig = makeFakeConfig();
+
+ // Mock LoadedSettings
+ mockSettings = {
+ merged: {
+ hideBanner: false,
+ hideFooter: false,
+ hideTips: false,
+ showMemoryUsage: false,
+ theme: 'default',
+ },
+ } as unknown as LoadedSettings;
+
+ // Mock InitializationResult
+ mockInitResult = {
+ themeError: null,
+ authError: null,
+ shouldOpenAuthDialog: false,
+ geminiMdFileCount: 0,
+ } as InitializationResult;
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ describe('Basic Rendering', () => {
+ it('renders without crashing with minimal props', () => {
+ expect(() => {
+ render(
+ ,
+ );
+ }).not.toThrow();
+ });
+
+ it('renders with startup warnings', () => {
+ const startupWarnings = ['Warning 1', 'Warning 2'];
+
+ expect(() => {
+ render(
+ ,
+ );
+ }).not.toThrow();
+ });
+ });
+
+ describe('State Initialization', () => {
+ it('initializes with theme error from initialization result', () => {
+ const initResultWithError = {
+ ...mockInitResult,
+ themeError: 'Failed to load theme',
+ };
+
+ expect(() => {
+ render(
+ ,
+ );
+ }).not.toThrow();
+ });
+
+ it('handles debug mode state', () => {
+ const debugConfig = makeFakeConfig();
+ vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true);
+
+ expect(() => {
+ render(
+ ,
+ );
+ }).not.toThrow();
+ });
+ });
+
+ describe('Context Providers', () => {
+ it('provides AppContext with correct values', () => {
+ const { unmount } = render(
+ ,
+ );
+
+ // Should render and unmount cleanly
+ expect(() => unmount()).not.toThrow();
+ });
+
+ it('provides UIStateContext with state management', () => {
+ expect(() => {
+ render(
+ ,
+ );
+ }).not.toThrow();
+ });
+
+ it('provides UIActionsContext with action handlers', () => {
+ expect(() => {
+ render(
+ ,
+ );
+ }).not.toThrow();
+ });
+
+ it('provides ConfigContext with config object', () => {
+ expect(() => {
+ render(
+ ,
+ );
+ }).not.toThrow();
+ });
+ });
+
+ describe('Settings Integration', () => {
+ it('handles settings with all display options disabled', () => {
+ const settingsAllHidden = {
+ merged: {
+ hideBanner: true,
+ hideFooter: true,
+ hideTips: true,
+ showMemoryUsage: false,
+ },
+ } as unknown as LoadedSettings;
+
+ expect(() => {
+ render(
+ ,
+ );
+ }).not.toThrow();
+ });
+
+ it('handles settings with memory usage enabled', () => {
+ const settingsWithMemory = {
+ merged: {
+ hideBanner: false,
+ hideFooter: false,
+ hideTips: false,
+ showMemoryUsage: true,
+ },
+ } as unknown as LoadedSettings;
+
+ expect(() => {
+ render(
+ ,
+ );
+ }).not.toThrow();
+ });
+ });
+
+ describe('Version Handling', () => {
+ it('handles different version formats', () => {
+ const versions = ['1.0.0', '2.1.3-beta', '3.0.0-nightly'];
+
+ versions.forEach((version) => {
+ expect(() => {
+ render(
+ ,
+ );
+ }).not.toThrow();
+ });
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('handles config methods that might throw', () => {
+ const errorConfig = makeFakeConfig();
+ vi.spyOn(errorConfig, 'getModel').mockImplementation(() => {
+ throw new Error('Config error');
+ });
+
+ // Should still render without crashing - errors should be handled internally
+ expect(() => {
+ render(
+ ,
+ );
+ }).not.toThrow();
+ });
+
+ it('handles undefined settings gracefully', () => {
+ const undefinedSettings = {
+ merged: {},
+ } as LoadedSettings;
+
+ expect(() => {
+ render(
+ ,
+ );
+ }).not.toThrow();
+ });
+ });
+
+ 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 } = render(
+ ,
+ );
+
+ expect(() => unmount()).not.toThrow();
+ });
+ });
+});
+
+// TODO: Add comprehensive integration test once all hook mocks are complete
+// For now, the 14 passing unit tests provide good coverage of AppContainer functionality
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
new file mode 100644
index 0000000000..d8fde483da
--- /dev/null
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -0,0 +1,1260 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useMemo, useState, useCallback, useEffect, useRef } from 'react';
+import { type DOMElement, measureElement } from 'ink';
+import { App } from './App.js';
+import { AppContext } from './contexts/AppContext.js';
+import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
+import {
+ UIActionsContext,
+ type UIActions,
+} from './contexts/UIActionsContext.js';
+import { ConfigContext } from './contexts/ConfigContext.js';
+import {
+ type HistoryItem,
+ ToolCallStatus,
+ type HistoryItemWithoutId,
+ AuthState,
+} from './types.js';
+import { MessageType } from './types.js';
+import {
+ type EditorType,
+ type Config,
+ IdeClient,
+ type DetectedIde,
+ ideContext,
+ type IdeContext,
+ getErrorMessage,
+ getAllGeminiMdFilenames,
+ UserTierId,
+ AuthType,
+ isProQuotaExceededError,
+ isGenericQuotaExceededError,
+ logFlashFallback,
+ FlashFallbackEvent,
+ clearCachedCredentialFile,
+} from '@google/gemini-cli-core';
+import { validateAuthMethod } from '../config/auth.js';
+import { loadHierarchicalGeminiMemory } from '../config/config.js';
+import process from 'node:process';
+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 { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
+import { useVimMode } from './contexts/VimModeContext.js';
+import { useConsoleMessages } from './hooks/useConsoleMessages.js';
+import { useTerminalSize } from './hooks/useTerminalSize.js';
+import { useStdin, useStdout } from 'ink';
+import ansiEscapes from 'ansi-escapes';
+import * as fs from 'node:fs';
+import { useTextBuffer } from './components/shared/text-buffer.js';
+import { useLogger } from './hooks/useLogger.js';
+import { useGeminiStream } from './hooks/useGeminiStream.js';
+import { useVim } from './hooks/vim.js';
+import { type LoadedSettings, SettingScope } from '../config/settings.js';
+import { type InitializationResult } from '../core/initializer.js';
+import { useFocus } from './hooks/useFocus.js';
+import { useBracketedPaste } from './hooks/useBracketedPaste.js';
+import { useKeypress, type Key } from './hooks/useKeypress.js';
+import { keyMatchers, Command } from './keyMatchers.js';
+import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
+import { useFolderTrust } from './hooks/useFolderTrust.js';
+import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
+import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
+import { appEvents, AppEvent } from '../utils/events.js';
+import { type UpdateObject } from './utils/updateCheck.js';
+import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
+import { ConsolePatcher } from './utils/ConsolePatcher.js';
+import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
+import { useMessageQueue } from './hooks/useMessageQueue.js';
+import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
+import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js';
+import { useSessionStats } from './contexts/SessionContext.js';
+import { useGitBranchName } from './hooks/useGitBranchName.js';
+
+const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
+
+function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
+ return pendingHistoryItems.some((item) => {
+ if (item && item.type === 'tool_group') {
+ return item.tools.some(
+ (tool) => ToolCallStatus.Executing === tool.status,
+ );
+ }
+ return false;
+ });
+}
+
+interface AppContainerProps {
+ config: Config;
+ settings: LoadedSettings;
+ startupWarnings?: string[];
+ version: string;
+ initializationResult: InitializationResult;
+}
+
+export const AppContainer = (props: AppContainerProps) => {
+ const { settings, config, initializationResult } = props;
+ const historyManager = useHistory();
+ const [corgiMode, setCorgiMode] = useState(false);
+ const [debugMessage, setDebugMessage] = useState('');
+ const [quittingMessages, setQuittingMessages] = useState<
+ HistoryItem[] | null
+ >(null);
+ const [showPrivacyNotice, setShowPrivacyNotice] = useState(false);
+ const [themeError, setThemeError] = useState(
+ initializationResult.themeError,
+ );
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [geminiMdFileCount, setGeminiMdFileCount] = useState(
+ initializationResult.geminiMdFileCount,
+ );
+ const [shellModeActive, setShellModeActive] = useState(false);
+ const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
+ useState(false);
+ const [historyRemountKey, setHistoryRemountKey] = useState(0);
+ const [updateInfo, setUpdateInfo] = useState(null);
+ const [isTrustedFolder, setIsTrustedFolder] = useState(
+ config.isTrustedFolder(),
+ );
+ const [currentModel, setCurrentModel] = useState(config.getModel());
+ const [userTier, setUserTier] = useState(undefined);
+ const [isProQuotaDialogOpen, setIsProQuotaDialogOpen] = useState(false);
+ const [proQuotaDialogResolver, setProQuotaDialogResolver] = useState<
+ ((value: boolean) => void) | null
+ >(null);
+
+ // Auto-accept indicator
+ const showAutoAcceptIndicator = useAutoAcceptIndicator({
+ config,
+ addItem: historyManager.addItem,
+ });
+
+ const logger = useLogger(config.storage);
+ const [userMessages, setUserMessages] = useState([]);
+
+ // Terminal and layout hooks
+ const { columns: terminalWidth, rows: terminalHeight } = useTerminalSize();
+ const { stdin, setRawMode } = useStdin();
+ const { stdout } = useStdout();
+
+ // Additional hooks moved from App.tsx
+ const { stats: sessionStats } = useSessionStats();
+ const branchName = useGitBranchName(config.getTargetDir());
+
+ // Layout measurements
+ const mainControlsRef = useRef(null);
+ const staticExtraHeight = 3;
+
+ useEffect(() => {
+ registerCleanup(async () => {
+ const ideClient = await IdeClient.getInstance();
+ await ideClient.disconnect();
+ });
+ }, [config]);
+
+ useEffect(() => {
+ const cleanup = setUpdateHandler(historyManager.addItem, setUpdateInfo);
+ return cleanup;
+ }, [historyManager.addItem]);
+
+ // Watch for model changes (e.g., from Flash fallback)
+ useEffect(() => {
+ const checkModelChange = () => {
+ const configModel = config.getModel();
+ if (configModel !== currentModel) {
+ setCurrentModel(configModel);
+ }
+ };
+
+ // Check immediately and then periodically
+ checkModelChange();
+ const interval = setInterval(checkModelChange, 1000); // Check every second
+
+ return () => clearInterval(interval);
+ }, [config, currentModel]);
+
+ const {
+ consoleMessages,
+ handleNewMessage,
+ clearConsoleMessages: clearConsoleMessagesState,
+ } = useConsoleMessages();
+
+ useEffect(() => {
+ const consolePatcher = new ConsolePatcher({
+ onNewMessage: handleNewMessage,
+ debugMode: config.getDebugMode(),
+ });
+ consolePatcher.patch();
+ registerCleanup(consolePatcher.cleanup);
+ }, [handleNewMessage, config]);
+
+ const widthFraction = 0.9;
+ const inputWidth = Math.max(
+ 20,
+ Math.floor(terminalWidth * widthFraction) - 3,
+ );
+ const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 0.8));
+ const mainAreaWidth = Math.floor(terminalWidth * 0.9);
+ const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
+
+ const isValidPath = useCallback((filePath: string): boolean => {
+ try {
+ return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
+ } catch (_e) {
+ return false;
+ }
+ }, []);
+
+ const buffer = useTextBuffer({
+ initialText: '',
+ viewport: { height: 10, width: inputWidth },
+ stdin,
+ setRawMode,
+ isValidPath,
+ shellModeActive,
+ });
+
+ useEffect(() => {
+ const fetchUserMessages = async () => {
+ const pastMessagesRaw = (await logger?.getPreviousUserMessages()) || [];
+ const currentSessionUserMessages = historyManager.history
+ .filter(
+ (item): item is HistoryItem & { type: 'user'; text: string } =>
+ item.type === 'user' &&
+ typeof item.text === 'string' &&
+ item.text.trim() !== '',
+ )
+ .map((item) => item.text)
+ .reverse();
+ const combinedMessages = [
+ ...currentSessionUserMessages,
+ ...pastMessagesRaw,
+ ];
+ const deduplicatedMessages: string[] = [];
+ if (combinedMessages.length > 0) {
+ deduplicatedMessages.push(combinedMessages[0]);
+ for (let i = 1; i < combinedMessages.length; i++) {
+ if (combinedMessages[i] !== combinedMessages[i - 1]) {
+ deduplicatedMessages.push(combinedMessages[i]);
+ }
+ }
+ }
+ setUserMessages(deduplicatedMessages.reverse());
+ };
+ fetchUserMessages();
+ }, [historyManager.history, logger]);
+
+ const refreshStatic = useCallback(() => {
+ stdout.write(ansiEscapes.clearTerminal);
+ setHistoryRemountKey((prev) => prev + 1);
+ }, [setHistoryRemountKey, stdout]);
+
+ const {
+ isThemeDialogOpen,
+ openThemeDialog,
+ handleThemeSelect,
+ handleThemeHighlight,
+ } = useThemeCommand(
+ settings,
+ setThemeError,
+ historyManager.addItem,
+ initializationResult.themeError,
+ );
+
+ const { authState, setAuthState, authError, onAuthError } = useAuthCommand(
+ settings,
+ config,
+ );
+
+ // Derive auth state variables for backward compatibility with UIStateContext
+ const isAuthDialogOpen = authState === AuthState.Updating;
+ const isAuthenticating = authState === AuthState.Unauthenticated;
+
+ // Create handleAuthSelect wrapper for backward compatibility
+ const handleAuthSelect = useCallback(
+ async (authType: AuthType | undefined, scope: SettingScope) => {
+ if (authType) {
+ await clearCachedCredentialFile();
+ settings.setValue(scope, 'security.auth.selectedType', authType);
+
+ try {
+ await config.refreshAuth(authType);
+ setAuthState(AuthState.Authenticated);
+ } catch (e) {
+ onAuthError(
+ `Failed to authenticate: ${e instanceof Error ? e.message : String(e)}`,
+ );
+ return;
+ }
+
+ if (
+ authType === AuthType.LOGIN_WITH_GOOGLE &&
+ config.isBrowserLaunchSuppressed()
+ ) {
+ await runExitCleanup();
+ console.log(`
+----------------------------------------------------------------
+Logging in with Google... Please restart Gemini CLI to continue.
+----------------------------------------------------------------
+ `);
+ process.exit(0);
+ }
+ }
+ setAuthState(AuthState.Authenticated);
+ },
+ [settings, config, setAuthState, onAuthError],
+ );
+
+ // Sync user tier from config when authentication changes
+ useEffect(() => {
+ // Only sync when not currently authenticating
+ if (authState === AuthState.Authenticated) {
+ setUserTier(config.getGeminiClient()?.getUserTier());
+ }
+ }, [config, authState]);
+
+ // Check for enforced auth type mismatch
+ useEffect(() => {
+ if (
+ settings.merged.security?.auth?.enforcedType &&
+ settings.merged.security?.auth.selectedType &&
+ settings.merged.security?.auth.enforcedType !==
+ settings.merged.security?.auth.selectedType
+ ) {
+ onAuthError(
+ `Authentication is enforced to be ${settings.merged.security?.auth.enforcedType}, but you are currently using ${settings.merged.security?.auth.selectedType}.`,
+ );
+ } else if (
+ settings.merged.security?.auth?.selectedType &&
+ !settings.merged.security?.auth?.useExternal
+ ) {
+ const error = validateAuthMethod(
+ settings.merged.security.auth.selectedType,
+ );
+ if (error) {
+ onAuthError(error);
+ }
+ }
+ }, [
+ settings.merged.security?.auth?.selectedType,
+ settings.merged.security?.auth?.enforcedType,
+ settings.merged.security?.auth?.useExternal,
+ onAuthError,
+ ]);
+
+ const [editorError, setEditorError] = useState(null);
+ const {
+ isEditorDialogOpen,
+ openEditorDialog,
+ handleEditorSelect,
+ exitEditorDialog,
+ } = useEditorSettings(settings, setEditorError, historyManager.addItem);
+
+ const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
+ useSettingsCommand();
+
+ const {
+ showWorkspaceMigrationDialog,
+ workspaceExtensions,
+ onWorkspaceMigrationDialogOpen,
+ onWorkspaceMigrationDialogClose,
+ } = useWorkspaceMigration(settings);
+
+ const { toggleVimEnabled } = useVimMode();
+
+ const slashCommandActions = useMemo(
+ () => ({
+ openAuthDialog: () => setAuthState(AuthState.Updating),
+ openThemeDialog,
+ openEditorDialog,
+ openPrivacyNotice: () => setShowPrivacyNotice(true),
+ openSettingsDialog,
+ quit: (messages: HistoryItem[]) => {
+ setQuittingMessages(messages);
+ setTimeout(async () => {
+ await runExitCleanup();
+ process.exit(0);
+ }, 100);
+ },
+ setDebugMessage,
+ toggleCorgiMode: () => setCorgiMode((prev) => !prev),
+ }),
+ [
+ setAuthState,
+ openThemeDialog,
+ openEditorDialog,
+ openSettingsDialog,
+ setQuittingMessages,
+ setDebugMessage,
+ setShowPrivacyNotice,
+ setCorgiMode,
+ ],
+ );
+
+ const {
+ handleSlashCommand,
+ slashCommands,
+ pendingHistoryItems: pendingSlashCommandHistoryItems,
+ commandContext,
+ shellConfirmationRequest,
+ confirmationRequest,
+ } = useSlashCommandProcessor(
+ config,
+ settings,
+ historyManager.addItem,
+ historyManager.clearItems,
+ historyManager.loadHistory,
+ refreshStatic,
+ toggleVimEnabled,
+ setIsProcessing,
+ setGeminiMdFileCount,
+ slashCommandActions,
+ );
+
+ const performMemoryRefresh = useCallback(async () => {
+ historyManager.addItem(
+ {
+ type: MessageType.INFO,
+ text: 'Refreshing hierarchical memory (GEMINI.md or other context files)...',
+ },
+ Date.now(),
+ );
+ try {
+ const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
+ process.cwd(),
+ settings.merged.context?.loadMemoryFromIncludeDirectories
+ ? config.getWorkspaceContext().getDirectories()
+ : [],
+ config.getDebugMode(),
+ config.getFileService(),
+ settings.merged,
+ config.getExtensionContextFilePaths(),
+ config.isTrustedFolder(),
+ settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree'
+ config.getFileFilteringOptions(),
+ );
+
+ config.setUserMemory(memoryContent);
+ config.setGeminiMdFileCount(fileCount);
+ setGeminiMdFileCount(fileCount);
+
+ historyManager.addItem(
+ {
+ type: MessageType.INFO,
+ text: `Memory refreshed successfully. ${
+ memoryContent.length > 0
+ ? `Loaded ${memoryContent.length} characters from ${fileCount} file(s).`
+ : 'No memory content found.'
+ }`,
+ },
+ Date.now(),
+ );
+ if (config.getDebugMode()) {
+ console.log(
+ `[DEBUG] Refreshed memory content in config: ${memoryContent.substring(
+ 0,
+ 200,
+ )}...`,
+ );
+ }
+ } catch (error) {
+ const errorMessage = getErrorMessage(error);
+ historyManager.addItem(
+ {
+ type: MessageType.ERROR,
+ text: `Error refreshing memory: ${errorMessage}`,
+ },
+ Date.now(),
+ );
+ console.error('Error refreshing memory:', error);
+ }
+ }, [config, historyManager, settings.merged]);
+
+ // Set up Flash fallback handler
+ useEffect(() => {
+ const flashFallbackHandler = async (
+ currentModel: string,
+ fallbackModel: string,
+ error?: unknown,
+ ): Promise => {
+ // Check if we've already switched to the fallback model
+ if (config.isInFallbackMode()) {
+ // If we're already in fallback mode, don't show the dialog again
+ return false;
+ }
+
+ let message: string;
+
+ if (
+ config.getContentGeneratorConfig().authType ===
+ AuthType.LOGIN_WITH_GOOGLE
+ ) {
+ // Use actual user tier if available; otherwise, default to FREE tier behavior (safe default)
+ const isPaidTier =
+ userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD;
+
+ // Check if this is a Pro quota exceeded error
+ if (error && isProQuotaExceededError(error)) {
+ if (isPaidTier) {
+ message = `⚡ You have reached your daily ${currentModel} quota limit.
+⚡ You can choose to authenticate with a paid API key or continue with the fallback model.
+⚡ To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
+ } else {
+ message = `⚡ You have reached your daily ${currentModel} quota limit.
+⚡ You can choose to authenticate with a paid API key or continue with the fallback model.
+⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
+⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
+⚡ You can switch authentication methods by typing /auth`;
+ }
+ } else if (error && isGenericQuotaExceededError(error)) {
+ if (isPaidTier) {
+ message = `⚡ You have reached your daily quota limit.
+⚡ Automatically switching from ${currentModel} to ${fallbackModel} for the remainder of this session.
+⚡ To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
+ } else {
+ message = `⚡ You have reached your daily quota limit.
+⚡ Automatically switching from ${currentModel} to ${fallbackModel} for the remainder of this session.
+⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
+⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
+⚡ You can switch authentication methods by typing /auth`;
+ }
+ } else {
+ if (isPaidTier) {
+ // Default fallback message for other cases (like consecutive 429s)
+ message = `⚡ Automatically switching from ${currentModel} to ${fallbackModel} for faster responses for the remainder of this session.
+⚡ Possible reasons for this are that you have received multiple consecutive capacity errors or you have reached your daily ${currentModel} quota limit
+⚡ To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
+ } else {
+ // Default fallback message for other cases (like consecutive 429s)
+ message = `⚡ Automatically switching from ${currentModel} to ${fallbackModel} for faster responses for the remainder of this session.
+⚡ Possible reasons for this are that you have received multiple consecutive capacity errors or you have reached your daily ${currentModel} quota limit
+⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
+⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
+⚡ You can switch authentication methods by typing /auth`;
+ }
+ }
+
+ // Add message to UI history
+ historyManager.addItem(
+ {
+ type: MessageType.INFO,
+ text: message,
+ },
+ Date.now(),
+ );
+
+ // For Pro quota errors, show the dialog and wait for user's choice
+ if (error && isProQuotaExceededError(error)) {
+ // Set the flag to prevent tool continuation
+ setModelSwitchedFromQuotaError(true);
+ // Set global quota error flag to prevent Flash model calls
+ config.setQuotaErrorOccurred(true);
+
+ // Show the ProQuotaDialog and wait for user's choice
+ const shouldContinueWithFallback = await new Promise(
+ (resolve) => {
+ setIsProQuotaDialogOpen(true);
+ setProQuotaDialogResolver(() => resolve);
+ },
+ );
+
+ // If user chose to continue with fallback, we don't need to stop the current prompt
+ if (shouldContinueWithFallback) {
+ // Switch to fallback model for future use
+ config.setModel(fallbackModel);
+ config.setFallbackMode(true);
+ logFlashFallback(
+ config,
+ new FlashFallbackEvent(
+ config.getContentGeneratorConfig().authType!,
+ ),
+ );
+ return true; // Continue with current prompt using fallback model
+ }
+
+ // If user chose to authenticate, stop current prompt
+ return false;
+ }
+
+ // For other quota errors, automatically switch to fallback model
+ // Set the flag to prevent tool continuation
+ setModelSwitchedFromQuotaError(true);
+ // Set global quota error flag to prevent Flash model calls
+ config.setQuotaErrorOccurred(true);
+ }
+
+ // Switch model for future use but return false to stop current retry
+ config.setModel(fallbackModel);
+ config.setFallbackMode(true);
+ logFlashFallback(
+ config,
+ new FlashFallbackEvent(config.getContentGeneratorConfig().authType!),
+ );
+ return false; // Don't continue with current prompt
+ };
+
+ config.setFlashFallbackHandler(flashFallbackHandler);
+ }, [config, historyManager, userTier]);
+
+ const cancelHandlerRef = useRef<() => void>(() => {});
+
+ const {
+ streamingState,
+ submitQuery,
+ initError,
+ pendingHistoryItems: pendingGeminiHistoryItems,
+ thought,
+ cancelOngoingRequest,
+ } = useGeminiStream(
+ config.getGeminiClient(),
+ historyManager.history,
+ historyManager.addItem,
+ config,
+ settings,
+ setDebugMessage,
+ handleSlashCommand,
+ shellModeActive,
+ () => settings.merged.general?.preferredEditor as EditorType,
+ onAuthError,
+ performMemoryRefresh,
+ modelSwitchedFromQuotaError,
+ setModelSwitchedFromQuotaError,
+ refreshStatic,
+ () => cancelHandlerRef.current(),
+ );
+
+ const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } =
+ useMessageQueue({
+ streamingState,
+ submitQuery,
+ });
+
+ cancelHandlerRef.current = useCallback(() => {
+ const pendingHistoryItems = [
+ ...pendingSlashCommandHistoryItems,
+ ...pendingGeminiHistoryItems,
+ ];
+ if (isToolExecuting(pendingHistoryItems)) {
+ buffer.setText(''); // Just clear the prompt
+ return;
+ }
+
+ const lastUserMessage = userMessages.at(-1);
+ let textToSet = lastUserMessage || '';
+
+ const queuedText = getQueuedMessagesText();
+ if (queuedText) {
+ textToSet = textToSet ? `${textToSet}\n\n${queuedText}` : queuedText;
+ clearQueue();
+ }
+
+ if (textToSet) {
+ buffer.setText(textToSet);
+ }
+ }, [
+ buffer,
+ userMessages,
+ getQueuedMessagesText,
+ clearQueue,
+ pendingSlashCommandHistoryItems,
+ pendingGeminiHistoryItems,
+ ]);
+
+ const handleFinalSubmit = useCallback(
+ (submittedValue: string) => {
+ addMessage(submittedValue);
+ },
+ [addMessage],
+ );
+
+ const handleClearScreen = useCallback(() => {
+ historyManager.clearItems();
+ clearConsoleMessagesState();
+ console.clear();
+ refreshStatic();
+ }, [historyManager, clearConsoleMessagesState, refreshStatic]);
+
+ const handleProQuotaChoice = useCallback(
+ (choice: 'auth' | 'continue') => {
+ setIsProQuotaDialogOpen(false);
+ if (proQuotaDialogResolver) {
+ if (choice === 'auth') {
+ proQuotaDialogResolver(false); // Don't continue with fallback, show auth dialog
+ setAuthState(AuthState.Updating);
+ } else {
+ proQuotaDialogResolver(true); // Continue with fallback model
+ }
+ setProQuotaDialogResolver(null);
+ }
+ },
+ [proQuotaDialogResolver, setAuthState],
+ );
+
+ const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
+
+ const isInputActive = !initError && !isProcessing;
+
+ // Compute available terminal height based on controls measurement
+ const availableTerminalHeight = useMemo(() => {
+ if (mainControlsRef.current) {
+ const fullFooterMeasurement = measureElement(mainControlsRef.current);
+ return terminalHeight - fullFooterMeasurement.height - staticExtraHeight;
+ }
+ return terminalHeight - staticExtraHeight;
+ }, [terminalHeight]);
+
+ const isFocused = useFocus();
+ useBracketedPaste();
+
+ // Context file names computation
+ const contextFileNames = useMemo(() => {
+ const fromSettings = settings.merged.context?.fileName;
+ return fromSettings
+ ? Array.isArray(fromSettings)
+ ? fromSettings
+ : [fromSettings]
+ : getAllGeminiMdFilenames();
+ }, [settings.merged.context?.fileName]);
+ // Initial prompt handling
+ const initialPrompt = useMemo(() => config.getQuestion(), [config]);
+ const initialPromptSubmitted = useRef(false);
+ const geminiClient = config.getGeminiClient();
+
+ useEffect(() => {
+ if (
+ initialPrompt &&
+ !initialPromptSubmitted.current &&
+ !isAuthenticating &&
+ !isAuthDialogOpen &&
+ !isThemeDialogOpen &&
+ !isEditorDialogOpen &&
+ !showPrivacyNotice &&
+ geminiClient?.isInitialized?.()
+ ) {
+ handleFinalSubmit(initialPrompt);
+ initialPromptSubmitted.current = true;
+ }
+ }, [
+ initialPrompt,
+ handleFinalSubmit,
+ isAuthenticating,
+ isAuthDialogOpen,
+ isThemeDialogOpen,
+ isEditorDialogOpen,
+ showPrivacyNotice,
+ geminiClient,
+ ]);
+
+ const [idePromptAnswered, setIdePromptAnswered] = useState(false);
+ const [currentIDE, setCurrentIDE] = useState(null);
+
+ useEffect(() => {
+ const getIde = async () => {
+ const ideClient = await IdeClient.getInstance();
+ const currentIde = ideClient.getCurrentIde();
+ setCurrentIDE(currentIde || null);
+ };
+ getIde();
+ }, []);
+ const shouldShowIdePrompt = Boolean(
+ currentIDE &&
+ !config.getIdeMode() &&
+ !settings.merged.ide?.hasSeenNudge &&
+ !idePromptAnswered,
+ );
+
+ const [showErrorDetails, setShowErrorDetails] = useState(false);
+ const [showToolDescriptions, setShowToolDescriptions] =
+ useState(false);
+
+ const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false);
+ const ctrlCTimerRef = useRef(null);
+ const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false);
+ const ctrlDTimerRef = useRef(null);
+ const [constrainHeight, setConstrainHeight] = useState(true);
+ const [ideContextState, setIdeContextState] = useState<
+ IdeContext | undefined
+ >();
+ const [showEscapePrompt, setShowEscapePrompt] = useState(false);
+ const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false);
+
+ const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
+ useFolderTrust(settings, config, setIsTrustedFolder);
+ const { needsRestart: ideNeedsRestart } = useIdeTrustListener();
+ const isInitialMount = useRef(true);
+
+ useEffect(() => {
+ if (ideNeedsRestart) {
+ // IDE trust changed, force a restart.
+ setShowIdeRestartPrompt(true);
+ }
+ }, [ideNeedsRestart]);
+
+ useEffect(() => {
+ if (isInitialMount.current) {
+ isInitialMount.current = false;
+ return;
+ }
+
+ const handler = setTimeout(() => {
+ refreshStatic();
+ }, 300);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [terminalWidth, refreshStatic]);
+
+ useEffect(() => {
+ const unsubscribe = ideContext.subscribeToIdeContext(setIdeContextState);
+ setIdeContextState(ideContext.getIdeContext());
+ return unsubscribe;
+ }, []);
+
+ useEffect(() => {
+ const openDebugConsole = () => {
+ setShowErrorDetails(true);
+ setConstrainHeight(false);
+ };
+ appEvents.on(AppEvent.OpenDebugConsole, openDebugConsole);
+
+ const logErrorHandler = (errorMessage: unknown) => {
+ handleNewMessage({
+ type: 'error',
+ content: String(errorMessage),
+ count: 1,
+ });
+ };
+ appEvents.on(AppEvent.LogError, logErrorHandler);
+
+ return () => {
+ appEvents.off(AppEvent.OpenDebugConsole, openDebugConsole);
+ appEvents.off(AppEvent.LogError, logErrorHandler);
+ };
+ }, [handleNewMessage]);
+
+ const handleEscapePromptChange = useCallback((showPrompt: boolean) => {
+ setShowEscapePrompt(showPrompt);
+ }, []);
+
+ const handleIdePromptComplete = useCallback(
+ (result: IdeIntegrationNudgeResult) => {
+ if (result.userSelection === 'yes') {
+ handleSlashCommand('/ide install');
+ settings.setValue(
+ SettingScope.User,
+ 'hasSeenIdeIntegrationNudge',
+ true,
+ );
+ } else if (result.userSelection === 'dismiss') {
+ settings.setValue(
+ SettingScope.User,
+ 'hasSeenIdeIntegrationNudge',
+ true,
+ );
+ }
+ setIdePromptAnswered(true);
+ },
+ [handleSlashCommand, settings],
+ );
+
+ const { elapsedTime, currentLoadingPhrase } =
+ useLoadingIndicator(streamingState);
+
+ const handleExit = useCallback(
+ (
+ pressedOnce: boolean,
+ setPressedOnce: (value: boolean) => void,
+ timerRef: React.MutableRefObject,
+ ) => {
+ if (pressedOnce) {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ }
+ handleSlashCommand('/quit');
+ } else {
+ setPressedOnce(true);
+ timerRef.current = setTimeout(() => {
+ setPressedOnce(false);
+ timerRef.current = null;
+ }, CTRL_EXIT_PROMPT_DURATION_MS);
+ }
+ },
+ [handleSlashCommand],
+ );
+
+ const handleGlobalKeypress = useCallback(
+ (key: Key) => {
+ // Debug log keystrokes if enabled
+ if (settings.merged.general?.debugKeystrokeLogging) {
+ console.log('[DEBUG] Keystroke:', JSON.stringify(key));
+ }
+
+ const anyDialogOpen =
+ isThemeDialogOpen ||
+ isAuthDialogOpen ||
+ isEditorDialogOpen ||
+ isSettingsDialogOpen ||
+ isFolderTrustDialogOpen ||
+ isAuthenticating ||
+ showPrivacyNotice;
+ if (anyDialogOpen) {
+ return;
+ }
+
+ let enteringConstrainHeightMode = false;
+ if (!constrainHeight) {
+ enteringConstrainHeightMode = true;
+ setConstrainHeight(true);
+ }
+
+ if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
+ setShowErrorDetails((prev) => !prev);
+ } else if (keyMatchers[Command.TOGGLE_TOOL_DESCRIPTIONS](key)) {
+ const newValue = !showToolDescriptions;
+ setShowToolDescriptions(newValue);
+
+ const mcpServers = config.getMcpServers();
+ if (Object.keys(mcpServers || {}).length > 0) {
+ handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc');
+ }
+ } else if (
+ keyMatchers[Command.TOGGLE_IDE_CONTEXT_DETAIL](key) &&
+ config.getIdeMode() &&
+ ideContextState
+ ) {
+ handleSlashCommand('/ide status');
+ } else if (keyMatchers[Command.QUIT](key)) {
+ if (isAuthenticating) {
+ return;
+ }
+ if (!ctrlCPressedOnce) {
+ cancelOngoingRequest?.();
+ }
+ handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
+ } else if (keyMatchers[Command.EXIT](key)) {
+ if (buffer.text.length > 0) {
+ return;
+ }
+ handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
+ } else if (
+ keyMatchers[Command.SHOW_MORE_LINES](key) &&
+ !enteringConstrainHeightMode
+ ) {
+ setConstrainHeight(false);
+ }
+ },
+ [
+ constrainHeight,
+ setConstrainHeight,
+ setShowErrorDetails,
+ showToolDescriptions,
+ setShowToolDescriptions,
+ config,
+ ideContextState,
+ handleExit,
+ ctrlCPressedOnce,
+ setCtrlCPressedOnce,
+ ctrlCTimerRef,
+ buffer.text.length,
+ ctrlDPressedOnce,
+ setCtrlDPressedOnce,
+ ctrlDTimerRef,
+ handleSlashCommand,
+ isAuthenticating,
+ cancelOngoingRequest,
+ isThemeDialogOpen,
+ isAuthDialogOpen,
+ isEditorDialogOpen,
+ isSettingsDialogOpen,
+ isFolderTrustDialogOpen,
+ showPrivacyNotice,
+ settings.merged.general?.debugKeystrokeLogging,
+ ],
+ );
+
+ useKeypress(handleGlobalKeypress, { isActive: true });
+ useKeypress(
+ (key) => {
+ if (key.name === 'r' || key.name === 'R') {
+ process.exit(0);
+ }
+ },
+ { isActive: showIdeRestartPrompt },
+ );
+
+ const filteredConsoleMessages = useMemo(() => {
+ if (config.getDebugMode()) {
+ return consoleMessages;
+ }
+ return consoleMessages.filter((msg) => msg.type !== 'debug');
+ }, [consoleMessages, config]);
+
+ // Computed values
+ const errorCount = useMemo(
+ () =>
+ filteredConsoleMessages
+ .filter((msg) => msg.type === 'error')
+ .reduce((total, msg) => total + msg.count, 0),
+ [filteredConsoleMessages],
+ );
+
+ const nightly = props.version.includes('nightly');
+
+ const dialogsVisible = useMemo(
+ () =>
+ showWorkspaceMigrationDialog ||
+ shouldShowIdePrompt ||
+ isFolderTrustDialogOpen ||
+ !!shellConfirmationRequest ||
+ !!confirmationRequest ||
+ isThemeDialogOpen ||
+ isSettingsDialogOpen ||
+ isAuthenticating ||
+ isAuthDialogOpen ||
+ isEditorDialogOpen ||
+ showPrivacyNotice ||
+ isProQuotaDialogOpen,
+ [
+ showWorkspaceMigrationDialog,
+ shouldShowIdePrompt,
+ isFolderTrustDialogOpen,
+ shellConfirmationRequest,
+ confirmationRequest,
+ isThemeDialogOpen,
+ isSettingsDialogOpen,
+ isAuthenticating,
+ isAuthDialogOpen,
+ isEditorDialogOpen,
+ showPrivacyNotice,
+ isProQuotaDialogOpen,
+ ],
+ );
+
+ const pendingHistoryItems = useMemo(
+ () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
+ [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
+ );
+
+ const uiState: UIState = useMemo(
+ () => ({
+ history: historyManager.history,
+ isThemeDialogOpen,
+ themeError,
+ isAuthenticating,
+ authError,
+ isAuthDialogOpen,
+ editorError,
+ isEditorDialogOpen,
+ showPrivacyNotice,
+ corgiMode,
+ debugMessage,
+ quittingMessages,
+ isSettingsDialogOpen,
+ slashCommands,
+ pendingSlashCommandHistoryItems,
+ commandContext,
+ shellConfirmationRequest,
+ confirmationRequest,
+ geminiMdFileCount,
+ streamingState,
+ initError,
+ pendingGeminiHistoryItems,
+ thought,
+ shellModeActive,
+ userMessages,
+ buffer,
+ inputWidth,
+ suggestionsWidth,
+ isInputActive,
+ shouldShowIdePrompt,
+ isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
+ isTrustedFolder,
+ constrainHeight,
+ showErrorDetails,
+ filteredConsoleMessages,
+ ideContextState,
+ showToolDescriptions,
+ ctrlCPressedOnce,
+ ctrlDPressedOnce,
+ showEscapePrompt,
+ isFocused,
+ elapsedTime,
+ currentLoadingPhrase,
+ historyRemountKey,
+ messageQueue,
+ showAutoAcceptIndicator,
+ showWorkspaceMigrationDialog,
+ workspaceExtensions,
+ // Use current state values instead of config.getModel()
+ currentModel,
+ userTier,
+ isProQuotaDialogOpen,
+ // New fields
+ contextFileNames,
+ errorCount,
+ availableTerminalHeight,
+ mainAreaWidth,
+ staticAreaMaxItemHeight,
+ staticExtraHeight,
+ dialogsVisible,
+ pendingHistoryItems,
+ nightly,
+ branchName,
+ sessionStats,
+ terminalWidth,
+ terminalHeight,
+ mainControlsRef,
+ currentIDE,
+ updateInfo,
+ showIdeRestartPrompt,
+ isRestarting,
+ }),
+ [
+ historyManager.history,
+ isThemeDialogOpen,
+ themeError,
+ isAuthenticating,
+ authError,
+ isAuthDialogOpen,
+ editorError,
+ isEditorDialogOpen,
+ showPrivacyNotice,
+ corgiMode,
+ debugMessage,
+ quittingMessages,
+ isSettingsDialogOpen,
+ slashCommands,
+ pendingSlashCommandHistoryItems,
+ commandContext,
+ shellConfirmationRequest,
+ confirmationRequest,
+ geminiMdFileCount,
+ streamingState,
+ initError,
+ pendingGeminiHistoryItems,
+ thought,
+ shellModeActive,
+ userMessages,
+ buffer,
+ inputWidth,
+ suggestionsWidth,
+ isInputActive,
+ shouldShowIdePrompt,
+ isFolderTrustDialogOpen,
+ isTrustedFolder,
+ constrainHeight,
+ showErrorDetails,
+ filteredConsoleMessages,
+ ideContextState,
+ showToolDescriptions,
+ ctrlCPressedOnce,
+ ctrlDPressedOnce,
+ showEscapePrompt,
+ isFocused,
+ elapsedTime,
+ currentLoadingPhrase,
+ historyRemountKey,
+ messageQueue,
+ showAutoAcceptIndicator,
+ showWorkspaceMigrationDialog,
+ workspaceExtensions,
+ // Quota-related state dependencies
+ userTier,
+ isProQuotaDialogOpen,
+ // New fields dependencies
+ contextFileNames,
+ errorCount,
+ availableTerminalHeight,
+ mainAreaWidth,
+ staticAreaMaxItemHeight,
+ staticExtraHeight,
+ dialogsVisible,
+ pendingHistoryItems,
+ nightly,
+ branchName,
+ sessionStats,
+ terminalWidth,
+ terminalHeight,
+ mainControlsRef,
+ currentIDE,
+ updateInfo,
+ showIdeRestartPrompt,
+ isRestarting,
+ // Quota-related dependencies
+ currentModel,
+ ],
+ );
+
+ const uiActions: UIActions = useMemo(
+ () => ({
+ handleThemeSelect,
+ handleThemeHighlight,
+ handleAuthSelect,
+ setAuthState,
+ onAuthError,
+ handleEditorSelect,
+ exitEditorDialog,
+ exitPrivacyNotice: () => setShowPrivacyNotice(false),
+ closeSettingsDialog,
+ setShellModeActive,
+ vimHandleInput,
+ handleIdePromptComplete,
+ handleFolderTrustSelect,
+ setConstrainHeight,
+ onEscapePromptChange: handleEscapePromptChange,
+ refreshStatic,
+ handleFinalSubmit,
+ handleClearScreen,
+ onWorkspaceMigrationDialogOpen,
+ onWorkspaceMigrationDialogClose,
+ handleProQuotaChoice,
+ }),
+ [
+ handleThemeSelect,
+ handleThemeHighlight,
+ handleAuthSelect,
+ setAuthState,
+ onAuthError,
+ handleEditorSelect,
+ exitEditorDialog,
+ closeSettingsDialog,
+ setShellModeActive,
+ vimHandleInput,
+ handleIdePromptComplete,
+ handleFolderTrustSelect,
+ setConstrainHeight,
+ handleEscapePromptChange,
+ refreshStatic,
+ handleFinalSubmit,
+ handleClearScreen,
+ onWorkspaceMigrationDialogOpen,
+ onWorkspaceMigrationDialogClose,
+ handleProQuotaChoice,
+ ],
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts
index bb2cd19e6a..90a7480358 100644
--- a/packages/cli/src/ui/commands/ideCommand.test.ts
+++ b/packages/cli/src/ui/commands/ideCommand.test.ts
@@ -20,9 +20,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
IdeClient: {
getInstance: vi.fn(),
},
- ideContext: {
- getIdeContext: vi.fn(),
- },
};
});
diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx
new file mode 100644
index 0000000000..685799111b
--- /dev/null
+++ b/packages/cli/src/ui/components/AppHeader.tsx
@@ -0,0 +1,32 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Box } from 'ink';
+import { Header } from './Header.js';
+import { Tips } from './Tips.js';
+import { useSettings } from '../contexts/SettingsContext.js';
+import { useConfig } from '../contexts/ConfigContext.js';
+import { useUIState } from '../contexts/UIStateContext.js';
+
+interface AppHeaderProps {
+ version: string;
+}
+
+export const AppHeader = ({ version }: AppHeaderProps) => {
+ const settings = useSettings();
+ const config = useConfig();
+ const { nightly } = useUIState();
+ return (
+
+ {!(settings.merged.ui?.hideBanner || config.getScreenReader()) && (
+
+ )}
+ {!(settings.merged.ui?.hideTips || config.getScreenReader()) && (
+
+ )}
+
+ );
+};
diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx
new file mode 100644
index 0000000000..2abb973b5b
--- /dev/null
+++ b/packages/cli/src/ui/components/Composer.test.tsx
@@ -0,0 +1,433 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render } from 'ink-testing-library';
+import { Text } from 'ink';
+import { Composer } from './Composer.js';
+import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
+import {
+ UIActionsContext,
+ type UIActions,
+} from '../contexts/UIActionsContext.js';
+import { ConfigContext } from '../contexts/ConfigContext.js';
+import { SettingsContext } from '../contexts/SettingsContext.js';
+// Mock VimModeContext hook
+vi.mock('../contexts/VimModeContext.js', () => ({
+ useVimMode: vi.fn(() => ({
+ vimEnabled: false,
+ vimMode: 'NORMAL',
+ })),
+}));
+import { ApprovalMode } from '@google/gemini-cli-core';
+import { StreamingState } from '../types.js';
+
+// Mock child components
+vi.mock('./LoadingIndicator.js', () => ({
+ LoadingIndicator: ({ thought }: { thought?: string }) => (
+ LoadingIndicator{thought ? `: ${thought}` : ''}
+ ),
+}));
+
+vi.mock('./ContextSummaryDisplay.js', () => ({
+ ContextSummaryDisplay: () => ContextSummaryDisplay,
+}));
+
+vi.mock('./AutoAcceptIndicator.js', () => ({
+ AutoAcceptIndicator: () => AutoAcceptIndicator,
+}));
+
+vi.mock('./ShellModeIndicator.js', () => ({
+ ShellModeIndicator: () => ShellModeIndicator,
+}));
+
+vi.mock('./DetailedMessagesDisplay.js', () => ({
+ DetailedMessagesDisplay: () => DetailedMessagesDisplay,
+}));
+
+vi.mock('./InputPrompt.js', () => ({
+ InputPrompt: () => InputPrompt,
+}));
+
+vi.mock('./Footer.js', () => ({
+ Footer: () => Footer,
+}));
+
+vi.mock('./ShowMoreLines.js', () => ({
+ ShowMoreLines: () => ShowMoreLines,
+}));
+
+// Mock contexts
+vi.mock('../contexts/OverflowContext.js', () => ({
+ OverflowProvider: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+// Create mock context providers
+const createMockUIState = (overrides: Partial = {}): UIState =>
+ ({
+ streamingState: null,
+ contextFileNames: [],
+ showAutoAcceptIndicator: ApprovalMode.DEFAULT,
+ messageQueue: [],
+ showErrorDetails: false,
+ constrainHeight: false,
+ isInputActive: true,
+ buffer: '',
+ inputWidth: 80,
+ suggestionsWidth: 40,
+ userMessages: [],
+ slashCommands: [],
+ commandContext: null,
+ shellModeActive: false,
+ isFocused: true,
+ thought: '',
+ currentLoadingPhrase: '',
+ elapsedTime: 0,
+ ctrlCPressedOnce: false,
+ ctrlDPressedOnce: false,
+ showEscapePrompt: false,
+ ideContextState: null,
+ geminiMdFileCount: 0,
+ showToolDescriptions: false,
+ filteredConsoleMessages: [],
+ sessionStats: {
+ lastPromptTokenCount: 0,
+ sessionTokenCount: 0,
+ totalPrompts: 0,
+ },
+ branchName: 'main',
+ debugMessage: '',
+ corgiMode: false,
+ errorCount: 0,
+ nightly: false,
+ isTrustedFolder: true,
+ ...overrides,
+ }) as UIState;
+
+const createMockUIActions = (): UIActions =>
+ ({
+ handleFinalSubmit: vi.fn(),
+ handleClearScreen: vi.fn(),
+ setShellModeActive: vi.fn(),
+ onEscapePromptChange: vi.fn(),
+ vimHandleInput: vi.fn(),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ }) as any;
+
+const createMockConfig = (overrides = {}) => ({
+ getModel: vi.fn(() => 'gemini-1.5-pro'),
+ getTargetDir: vi.fn(() => '/test/dir'),
+ getDebugMode: vi.fn(() => false),
+ getAccessibility: vi.fn(() => ({})),
+ getMcpServers: vi.fn(() => ({})),
+ getBlockedMcpServers: vi.fn(() => []),
+ ...overrides,
+});
+
+const createMockSettings = (merged = {}) => ({
+ merged: {
+ hideFooter: false,
+ showMemoryUsage: false,
+ ...merged,
+ },
+});
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+const renderComposer = (
+ uiState: UIState,
+ settings = createMockSettings(),
+ config = createMockConfig(),
+ uiActions = createMockUIActions(),
+) =>
+ render(
+
+
+
+
+
+
+
+
+ ,
+ );
+/* eslint-enable @typescript-eslint/no-explicit-any */
+
+describe('Composer', () => {
+ describe('Footer Display Settings', () => {
+ it('renders Footer by default when hideFooter is false', () => {
+ const uiState = createMockUIState();
+ const settings = createMockSettings({ hideFooter: false });
+
+ const { lastFrame } = renderComposer(uiState, settings);
+
+ expect(lastFrame()).toContain('Footer');
+ });
+
+ it('does NOT render Footer when hideFooter is true', () => {
+ const uiState = createMockUIState();
+ const settings = createMockSettings({ hideFooter: true });
+
+ const { lastFrame } = renderComposer(uiState, settings);
+
+ // Check for content that only appears IN the Footer component itself
+ expect(lastFrame()).not.toContain('[NORMAL]'); // Vim mode indicator
+ expect(lastFrame()).not.toContain('(main'); // Branch name with parentheses
+ });
+
+ it('passes correct props to Footer including vim mode when enabled', async () => {
+ const uiState = createMockUIState({
+ branchName: 'feature-branch',
+ corgiMode: true,
+ errorCount: 2,
+ sessionStats: {
+ sessionId: 'test-session',
+ sessionStartTime: new Date(),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ metrics: {} as any,
+ lastPromptTokenCount: 150,
+ promptCount: 5,
+ },
+ });
+ const config = createMockConfig({
+ getModel: vi.fn(() => 'gemini-1.5-flash'),
+ getTargetDir: vi.fn(() => '/project/path'),
+ getDebugMode: vi.fn(() => true),
+ });
+ const settings = createMockSettings({
+ hideFooter: false,
+ showMemoryUsage: true,
+ });
+ // Mock vim mode for this test
+ const { useVimMode } = await import('../contexts/VimModeContext.js');
+ vi.mocked(useVimMode).mockReturnValueOnce({
+ vimEnabled: true,
+ vimMode: 'INSERT',
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any);
+
+ const { lastFrame } = renderComposer(uiState, settings, config);
+
+ expect(lastFrame()).toContain('Footer');
+ // Footer should be rendered with all the state passed through
+ });
+ });
+
+ describe('Loading Indicator', () => {
+ it('renders LoadingIndicator with thought when streaming', () => {
+ const uiState = createMockUIState({
+ streamingState: StreamingState.Responding,
+ thought: {
+ subject: 'Processing',
+ description: 'Processing your request...',
+ },
+ currentLoadingPhrase: 'Analyzing',
+ elapsedTime: 1500,
+ });
+
+ const { lastFrame } = renderComposer(uiState);
+
+ const output = lastFrame();
+ expect(output).toContain('LoadingIndicator');
+ });
+
+ it('renders LoadingIndicator without thought when accessibility disables loading phrases', () => {
+ const uiState = createMockUIState({
+ streamingState: StreamingState.Responding,
+ thought: { subject: 'Hidden', description: 'Should not show' },
+ });
+ const config = createMockConfig({
+ getAccessibility: vi.fn(() => ({ disableLoadingPhrases: true })),
+ });
+
+ const { lastFrame } = renderComposer(uiState, undefined, config);
+
+ const output = lastFrame();
+ expect(output).toContain('LoadingIndicator');
+ expect(output).not.toContain('Should not show');
+ });
+
+ it('suppresses thought when waiting for confirmation', () => {
+ const uiState = createMockUIState({
+ streamingState: StreamingState.WaitingForConfirmation,
+ thought: {
+ subject: 'Confirmation',
+ description: 'Should not show during confirmation',
+ },
+ });
+
+ const { lastFrame } = renderComposer(uiState);
+
+ const output = lastFrame();
+ expect(output).toContain('LoadingIndicator');
+ expect(output).not.toContain('Should not show during confirmation');
+ });
+ });
+
+ describe('Message Queue Display', () => {
+ it('displays queued messages when present', () => {
+ const uiState = createMockUIState({
+ messageQueue: [
+ 'First queued message',
+ 'Second queued message',
+ 'Third queued message',
+ ],
+ });
+
+ const { lastFrame } = renderComposer(uiState);
+
+ const output = lastFrame();
+ expect(output).toContain('First queued message');
+ expect(output).toContain('Second queued message');
+ expect(output).toContain('Third queued message');
+ });
+
+ it('shows overflow indicator when more than 3 messages are queued', () => {
+ const uiState = createMockUIState({
+ messageQueue: [
+ 'Message 1',
+ 'Message 2',
+ 'Message 3',
+ 'Message 4',
+ 'Message 5',
+ ],
+ });
+
+ const { lastFrame } = renderComposer(uiState);
+
+ const output = lastFrame();
+ expect(output).toContain('Message 1');
+ expect(output).toContain('Message 2');
+ expect(output).toContain('Message 3');
+ expect(output).toContain('... (+2 more)');
+ });
+
+ it('does not display message queue section when empty', () => {
+ const uiState = createMockUIState({
+ messageQueue: [],
+ });
+
+ const { lastFrame } = renderComposer(uiState);
+
+ // Should not contain queued message indicators
+ const output = lastFrame();
+ expect(output).not.toContain('more)');
+ });
+ });
+
+ describe('Context and Status Display', () => {
+ it('shows ContextSummaryDisplay in normal state', () => {
+ const uiState = createMockUIState({
+ ctrlCPressedOnce: false,
+ ctrlDPressedOnce: false,
+ showEscapePrompt: false,
+ });
+
+ const { lastFrame } = renderComposer(uiState);
+
+ expect(lastFrame()).toContain('ContextSummaryDisplay');
+ });
+
+ it('shows Ctrl+C exit prompt when ctrlCPressedOnce is true', () => {
+ const uiState = createMockUIState({
+ ctrlCPressedOnce: true,
+ });
+
+ const { lastFrame } = renderComposer(uiState);
+
+ expect(lastFrame()).toContain('Press Ctrl+C again to exit');
+ });
+
+ it('shows Ctrl+D exit prompt when ctrlDPressedOnce is true', () => {
+ const uiState = createMockUIState({
+ ctrlDPressedOnce: true,
+ });
+
+ const { lastFrame } = renderComposer(uiState);
+
+ expect(lastFrame()).toContain('Press Ctrl+D again to exit');
+ });
+
+ it('shows escape prompt when showEscapePrompt is true', () => {
+ const uiState = createMockUIState({
+ showEscapePrompt: true,
+ });
+
+ const { lastFrame } = renderComposer(uiState);
+
+ expect(lastFrame()).toContain('Press Esc again to clear');
+ });
+ });
+
+ describe('Input and Indicators', () => {
+ it('renders InputPrompt when input is active', () => {
+ const uiState = createMockUIState({
+ isInputActive: true,
+ });
+
+ const { lastFrame } = renderComposer(uiState);
+
+ expect(lastFrame()).toContain('InputPrompt');
+ });
+
+ it('does not render InputPrompt when input is inactive', () => {
+ const uiState = createMockUIState({
+ isInputActive: false,
+ });
+
+ const { lastFrame } = renderComposer(uiState);
+
+ expect(lastFrame()).not.toContain('InputPrompt');
+ });
+
+ it('shows AutoAcceptIndicator when approval mode is not default and shell mode is inactive', () => {
+ const uiState = createMockUIState({
+ showAutoAcceptIndicator: ApprovalMode.YOLO,
+ shellModeActive: false,
+ });
+
+ const { lastFrame } = renderComposer(uiState);
+
+ expect(lastFrame()).toContain('AutoAcceptIndicator');
+ });
+
+ it('shows ShellModeIndicator when shell mode is active', () => {
+ const uiState = createMockUIState({
+ shellModeActive: true,
+ });
+
+ const { lastFrame } = renderComposer(uiState);
+
+ expect(lastFrame()).toContain('ShellModeIndicator');
+ });
+ });
+
+ describe('Error Details Display', () => {
+ it('shows DetailedMessagesDisplay when showErrorDetails is true', () => {
+ const uiState = createMockUIState({
+ showErrorDetails: true,
+ filteredConsoleMessages: [
+ { level: 'error', message: 'Test error', timestamp: new Date() },
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ] as any,
+ });
+
+ const { lastFrame } = renderComposer(uiState);
+
+ expect(lastFrame()).toContain('DetailedMessagesDisplay');
+ expect(lastFrame()).toContain('ShowMoreLines');
+ });
+
+ it('does not show error details when showErrorDetails is false', () => {
+ const uiState = createMockUIState({
+ showErrorDetails: false,
+ });
+
+ const { lastFrame } = renderComposer(uiState);
+
+ expect(lastFrame()).not.toContain('DetailedMessagesDisplay');
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx
new file mode 100644
index 0000000000..d84dc602bd
--- /dev/null
+++ b/packages/cli/src/ui/components/Composer.tsx
@@ -0,0 +1,186 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Box, Text } from 'ink';
+import { LoadingIndicator } from './LoadingIndicator.js';
+import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
+import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
+import { ShellModeIndicator } from './ShellModeIndicator.js';
+import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
+import { InputPrompt } from './InputPrompt.js';
+import { Footer, type FooterProps } from './Footer.js';
+import { ShowMoreLines } from './ShowMoreLines.js';
+import { OverflowProvider } from '../contexts/OverflowContext.js';
+import { Colors } from '../colors.js';
+import { isNarrowWidth } from '../utils/isNarrowWidth.js';
+import { useUIState } from '../contexts/UIStateContext.js';
+import { useUIActions } from '../contexts/UIActionsContext.js';
+import { useVimMode } from '../contexts/VimModeContext.js';
+import { useConfig } from '../contexts/ConfigContext.js';
+import { useSettings } from '../contexts/SettingsContext.js';
+import { ApprovalMode } from '@google/gemini-cli-core';
+import { StreamingState } from '../types.js';
+
+const MAX_DISPLAYED_QUEUED_MESSAGES = 3;
+
+export const Composer = () => {
+ const config = useConfig();
+ const settings = useSettings();
+ const uiState = useUIState();
+ const uiActions = useUIActions();
+ const { vimEnabled, vimMode } = useVimMode();
+ const terminalWidth = process.stdout.columns;
+ const isNarrow = isNarrowWidth(terminalWidth);
+ const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
+
+ const { contextFileNames, showAutoAcceptIndicator } = uiState;
+
+ // Build footer props from context values
+ const footerProps: Omit = {
+ model: config.getModel(),
+ targetDir: config.getTargetDir(),
+ debugMode: config.getDebugMode(),
+ branchName: uiState.branchName,
+ debugMessage: uiState.debugMessage,
+ corgiMode: uiState.corgiMode,
+ errorCount: uiState.errorCount,
+ showErrorDetails: uiState.showErrorDetails,
+ showMemoryUsage:
+ config.getDebugMode() || settings.merged.ui?.showMemoryUsage || false,
+ promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
+ nightly: uiState.nightly,
+ isTrustedFolder: uiState.isTrustedFolder,
+ };
+
+ return (
+
+
+
+ {uiState.messageQueue.length > 0 && (
+
+ {uiState.messageQueue
+ .slice(0, MAX_DISPLAYED_QUEUED_MESSAGES)
+ .map((message, index) => {
+ const preview = message.replace(/\s+/g, ' ');
+
+ return (
+
+
+ {preview}
+
+
+ );
+ })}
+ {uiState.messageQueue.length > MAX_DISPLAYED_QUEUED_MESSAGES && (
+
+
+ ... (+
+ {uiState.messageQueue.length -
+ MAX_DISPLAYED_QUEUED_MESSAGES}{' '}
+ more)
+
+
+ )}
+
+ )}
+
+
+
+ {process.env['GEMINI_SYSTEM_MD'] && (
+ |⌐■_■|
+ )}
+ {uiState.ctrlCPressedOnce ? (
+ Press Ctrl+C again to exit.
+ ) : uiState.ctrlDPressedOnce ? (
+ Press Ctrl+D again to exit.
+ ) : uiState.showEscapePrompt ? (
+ Press Esc again to clear.
+ ) : (
+ !settings.merged.ui?.hideContextSummary && (
+
+ )
+ )}
+
+
+ {showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
+ !uiState.shellModeActive && (
+
+ )}
+ {uiState.shellModeActive && }
+
+
+
+ {uiState.showErrorDetails && (
+
+
+
+
+
+
+ )}
+
+ {uiState.isInputActive && (
+
+ )}
+
+ {!settings.merged.ui?.hideFooter && (
+
+ )}
+
+ );
+};
diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx
new file mode 100644
index 0000000000..4cd39fe4b5
--- /dev/null
+++ b/packages/cli/src/ui/components/DialogManager.tsx
@@ -0,0 +1,184 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Box, Text } from 'ink';
+import { IdeIntegrationNudge } from '../IdeIntegrationNudge.js';
+import { FolderTrustDialog } from './FolderTrustDialog.js';
+import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
+import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
+import { ThemeDialog } from './ThemeDialog.js';
+import { SettingsDialog } from './SettingsDialog.js';
+import { AuthInProgress } from '../auth/AuthInProgress.js';
+import { AuthDialog } from '../auth/AuthDialog.js';
+import { EditorSettingsDialog } from './EditorSettingsDialog.js';
+import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
+import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
+import { ProQuotaDialog } from './ProQuotaDialog.js';
+import { Colors } from '../colors.js';
+import { useUIState } from '../contexts/UIStateContext.js';
+import { useUIActions } from '../contexts/UIActionsContext.js';
+import { useConfig } from '../contexts/ConfigContext.js';
+import { useSettings } from '../contexts/SettingsContext.js';
+import { DEFAULT_GEMINI_FLASH_MODEL } from '@google/gemini-cli-core';
+import process from 'node:process';
+
+// Props for DialogManager
+export const DialogManager = () => {
+ const config = useConfig();
+ const settings = useSettings();
+
+ const uiState = useUIState();
+ const uiActions = useUIActions();
+ const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
+ uiState;
+
+ if (uiState.showIdeRestartPrompt) {
+ return (
+
+
+ Workspace trust has changed. Press 'r' to restart Gemini to
+ apply the changes.
+
+
+ );
+ }
+ if (uiState.showWorkspaceMigrationDialog) {
+ return (
+
+ );
+ }
+ if (uiState.isProQuotaDialogOpen) {
+ return (
+
+ );
+ }
+ if (uiState.shouldShowIdePrompt) {
+ return (
+
+ );
+ }
+ if (uiState.isFolderTrustDialogOpen) {
+ return (
+
+ );
+ }
+ if (uiState.shellConfirmationRequest) {
+ return (
+
+ );
+ }
+ if (uiState.confirmationRequest) {
+ return (
+
+ {uiState.confirmationRequest.prompt}
+
+ {
+ uiState.confirmationRequest!.onConfirm(value);
+ }}
+ />
+
+
+ );
+ }
+ if (uiState.isThemeDialogOpen) {
+ return (
+
+ {uiState.themeError && (
+
+ {uiState.themeError}
+
+ )}
+
+
+ );
+ }
+ if (uiState.isSettingsDialogOpen) {
+ return (
+
+ uiActions.closeSettingsDialog()}
+ onRestartRequest={() => process.exit(0)}
+ />
+
+ );
+ }
+ if (uiState.isAuthenticating) {
+ return (
+ {
+ /* This is now handled in AppContainer */
+ }}
+ />
+ );
+ }
+ if (uiState.isAuthDialogOpen) {
+ return (
+
+
+
+ );
+ }
+ if (uiState.isEditorDialogOpen) {
+ return (
+
+ {uiState.editorError && (
+
+ {uiState.editorError}
+
+ )}
+
+
+ );
+ }
+ if (uiState.showPrivacyNotice) {
+ return (
+ uiActions.exitPrivacyNotice()}
+ config={config}
+ />
+ );
+ }
+
+ return null;
+};
diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx
index fbeb2eeef6..e3673dfee7 100644
--- a/packages/cli/src/ui/components/Footer.test.tsx
+++ b/packages/cli/src/ui/components/Footer.test.tsx
@@ -156,31 +156,4 @@ describe('', () => {
vi.unstubAllEnvs();
});
});
-
- describe('visibility toggles', () => {
- it('should hide CWD when hideCWD is true', () => {
- const { lastFrame } = renderWithWidth(120, {
- ...defaultProps,
- hideCWD: true,
- });
- expect(lastFrame()).not.toContain(defaultProps.targetDir);
- });
-
- it('should hide sandbox status when hideSandboxStatus is true', () => {
- const { lastFrame } = renderWithWidth(120, {
- ...defaultProps,
- isTrustedFolder: true,
- hideSandboxStatus: true,
- });
- expect(lastFrame()).not.toContain('no sandbox');
- });
-
- it('should hide model info when hideModelInfo is true', () => {
- const { lastFrame } = renderWithWidth(120, {
- ...defaultProps,
- hideModelInfo: true,
- });
- expect(lastFrame()).not.toContain(defaultProps.model);
- });
- });
});
diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx
index 9ecd7058be..f1cb7445fc 100644
--- a/packages/cli/src/ui/components/Footer.tsx
+++ b/packages/cli/src/ui/components/Footer.tsx
@@ -19,7 +19,7 @@ import { DebugProfiler } from './DebugProfiler.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
-interface FooterProps {
+export interface FooterProps {
model: string;
targetDir: string;
branchName?: string;
@@ -33,9 +33,6 @@ interface FooterProps {
nightly: boolean;
vimMode?: string;
isTrustedFolder?: boolean;
- hideCWD?: boolean;
- hideSandboxStatus?: boolean;
- hideModelInfo?: boolean;
}
export const Footer: React.FC = ({
@@ -52,9 +49,6 @@ export const Footer: React.FC = ({
nightly,
vimMode,
isTrustedFolder,
- hideCWD = false,
- hideSandboxStatus = false,
- hideModelInfo = false,
}) => {
const { columns: terminalWidth } = useTerminalSize();
@@ -66,93 +60,85 @@ export const Footer: React.FC = ({
? path.basename(tildeifyPath(targetDir))
: shortenPath(tildeifyPath(targetDir), pathLength);
- const justifyContent = hideCWD && hideModelInfo ? 'center' : 'space-between';
-
return (
- {!hideCWD && (
-
- {debugMode && }
- {vimMode && [{vimMode}] }
- {nightly ? (
-
-
- {displayPath}
- {branchName && ({branchName}*)}
-
-
- ) : (
-
+
+ {debugMode && }
+ {vimMode && [{vimMode}] }
+ {nightly ? (
+
+
{displayPath}
- {branchName && (
- ({branchName}*)
- )}
+ {branchName && ({branchName}*)}
- )}
- {debugMode && (
-
- {' ' + (debugMessage || '--debug')}
-
- )}
-
- )}
+
+ ) : (
+
+ {displayPath}
+ {branchName && (
+ ({branchName}*)
+ )}
+
+ )}
+ {debugMode && (
+
+ {' ' + (debugMessage || '--debug')}
+
+ )}
+
{/* Middle Section: Centered Trust/Sandbox Info */}
- {!hideSandboxStatus && (
-
- {isTrustedFolder === false ? (
- untrusted
- ) : process.env['SANDBOX'] &&
- process.env['SANDBOX'] !== 'sandbox-exec' ? (
-
- {process.env['SANDBOX'].replace(/^gemini-(?:cli-)?/, '')}
+
+ {isTrustedFolder === false ? (
+ untrusted
+ ) : process.env['SANDBOX'] &&
+ process.env['SANDBOX'] !== 'sandbox-exec' ? (
+
+ {process.env['SANDBOX'].replace(/^gemini-(?:cli-)?/, '')}
+
+ ) : process.env['SANDBOX'] === 'sandbox-exec' ? (
+
+ macOS Seatbelt{' '}
+
+ ({process.env['SEATBELT_PROFILE']})
- ) : process.env['SANDBOX'] === 'sandbox-exec' ? (
-
- macOS Seatbelt{' '}
-
- ({process.env['SEATBELT_PROFILE']})
-
-
- ) : (
-
- no sandbox (see /docs)
-
- )}
-
- )}
+
+ ) : (
+
+ no sandbox (see /docs)
+
+ )}
+
{/* Right Section: Gemini Label and Console Summary */}
- {!hideModelInfo && (
-
-
- {isNarrow ? '' : ' '}
- {model}{' '}
-
-
- {showMemoryUsage && }
-
- )}
+
+
+ {isNarrow ? '' : ' '}
+ {model}{' '}
+
+
+ {showMemoryUsage && }
+
{corgiMode && (
- {!hideModelInfo && | }
+ |
▼
(´
ᴥ
@@ -162,7 +148,7 @@ export const Footer: React.FC = ({
)}
{!showErrorDetails && errorCount > 0 && (
- {!hideModelInfo && | }
+ |
)}
diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
index 7881f9bee9..9c08a5828f 100644
--- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx
+++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
@@ -20,7 +20,6 @@ import { StatsDisplay } from './StatsDisplay.js';
import { ModelStatsDisplay } from './ModelStatsDisplay.js';
import { ToolStatsDisplay } from './ToolStatsDisplay.js';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
-import type { Config } from '@google/gemini-cli-core';
import { Help } from './Help.js';
import type { SlashCommand } from '../commands/types.js';
@@ -29,7 +28,6 @@ interface HistoryItemDisplayProps {
availableTerminalHeight?: number;
terminalWidth: number;
isPending: boolean;
- config: Config;
isFocused?: boolean;
commands?: readonly SlashCommand[];
}
@@ -39,7 +37,6 @@ export const HistoryItemDisplay: React.FC = ({
availableTerminalHeight,
terminalWidth,
isPending,
- config,
commands,
isFocused = true,
}) => (
@@ -87,7 +84,6 @@ export const HistoryItemDisplay: React.FC = ({
groupId={item.id}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
- config={config}
isFocused={isFocused}
/>
)}
diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx
new file mode 100644
index 0000000000..ff63d9f767
--- /dev/null
+++ b/packages/cli/src/ui/components/MainContent.tsx
@@ -0,0 +1,64 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Box, Static } from 'ink';
+import { HistoryItemDisplay } from './HistoryItemDisplay.js';
+import { ShowMoreLines } from './ShowMoreLines.js';
+import { OverflowProvider } from '../contexts/OverflowContext.js';
+import { useUIState } from '../contexts/UIStateContext.js';
+import { useAppContext } from '../contexts/AppContext.js';
+import { AppHeader } from './AppHeader.js';
+
+export const MainContent = () => {
+ const { version } = useAppContext();
+ const uiState = useUIState();
+ const {
+ pendingHistoryItems,
+ mainAreaWidth,
+ staticAreaMaxItemHeight,
+ availableTerminalHeight,
+ } = uiState;
+
+ return (
+ <>
+ ,
+ ...uiState.history.map((h) => (
+
+ )),
+ ]}
+ >
+ {(item) => item}
+
+
+
+ {pendingHistoryItems.map((item, i) => (
+
+ ))}
+
+
+
+ >
+ );
+};
diff --git a/packages/cli/src/ui/components/Notifications.tsx b/packages/cli/src/ui/components/Notifications.tsx
new file mode 100644
index 0000000000..954945d3ad
--- /dev/null
+++ b/packages/cli/src/ui/components/Notifications.tsx
@@ -0,0 +1,62 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Box, Text } from 'ink';
+import { useAppContext } from '../contexts/AppContext.js';
+import { useUIState } from '../contexts/UIStateContext.js';
+import { Colors } from '../colors.js';
+import { StreamingState } from '../types.js';
+import { UpdateNotification } from './UpdateNotification.js';
+
+export const Notifications = () => {
+ const { startupWarnings } = useAppContext();
+ const { initError, streamingState, updateInfo } = useUIState();
+
+ const showStartupWarnings = startupWarnings.length > 0;
+ const showInitError =
+ initError && streamingState !== StreamingState.Responding;
+
+ if (!showStartupWarnings && !showInitError && !updateInfo) {
+ return null;
+ }
+
+ return (
+ <>
+ {updateInfo && }
+ {showStartupWarnings && (
+
+ {startupWarnings.map((warning, index) => (
+
+ {warning}
+
+ ))}
+
+ )}
+ {showInitError && (
+
+
+ Initialization Error: {initError}
+
+
+ {' '}
+ Please check API key and configuration.
+
+
+ )}
+ >
+ );
+};
diff --git a/packages/cli/src/ui/components/QuittingDisplay.tsx b/packages/cli/src/ui/components/QuittingDisplay.tsx
new file mode 100644
index 0000000000..ee81f92012
--- /dev/null
+++ b/packages/cli/src/ui/components/QuittingDisplay.tsx
@@ -0,0 +1,37 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Box } from 'ink';
+import { useUIState } from '../contexts/UIStateContext.js';
+import { HistoryItemDisplay } from './HistoryItemDisplay.js';
+import { useTerminalSize } from '../hooks/useTerminalSize.js';
+
+export const QuittingDisplay = () => {
+ const uiState = useUIState();
+ const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
+
+ const availableTerminalHeight = terminalHeight;
+
+ if (!uiState.quittingMessages) {
+ return null;
+ }
+
+ return (
+
+ {uiState.quittingMessages.map((item) => (
+
+ ))}
+
+ );
+};
diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
index f90cc69387..69b246e9bb 100644
--- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
@@ -7,13 +7,16 @@
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { Text } from 'ink';
+import type React from 'react';
import { ToolGroupMessage } from './ToolGroupMessage.js';
-import { type IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
+import type { IndividualToolCallDisplay } from '../../types.js';
+import { ToolCallStatus } from '../../types.js';
import type {
Config,
ToolCallConfirmationDetails,
} from '@google/gemini-cli-core';
import { TOOL_STATUS } from '../../constants.js';
+import { ConfigContext } from '../../contexts/ConfigContext.js';
// Mock child components to isolate ToolGroupMessage behavior
vi.mock('./ToolMessage.js', () => ({
@@ -81,14 +84,21 @@ describe('', () => {
const baseProps = {
groupId: 1,
terminalWidth: 80,
- config: mockConfig,
isFocused: true,
};
+ // Helper to wrap component with required providers
+ const renderWithProviders = (component: React.ReactElement) =>
+ render(
+
+ {component}
+ ,
+ );
+
describe('Golden Snapshots', () => {
it('renders single successful tool call', () => {
const toolCalls = [createToolCall()];
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
,
);
expect(lastFrame()).toMatchSnapshot();
@@ -115,7 +125,7 @@ describe('', () => {
status: ToolCallStatus.Error,
}),
];
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
,
);
expect(lastFrame()).toMatchSnapshot();
@@ -136,7 +146,7 @@ describe('', () => {
},
}),
];
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
,
);
expect(lastFrame()).toMatchSnapshot();
@@ -151,7 +161,7 @@ describe('', () => {
status: ToolCallStatus.Success,
}),
];
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
,
);
expect(lastFrame()).toMatchSnapshot();
@@ -178,7 +188,7 @@ describe('', () => {
status: ToolCallStatus.Pending,
}),
];
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
,
);
expect(lastFrame()).toMatchSnapshot();
@@ -200,7 +210,7 @@ describe('', () => {
resultDisplay: 'More output here',
}),
];
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
', () => {
it('renders when not focused', () => {
const toolCalls = [createToolCall()];
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
', () => {
'This is a very long description that might cause wrapping issues',
}),
];
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
', () => {
});
it('renders empty tool calls array', () => {
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
,
);
expect(lastFrame()).toMatchSnapshot();
@@ -251,7 +261,7 @@ describe('', () => {
describe('Border Color Logic', () => {
it('uses yellow border when tools are pending', () => {
const toolCalls = [createToolCall({ status: ToolCallStatus.Pending })];
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
,
);
// The snapshot will capture the visual appearance including border color
@@ -265,7 +275,7 @@ describe('', () => {
status: ToolCallStatus.Success,
}),
];
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
,
);
expect(lastFrame()).toMatchSnapshot();
@@ -280,7 +290,7 @@ describe('', () => {
status: ToolCallStatus.Success,
}),
];
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
,
);
expect(lastFrame()).toMatchSnapshot();
@@ -303,7 +313,7 @@ describe('', () => {
resultDisplay: '', // No result
}),
];
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
', () => {
},
}),
];
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
,
);
// Should only show confirmation for the first tool
diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
index 9876b703a2..bfa2db3343 100644
--- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
@@ -11,16 +11,15 @@ import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
-import { Colors } from '../../colors.js';
-import type { Config } from '@google/gemini-cli-core';
+import { theme } from '../../semantic-colors.js';
import { SHELL_COMMAND_NAME } from '../../constants.js';
+import { useConfig } from '../../contexts/ConfigContext.js';
interface ToolGroupMessageProps {
groupId: number;
toolCalls: IndividualToolCallDisplay[];
availableTerminalHeight?: number;
terminalWidth: number;
- config: Config;
isFocused?: boolean;
}
@@ -29,15 +28,15 @@ export const ToolGroupMessage: React.FC = ({
toolCalls,
availableTerminalHeight,
terminalWidth,
- config,
isFocused = true,
}) => {
+ const config = useConfig();
const hasPending = !toolCalls.every(
(t) => t.status === ToolCallStatus.Success,
);
const isShellCommand = toolCalls.some((t) => t.name === SHELL_COMMAND_NAME);
const borderColor =
- hasPending || isShellCommand ? Colors.AccentYellow : Colors.Gray;
+ hasPending || isShellCommand ? theme.status.warning : theme.border.default;
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
// This is a bit of a magic number, but it accounts for the border and
diff --git a/packages/cli/src/ui/contexts/AppContext.tsx b/packages/cli/src/ui/contexts/AppContext.tsx
new file mode 100644
index 0000000000..791c8a73ac
--- /dev/null
+++ b/packages/cli/src/ui/contexts/AppContext.tsx
@@ -0,0 +1,22 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { createContext, useContext } from 'react';
+
+export interface AppState {
+ version: string;
+ startupWarnings: string[];
+}
+
+export const AppContext = createContext(null);
+
+export const useAppContext = () => {
+ const context = useContext(AppContext);
+ if (!context) {
+ throw new Error('useAppContext must be used within an AppProvider');
+ }
+ return context;
+};
diff --git a/packages/cli/src/ui/contexts/ConfigContext.tsx b/packages/cli/src/ui/contexts/ConfigContext.tsx
new file mode 100644
index 0000000000..a06687b1df
--- /dev/null
+++ b/packages/cli/src/ui/contexts/ConfigContext.tsx
@@ -0,0 +1,18 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { useContext } from 'react';
+import { type Config } from '@google/gemini-cli-core';
+
+export const ConfigContext = React.createContext(undefined);
+
+export const useConfig = () => {
+ const context = useContext(ConfigContext);
+ if (context === undefined) {
+ throw new Error('useConfig must be used within a ConfigProvider');
+ }
+ return context;
+};
diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx
new file mode 100644
index 0000000000..2a88fd3097
--- /dev/null
+++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { createContext, useContext } from 'react';
+import { type Key } from '../hooks/useKeypress.js';
+import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js';
+import { type FolderTrustChoice } from '../components/FolderTrustDialog.js';
+import { type AuthType, type EditorType } from '@google/gemini-cli-core';
+import { type SettingScope } from '../../config/settings.js';
+import type { AuthState } from '../types.js';
+
+export interface UIActions {
+ handleThemeSelect: (
+ themeName: string | undefined,
+ scope: SettingScope,
+ ) => void;
+ handleThemeHighlight: (themeName: string | undefined) => void;
+ handleAuthSelect: (
+ authType: AuthType | undefined,
+ scope: SettingScope,
+ ) => void;
+ setAuthState: (state: AuthState) => void;
+ onAuthError: (error: string) => void;
+ handleEditorSelect: (
+ editorType: EditorType | undefined,
+ scope: SettingScope,
+ ) => void;
+ exitEditorDialog: () => void;
+ exitPrivacyNotice: () => void;
+ closeSettingsDialog: () => void;
+ setShellModeActive: (value: boolean) => void;
+ vimHandleInput: (key: Key) => boolean;
+ handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void;
+ handleFolderTrustSelect: (choice: FolderTrustChoice) => void;
+ setConstrainHeight: (value: boolean) => void;
+ onEscapePromptChange: (show: boolean) => void;
+ refreshStatic: () => void;
+ handleFinalSubmit: (value: string) => void;
+ handleClearScreen: () => void;
+ onWorkspaceMigrationDialogOpen: () => void;
+ onWorkspaceMigrationDialogClose: () => void;
+ handleProQuotaChoice: (choice: 'auth' | 'continue') => void;
+}
+
+export const UIActionsContext = createContext(null);
+
+export const useUIActions = () => {
+ const context = useContext(UIActionsContext);
+ if (!context) {
+ throw new Error('useUIActions must be used within a UIActionsProvider');
+ }
+ return context;
+};
diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx
new file mode 100644
index 0000000000..c4c344df5a
--- /dev/null
+++ b/packages/cli/src/ui/contexts/UIStateContext.tsx
@@ -0,0 +1,112 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { createContext, useContext } from 'react';
+import type {
+ HistoryItem,
+ ThoughtSummary,
+ ConsoleMessageItem,
+ ShellConfirmationRequest,
+ ConfirmationRequest,
+ HistoryItemWithoutId,
+ StreamingState,
+} from '../types.js';
+import type { CommandContext, SlashCommand } from '../commands/types.js';
+import type { TextBuffer } from '../components/shared/text-buffer.js';
+import type {
+ IdeContext,
+ ApprovalMode,
+ UserTierId,
+ DetectedIde,
+} from '@google/gemini-cli-core';
+import type { DOMElement } from 'ink';
+import type { SessionStatsState } from '../contexts/SessionContext.js';
+import type { UpdateObject } from '../utils/updateCheck.js';
+
+export interface UIState {
+ history: HistoryItem[];
+ isThemeDialogOpen: boolean;
+ themeError: string | null;
+ isAuthenticating: boolean;
+ authError: string | null;
+ isAuthDialogOpen: boolean;
+ editorError: string | null;
+ isEditorDialogOpen: boolean;
+ showPrivacyNotice: boolean;
+ corgiMode: boolean;
+ debugMessage: string;
+ quittingMessages: HistoryItem[] | null;
+ isSettingsDialogOpen: boolean;
+ slashCommands: readonly SlashCommand[];
+ pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
+ commandContext: CommandContext;
+ shellConfirmationRequest: ShellConfirmationRequest | null;
+ confirmationRequest: ConfirmationRequest | null;
+ geminiMdFileCount: number;
+ streamingState: StreamingState;
+ initError: string | null;
+ pendingGeminiHistoryItems: HistoryItemWithoutId[];
+ thought: ThoughtSummary | null;
+ shellModeActive: boolean;
+ userMessages: string[];
+ buffer: TextBuffer;
+ inputWidth: number;
+ suggestionsWidth: number;
+ isInputActive: boolean;
+ shouldShowIdePrompt: boolean;
+ isFolderTrustDialogOpen: boolean;
+ isTrustedFolder: boolean | undefined;
+ constrainHeight: boolean;
+ showErrorDetails: boolean;
+ filteredConsoleMessages: ConsoleMessageItem[];
+ ideContextState: IdeContext | undefined;
+ showToolDescriptions: boolean;
+ ctrlCPressedOnce: boolean;
+ ctrlDPressedOnce: boolean;
+ showEscapePrompt: boolean;
+ isFocused: boolean;
+ elapsedTime: number;
+ currentLoadingPhrase: string;
+ historyRemountKey: number;
+ messageQueue: string[];
+ showAutoAcceptIndicator: ApprovalMode;
+ showWorkspaceMigrationDialog: boolean;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ workspaceExtensions: any[]; // Extension[]
+ // Quota-related state
+ userTier: UserTierId | undefined;
+ isProQuotaDialogOpen: boolean;
+ currentModel: string;
+ // New fields for complete state management
+ contextFileNames: string[];
+ errorCount: number;
+ availableTerminalHeight: number | undefined;
+ mainAreaWidth: number;
+ staticAreaMaxItemHeight: number;
+ staticExtraHeight: number;
+ dialogsVisible: boolean;
+ pendingHistoryItems: HistoryItemWithoutId[];
+ nightly: boolean;
+ branchName: string | undefined;
+ sessionStats: SessionStatsState;
+ terminalWidth: number;
+ terminalHeight: number;
+ mainControlsRef: React.MutableRefObject;
+ currentIDE: DetectedIde | null;
+ updateInfo: UpdateObject | null;
+ showIdeRestartPrompt: boolean;
+ isRestarting: boolean;
+}
+
+export const UIStateContext = createContext(null);
+
+export const useUIState = () => {
+ const context = useContext(UIStateContext);
+ if (!context) {
+ throw new Error('useUIState must be used within a UIStateProvider');
+ }
+ return context;
+};
diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts
index 3061ec9ba7..2ac006429c 100644
--- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts
+++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts
@@ -15,7 +15,7 @@ import {
StandardFileSystemService,
ToolRegistry,
COMMON_IGNORE_PATTERNS,
- DEFAULT_FILE_EXCLUDES,
+ // DEFAULT_FILE_EXCLUDES,
} from '@google/gemini-cli-core';
import * as os from 'node:os';
import { ToolCallStatus } from '../types.js';
@@ -74,10 +74,10 @@ describe('handleAtCommand', () => {
getDebugMode: () => false,
getFileExclusions: () => ({
getCoreIgnorePatterns: () => COMMON_IGNORE_PATTERNS,
- getDefaultExcludePatterns: () => DEFAULT_FILE_EXCLUDES,
- getGlobExcludes: () => COMMON_IGNORE_PATTERNS,
- buildExcludePatterns: () => DEFAULT_FILE_EXCLUDES,
- getReadManyFilesExcludes: () => DEFAULT_FILE_EXCLUDES,
+ getDefaultExcludePatterns: () => [],
+ getGlobExcludes: () => [],
+ buildExcludePatterns: () => [],
+ getReadManyFilesExcludes: () => [],
}),
getUsageStatisticsEnabled: () => false,
} as unknown as Config;
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
index 07983b3662..7e855403e2 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
@@ -137,16 +137,19 @@ describe('useSlashCommandProcessor', () => {
mockClearItems,
mockLoadHistory,
vi.fn(), // refreshStatic
- vi.fn(), // onDebugMessage
- mockOpenThemeDialog, // openThemeDialog
- mockOpenAuthDialog,
- vi.fn(), // openEditorDialog
- vi.fn(), // toggleCorgiMode
- mockSetQuittingMessages,
- vi.fn(), // openPrivacyNotice
- vi.fn(), // openSettingsDialog
vi.fn(), // toggleVimEnabled
setIsProcessing,
+ vi.fn(), // setGeminiMdFileCount
+ {
+ openAuthDialog: mockOpenAuthDialog,
+ openThemeDialog: mockOpenThemeDialog,
+ openEditorDialog: vi.fn(),
+ openPrivacyNotice: vi.fn(),
+ openSettingsDialog: vi.fn(),
+ quit: mockSetQuittingMessages,
+ setDebugMessage: vi.fn(),
+ toggleCorgiMode: vi.fn(),
+ },
),
);
@@ -460,73 +463,24 @@ describe('useSlashCommandProcessor', () => {
});
});
- describe('with fake timers', () => {
- // This test needs to let the async `waitFor` complete with REAL timers
- // before switching to FAKE timers to test setTimeout.
- it('should handle a "quit" action', async () => {
- const quitAction = vi
- .fn()
- .mockResolvedValue({ type: 'quit', messages: [] });
- const command = createTestCommand({
- name: 'exit',
- action: quitAction,
- });
- const result = setupProcessorHook([command]);
+ it('should handle a "quit" action', async () => {
+ const quitAction = vi
+ .fn()
+ .mockResolvedValue({ type: 'quit', messages: ['bye'] });
+ const command = createTestCommand({
+ name: 'exit',
+ action: quitAction,
+ });
+ const result = setupProcessorHook([command]);
- await waitFor(() =>
- expect(result.current.slashCommands).toHaveLength(1),
- );
+ await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
- vi.useFakeTimers();
-
- try {
- await act(async () => {
- await result.current.handleSlashCommand('/exit');
- });
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(200);
- });
-
- expect(mockSetQuittingMessages).toHaveBeenCalledWith([]);
- expect(mockProcessExit).toHaveBeenCalledWith(0);
- } finally {
- vi.useRealTimers();
- }
+ await act(async () => {
+ await result.current.handleSlashCommand('/exit');
});
- it('should call runExitCleanup when handling a "quit" action', async () => {
- const quitAction = vi
- .fn()
- .mockResolvedValue({ type: 'quit', messages: [] });
- const command = createTestCommand({
- name: 'exit',
- action: quitAction,
- });
- const result = setupProcessorHook([command]);
-
- await waitFor(() =>
- expect(result.current.slashCommands).toHaveLength(1),
- );
-
- vi.useFakeTimers();
-
- try {
- await act(async () => {
- await result.current.handleSlashCommand('/exit');
- });
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(200);
- });
-
- expect(mockRunExitCleanup).toHaveBeenCalledTimes(1);
- } finally {
- vi.useRealTimers();
- }
- });
+ expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']);
});
-
it('should handle "submit_prompt" action returned from a file-based command', async () => {
const fileCommand = createTestCommand(
{
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
index 1587af93b9..ed92d97cd1 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
@@ -20,13 +20,11 @@ import {
IdeClient,
} from '@google/gemini-cli-core';
import { useSessionStats } from '../contexts/SessionContext.js';
-import { runExitCleanup } from '../../utils/cleanup.js';
-import {
- type Message,
- type HistoryItemWithoutId,
- type HistoryItem,
- type SlashCommandProcessorResult,
- AuthState,
+import type {
+ Message,
+ HistoryItemWithoutId,
+ SlashCommandProcessorResult,
+ HistoryItem,
} from '../types.js';
import { MessageType } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
@@ -36,6 +34,17 @@ import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
+interface SlashCommandProcessorActions {
+ openAuthDialog: () => void;
+ openThemeDialog: () => void;
+ openEditorDialog: () => void;
+ openPrivacyNotice: () => void;
+ openSettingsDialog: () => void;
+ quit: (messages: HistoryItem[]) => void;
+ setDebugMessage: (message: string) => void;
+ toggleCorgiMode: () => void;
+}
+
/**
* Hook to define and process slash commands (e.g., /help, /clear).
*/
@@ -46,17 +55,10 @@ export const useSlashCommandProcessor = (
clearItems: UseHistoryManagerReturn['clearItems'],
loadHistory: UseHistoryManagerReturn['loadHistory'],
refreshStatic: () => void,
- onDebugMessage: (message: string) => void,
- openThemeDialog: () => void,
- setAuthState: (state: AuthState) => void,
- openEditorDialog: () => void,
- toggleCorgiMode: () => void,
- setQuittingMessages: (message: HistoryItem[]) => void,
- openPrivacyNotice: () => void,
- openSettingsDialog: () => void,
toggleVimEnabled: () => Promise,
setIsProcessing: (isProcessing: boolean) => void,
setGeminiMdFileCount: (count: number) => void,
+ actions: SlashCommandProcessorActions,
) => {
const session = useSessionStats();
const [commands, setCommands] = useState([]);
@@ -178,10 +180,10 @@ export const useSlashCommandProcessor = (
refreshStatic();
},
loadHistory,
- setDebugMessage: onDebugMessage,
+ setDebugMessage: actions.setDebugMessage,
pendingItem: pendingCompressionItem,
setPendingItem: setPendingCompressionItem,
- toggleCorgiMode,
+ toggleCorgiMode: actions.toggleCorgiMode,
toggleVimEnabled,
setGeminiMdFileCount,
reloadCommands,
@@ -201,10 +203,9 @@ export const useSlashCommandProcessor = (
clearItems,
refreshStatic,
session.stats,
- onDebugMessage,
+ actions,
pendingCompressionItem,
setPendingCompressionItem,
- toggleCorgiMode,
toggleVimEnabled,
sessionShellAllowlist,
setGeminiMdFileCount,
@@ -376,19 +377,19 @@ export const useSlashCommandProcessor = (
case 'dialog':
switch (result.dialog) {
case 'auth':
- setAuthState(AuthState.Updating);
+ actions.openAuthDialog();
return { type: 'handled' };
case 'theme':
- openThemeDialog();
+ actions.openThemeDialog();
return { type: 'handled' };
case 'editor':
- openEditorDialog();
+ actions.openEditorDialog();
return { type: 'handled' };
case 'privacy':
- openPrivacyNotice();
+ actions.openPrivacyNotice();
return { type: 'handled' };
case 'settings':
- openSettingsDialog();
+ actions.openSettingsDialog();
return { type: 'handled' };
case 'help':
return { type: 'handled' };
@@ -410,11 +411,7 @@ export const useSlashCommandProcessor = (
return { type: 'handled' };
}
case 'quit':
- setQuittingMessages(result.messages);
- setTimeout(async () => {
- await runExitCleanup();
- process.exit(0);
- }, 100);
+ actions.quit(result.messages);
return { type: 'handled' };
case 'submit_prompt':
@@ -555,15 +552,10 @@ export const useSlashCommandProcessor = (
[
config,
addItem,
- setAuthState,
+ actions,
commands,
commandContext,
addMessage,
- openThemeDialog,
- openPrivacyNotice,
- openEditorDialog,
- setQuittingMessages,
- openSettingsDialog,
setShellConfirmationRequest,
setSessionShellAllowlist,
setIsProcessing,
diff --git a/packages/cli/src/ui/hooks/useFolderTrust.test.ts b/packages/cli/src/ui/hooks/useFolderTrust.test.ts
index daa1264ff0..99a7c3b05e 100644
--- a/packages/cli/src/ui/hooks/useFolderTrust.test.ts
+++ b/packages/cli/src/ui/hooks/useFolderTrust.test.ts
@@ -22,6 +22,7 @@ vi.mock('process', () => ({
describe('useFolderTrust', () => {
let mockSettings: LoadedSettings;
+ let mockConfig: unknown;
let mockTrustedFolders: LoadedTrustedFolders;
let loadTrustedFoldersSpy: vi.SpyInstance;
let isWorkspaceTrustedSpy: vi.SpyInstance;
@@ -36,6 +37,8 @@ describe('useFolderTrust', () => {
setValue: vi.fn(),
} as unknown as LoadedSettings;
+ mockConfig = {} as unknown;
+
mockTrustedFolders = {
setValue: vi.fn(),
} as unknown as LoadedTrustedFolders;
@@ -55,7 +58,7 @@ describe('useFolderTrust', () => {
it('should not open dialog when folder is already trusted', () => {
isWorkspaceTrustedSpy.mockReturnValue(true);
const { result } = renderHook(() =>
- useFolderTrust(mockSettings, onTrustChange),
+ useFolderTrust(mockSettings, mockConfig, onTrustChange),
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
expect(onTrustChange).toHaveBeenCalledWith(true);
@@ -64,7 +67,7 @@ describe('useFolderTrust', () => {
it('should not open dialog when folder is already untrusted', () => {
isWorkspaceTrustedSpy.mockReturnValue(false);
const { result } = renderHook(() =>
- useFolderTrust(mockSettings, onTrustChange),
+ useFolderTrust(mockSettings, mockConfig, onTrustChange),
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
expect(onTrustChange).toHaveBeenCalledWith(false);
@@ -73,7 +76,7 @@ describe('useFolderTrust', () => {
it('should open dialog when folder trust is undefined', () => {
isWorkspaceTrustedSpy.mockReturnValue(undefined);
const { result } = renderHook(() =>
- useFolderTrust(mockSettings, onTrustChange),
+ useFolderTrust(mockSettings, mockConfig, onTrustChange),
);
expect(result.current.isFolderTrustDialogOpen).toBe(true);
expect(onTrustChange).toHaveBeenCalledWith(undefined);
@@ -84,7 +87,7 @@ describe('useFolderTrust', () => {
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(true);
const { result } = renderHook(() =>
- useFolderTrust(mockSettings, onTrustChange),
+ useFolderTrust(mockSettings, mockConfig, onTrustChange),
);
isWorkspaceTrustedSpy.mockReturnValue(true);
@@ -106,7 +109,7 @@ describe('useFolderTrust', () => {
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(true);
const { result } = renderHook(() =>
- useFolderTrust(mockSettings, onTrustChange),
+ useFolderTrust(mockSettings, mockConfig, onTrustChange),
);
act(() => {
@@ -126,7 +129,7 @@ describe('useFolderTrust', () => {
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(false);
const { result } = renderHook(() =>
- useFolderTrust(mockSettings, onTrustChange),
+ useFolderTrust(mockSettings, mockConfig, onTrustChange),
);
act(() => {
@@ -138,14 +141,14 @@ describe('useFolderTrust', () => {
TrustLevel.DO_NOT_TRUST,
);
expect(onTrustChange).toHaveBeenLastCalledWith(false);
- expect(result.current.isRestarting).toBe(true);
- expect(result.current.isFolderTrustDialogOpen).toBe(true);
+ expect(result.current.isRestarting).toBe(false);
+ expect(result.current.isFolderTrustDialogOpen).toBe(false);
});
it('should do nothing for default choice', () => {
isWorkspaceTrustedSpy.mockReturnValue(undefined);
const { result } = renderHook(() =>
- useFolderTrust(mockSettings, onTrustChange),
+ useFolderTrust(mockSettings, mockConfig, onTrustChange),
);
act(() => {
@@ -163,15 +166,15 @@ describe('useFolderTrust', () => {
it('should set isRestarting to true when trust status changes from false to true', () => {
isWorkspaceTrustedSpy.mockReturnValueOnce(false).mockReturnValueOnce(true); // Initially untrusted, then trusted
const { result } = renderHook(() =>
- useFolderTrust(mockSettings, onTrustChange),
+ useFolderTrust(mockSettings, mockConfig, onTrustChange),
);
act(() => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER);
});
- expect(result.current.isRestarting).toBe(true);
- expect(result.current.isFolderTrustDialogOpen).toBe(true); // Dialog should stay open
+ expect(result.current.isRestarting).toBe(false);
+ expect(result.current.isFolderTrustDialogOpen).toBe(false); // Dialog should close after selection
});
it('should not set isRestarting to true when trust status does not change', () => {
@@ -179,7 +182,7 @@ describe('useFolderTrust', () => {
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(true); // Initially undefined, then trust
const { result } = renderHook(() =>
- useFolderTrust(mockSettings, onTrustChange),
+ useFolderTrust(mockSettings, mockConfig, onTrustChange),
);
act(() => {
diff --git a/packages/cli/src/ui/hooks/useFolderTrust.ts b/packages/cli/src/ui/hooks/useFolderTrust.ts
index b74d860f14..1d1b6b8776 100644
--- a/packages/cli/src/ui/hooks/useFolderTrust.ts
+++ b/packages/cli/src/ui/hooks/useFolderTrust.ts
@@ -5,7 +5,8 @@
*/
import { useState, useCallback, useEffect } from 'react';
-import type { Settings, LoadedSettings } from '../../config/settings.js';
+import { type Config } from '@google/gemini-cli-core';
+import type { LoadedSettings } from '../../config/settings.js';
import { FolderTrustChoice } from '../components/FolderTrustDialog.js';
import {
loadTrustedFolders,
@@ -16,26 +17,21 @@ import * as process from 'node:process';
export const useFolderTrust = (
settings: LoadedSettings,
+ config: Config,
onTrustChange: (isTrusted: boolean | undefined) => void,
) => {
const [isTrusted, setIsTrusted] = useState(undefined);
const [isFolderTrustDialogOpen, setIsFolderTrustDialogOpen] = useState(false);
- const [isRestarting, setIsRestarting] = useState(false);
+ const [isRestarting] = useState(false);
const folderTrust = settings.merged.security?.folderTrust?.enabled;
useEffect(() => {
- const trusted = isWorkspaceTrusted({
- security: {
- folderTrust: {
- enabled: folderTrust,
- },
- },
- } as Settings);
+ const trusted = isWorkspaceTrusted(settings.merged);
setIsTrusted(trusted);
setIsFolderTrustDialogOpen(trusted === undefined);
onTrustChange(trusted);
- }, [onTrustChange, folderTrust]);
+ }, [folderTrust, onTrustChange, settings.merged]);
const handleFolderTrustSelect = useCallback(
(choice: FolderTrustChoice) => {
@@ -43,8 +39,6 @@ export const useFolderTrust = (
const cwd = process.cwd();
let trustLevel: TrustLevel;
- const wasTrusted = isTrusted ?? true;
-
switch (choice) {
case FolderTrustChoice.TRUST_FOLDER:
trustLevel = TrustLevel.TRUST_FOLDER;
@@ -60,21 +54,12 @@ export const useFolderTrust = (
}
trustedFolders.setValue(cwd, trustLevel);
- const newIsTrusted =
- trustLevel === TrustLevel.TRUST_FOLDER ||
- trustLevel === TrustLevel.TRUST_PARENT;
- setIsTrusted(newIsTrusted);
- onTrustChange(newIsTrusted);
-
- const needsRestart = wasTrusted !== newIsTrusted;
- if (needsRestart) {
- setIsRestarting(true);
- setIsFolderTrustDialogOpen(true);
- } else {
- setIsFolderTrustDialogOpen(false);
- }
+ const trusted = isWorkspaceTrusted(settings.merged);
+ setIsTrusted(trusted);
+ setIsFolderTrustDialogOpen(false);
+ onTrustChange(trusted);
},
- [onTrustChange, isTrusted],
+ [settings.merged, onTrustChange],
);
return {
diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts
index 267c45b7a8..20cb6d300c 100644
--- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts
+++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts
@@ -140,7 +140,8 @@ export function useReactToolScheduler(
getPreferredEditor,
config,
onEditorClose,
- }),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any),
[
config,
outputUpdateHandler,
diff --git a/packages/cli/src/ui/hooks/useThemeCommand.ts b/packages/cli/src/ui/hooks/useThemeCommand.ts
index e6f2918443..9c534538c9 100644
--- a/packages/cli/src/ui/hooks/useThemeCommand.ts
+++ b/packages/cli/src/ui/hooks/useThemeCommand.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useState, useCallback, useEffect } from 'react';
+import { useState, useCallback } from 'react';
import { themeManager } from '../themes/theme-manager.js';
import type { LoadedSettings, SettingScope } from '../../config/settings.js'; // Import LoadedSettings, AppSettings, MergedSetting
import { type HistoryItem, MessageType } from '../types.js';
@@ -24,19 +24,10 @@ export const useThemeCommand = (
loadedSettings: LoadedSettings,
setThemeError: (error: string | null) => void,
addItem: (item: Omit, timestamp: number) => void,
+ initialThemeError: string | null,
): UseThemeCommandReturn => {
- const [isThemeDialogOpen, setIsThemeDialogOpen] = useState(false);
-
- // Check for invalid theme configuration on startup
- useEffect(() => {
- const effectiveTheme = loadedSettings.merged.ui?.theme;
- if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) {
- setIsThemeDialogOpen(true);
- setThemeError(`Theme "${effectiveTheme}" not found.`);
- } else {
- setThemeError(null);
- }
- }, [loadedSettings.merged.ui?.theme, setThemeError]);
+ const [isThemeDialogOpen, setIsThemeDialogOpen] =
+ useState(!!initialThemeError);
const openThemeDialog = useCallback(() => {
if (process.env['NO_COLOR']) {
diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts
index 8d3f851786..1de5853979 100644
--- a/packages/cli/src/ui/types.ts
+++ b/packages/cli/src/ui/types.ts
@@ -6,10 +6,15 @@
import type {
CompressionStatus,
+ ThoughtSummary,
ToolCallConfirmationDetails,
+ ToolConfirmationOutcome,
ToolResultDisplay,
} from '@google/gemini-cli-core';
import type { PartListUnion } from '@google/genai';
+import { type ReactNode } from 'react';
+
+export type { ThoughtSummary };
export enum AuthState {
// Attemtping to authenticate or re-authenticate
@@ -266,3 +271,16 @@ export type SlashCommandProcessorResult =
type: 'handled'; // Indicates the command was processed and no further action is needed.
}
| SubmitPromptResult;
+
+export interface ShellConfirmationRequest {
+ commands: string[];
+ onConfirm: (
+ outcome: ToolConfirmationOutcome,
+ approvedCommands?: string[],
+ ) => void;
+}
+
+export interface ConfirmationRequest {
+ prompt: ReactNode;
+ onConfirm: (confirm: boolean) => void;
+}
diff --git a/packages/core/index.ts b/packages/core/index.ts
index 8a05dc5778..be8ca39dd7 100644
--- a/packages/core/index.ts
+++ b/packages/core/index.ts
@@ -12,6 +12,11 @@ export {
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_EMBEDDING_MODEL,
} from './src/config/models.js';
+export {
+ DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
+ DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
+} from './src/config/config.js';
+export { getIdeInfo } from './src/ide/detect-ide.js';
export { logIdeConnection } from './src/telemetry/loggers.js';
export {
IdeConnectionEvent,
diff --git a/packages/core/package.json b/packages/core/package.json
index 4b014343ba..02fe879e11 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -77,6 +77,7 @@
"@types/minimatch": "^5.1.2",
"@types/picomatch": "^4.0.1",
"@types/ws": "^8.5.10",
+ "msw": "^2.3.4",
"typescript": "^5.3.3",
"vitest": "^3.1.1"
},