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 && ( -