feat(ui): Introduce useUI Hook and UIContext (#5488)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Keith Lyons 2025-09-06 01:39:02 -04:00 committed by GitHub
parent fe15b04f33
commit 885af07ddb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 3443 additions and 3388 deletions

103
docs/mermaid/context.mmd Normal file
View file

@ -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

View file

@ -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<br>(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

15
package-lock.json generated
View file

@ -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"
},

View file

@ -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<string | null> {
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;
}

View file

@ -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<InitializationResult> {
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(),
};
}

View file

@ -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;
}

View file

@ -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

View file

@ -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 (
<SettingsContext.Provider value={settings}>
<KeypressProvider
kittyProtocolEnabled={kittyProtocolStatus.enabled}
config={config}
debugKeystrokeLogging={settings.merged.general?.debugKeystrokeLogging}
>
<SessionStatsProvider>
<VimModeProvider settings={settings}>
<AppContainer
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
initializationResult={initializationResult}
/>
</VimModeProvider>
</SessionStatsProvider>
</KeypressProvider>
</SettingsContext.Provider>
);
};
const instance = render(
<React.StrictMode>
<SettingsContext.Provider value={settings}>
<AppWrapper
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
/>
</SettingsContext.Provider>
<AppWrapper />
</React.StrictMode>,
{ 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

View file

@ -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);
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,331 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup } from 'ink-testing-library';
import { AppContainer } from './AppContainer.js';
import { type Config, makeFakeConfig } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../config/settings.js';
import type { InitializationResult } from '../core/initializer.js';
// Mock App component to isolate AppContainer testing
vi.mock('./App.js', () => ({
App: () => 'App Component',
}));
// Mock all the hooks and utilities
vi.mock('./hooks/useHistory.js');
vi.mock('./hooks/useThemeCommand.js');
vi.mock('./hooks/useAuthCommand.js');
vi.mock('./hooks/useEditorSettings.js');
vi.mock('./hooks/useSettingsCommand.js');
vi.mock('./hooks/useSlashCommandProcessor.js');
vi.mock('./hooks/useConsoleMessages.js');
vi.mock('./hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(() => ({ columns: 80, rows: 24 })),
}));
vi.mock('./hooks/useGeminiStream.js');
vi.mock('./hooks/useVim.js');
vi.mock('./hooks/useFocus.js');
vi.mock('./hooks/useBracketedPaste.js');
vi.mock('./hooks/useKeypress.js');
vi.mock('./hooks/useLoadingIndicator.js');
vi.mock('./hooks/useFolderTrust.js');
vi.mock('./hooks/useMessageQueue.js');
vi.mock('./hooks/useAutoAcceptIndicator.js');
vi.mock('./hooks/useWorkspaceMigration.js');
vi.mock('./hooks/useGitBranchName.js');
vi.mock('./contexts/VimModeContext.js');
vi.mock('./contexts/SessionContext.js');
vi.mock('./hooks/useTextBuffer.js');
vi.mock('./hooks/useLogger.js');
// Mock external utilities
vi.mock('../utils/events.js');
vi.mock('../utils/handleAutoUpdate.js');
vi.mock('./utils/ConsolePatcher.js');
vi.mock('../utils/cleanup.js');
describe('AppContainer State Management', () => {
let mockConfig: Config;
let mockSettings: LoadedSettings;
let mockInitResult: InitializationResult;
beforeEach(() => {
vi.clearAllMocks();
// Mock Config
mockConfig = makeFakeConfig();
// Mock LoadedSettings
mockSettings = {
merged: {
hideBanner: false,
hideFooter: false,
hideTips: false,
showMemoryUsage: false,
theme: 'default',
},
} as unknown as LoadedSettings;
// Mock InitializationResult
mockInitResult = {
themeError: null,
authError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 0,
} as InitializationResult;
});
afterEach(() => {
cleanup();
});
describe('Basic Rendering', () => {
it('renders without crashing with minimal props', () => {
expect(() => {
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
});
it('renders with startup warnings', () => {
const startupWarnings = ['Warning 1', 'Warning 2'];
expect(() => {
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
startupWarnings={startupWarnings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
});
});
describe('State Initialization', () => {
it('initializes with theme error from initialization result', () => {
const initResultWithError = {
...mockInitResult,
themeError: 'Failed to load theme',
};
expect(() => {
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={initResultWithError}
/>,
);
}).not.toThrow();
});
it('handles debug mode state', () => {
const debugConfig = makeFakeConfig();
vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true);
expect(() => {
render(
<AppContainer
config={debugConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
});
});
describe('Context Providers', () => {
it('provides AppContext with correct values', () => {
const { unmount } = render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="2.0.0"
initializationResult={mockInitResult}
/>,
);
// Should render and unmount cleanly
expect(() => unmount()).not.toThrow();
});
it('provides UIStateContext with state management', () => {
expect(() => {
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
});
it('provides UIActionsContext with action handlers', () => {
expect(() => {
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
});
it('provides ConfigContext with config object', () => {
expect(() => {
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
});
});
describe('Settings Integration', () => {
it('handles settings with all display options disabled', () => {
const settingsAllHidden = {
merged: {
hideBanner: true,
hideFooter: true,
hideTips: true,
showMemoryUsage: false,
},
} as unknown as LoadedSettings;
expect(() => {
render(
<AppContainer
config={mockConfig}
settings={settingsAllHidden}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
});
it('handles settings with memory usage enabled', () => {
const settingsWithMemory = {
merged: {
hideBanner: false,
hideFooter: false,
hideTips: false,
showMemoryUsage: true,
},
} as unknown as LoadedSettings;
expect(() => {
render(
<AppContainer
config={mockConfig}
settings={settingsWithMemory}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
});
});
describe('Version Handling', () => {
it('handles different version formats', () => {
const versions = ['1.0.0', '2.1.3-beta', '3.0.0-nightly'];
versions.forEach((version) => {
expect(() => {
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version={version}
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
});
});
});
describe('Error Handling', () => {
it('handles config methods that might throw', () => {
const errorConfig = makeFakeConfig();
vi.spyOn(errorConfig, 'getModel').mockImplementation(() => {
throw new Error('Config error');
});
// Should still render without crashing - errors should be handled internally
expect(() => {
render(
<AppContainer
config={errorConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
});
it('handles undefined settings gracefully', () => {
const undefinedSettings = {
merged: {},
} as LoadedSettings;
expect(() => {
render(
<AppContainer
config={mockConfig}
settings={undefinedSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
});
});
describe('Provider Hierarchy', () => {
it('establishes correct provider nesting order', () => {
// This tests that all the context providers are properly nested
// and that the component tree can be built without circular dependencies
const { unmount } = render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
expect(() => unmount()).not.toThrow();
});
});
});
// TODO: Add comprehensive integration test once all hook mocks are complete
// For now, the 14 passing unit tests provide good coverage of AppContainer functionality

File diff suppressed because it is too large Load diff

View file

@ -20,9 +20,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
IdeClient: {
getInstance: vi.fn(),
},
ideContext: {
getIdeContext: vi.fn(),
},
};
});

View file

@ -0,0 +1,32 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box } from 'ink';
import { Header } from './Header.js';
import { Tips } from './Tips.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
interface AppHeaderProps {
version: string;
}
export const AppHeader = ({ version }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
const { nightly } = useUIState();
return (
<Box flexDirection="column">
{!(settings.merged.ui?.hideBanner || config.getScreenReader()) && (
<Header version={version} nightly={nightly} />
)}
{!(settings.merged.ui?.hideTips || config.getScreenReader()) && (
<Tips config={config} />
)}
</Box>
);
};

View file

@ -0,0 +1,433 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { render } from 'ink-testing-library';
import { Text } from 'ink';
import { Composer } from './Composer.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import {
UIActionsContext,
type UIActions,
} from '../contexts/UIActionsContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
// Mock VimModeContext hook
vi.mock('../contexts/VimModeContext.js', () => ({
useVimMode: vi.fn(() => ({
vimEnabled: false,
vimMode: 'NORMAL',
})),
}));
import { ApprovalMode } from '@google/gemini-cli-core';
import { StreamingState } from '../types.js';
// Mock child components
vi.mock('./LoadingIndicator.js', () => ({
LoadingIndicator: ({ thought }: { thought?: string }) => (
<Text>LoadingIndicator{thought ? `: ${thought}` : ''}</Text>
),
}));
vi.mock('./ContextSummaryDisplay.js', () => ({
ContextSummaryDisplay: () => <Text>ContextSummaryDisplay</Text>,
}));
vi.mock('./AutoAcceptIndicator.js', () => ({
AutoAcceptIndicator: () => <Text>AutoAcceptIndicator</Text>,
}));
vi.mock('./ShellModeIndicator.js', () => ({
ShellModeIndicator: () => <Text>ShellModeIndicator</Text>,
}));
vi.mock('./DetailedMessagesDisplay.js', () => ({
DetailedMessagesDisplay: () => <Text>DetailedMessagesDisplay</Text>,
}));
vi.mock('./InputPrompt.js', () => ({
InputPrompt: () => <Text>InputPrompt</Text>,
}));
vi.mock('./Footer.js', () => ({
Footer: () => <Text>Footer</Text>,
}));
vi.mock('./ShowMoreLines.js', () => ({
ShowMoreLines: () => <Text>ShowMoreLines</Text>,
}));
// Mock contexts
vi.mock('../contexts/OverflowContext.js', () => ({
OverflowProvider: ({ children }: { children: React.ReactNode }) => children,
}));
// Create mock context providers
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
({
streamingState: null,
contextFileNames: [],
showAutoAcceptIndicator: ApprovalMode.DEFAULT,
messageQueue: [],
showErrorDetails: false,
constrainHeight: false,
isInputActive: true,
buffer: '',
inputWidth: 80,
suggestionsWidth: 40,
userMessages: [],
slashCommands: [],
commandContext: null,
shellModeActive: false,
isFocused: true,
thought: '',
currentLoadingPhrase: '',
elapsedTime: 0,
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
showEscapePrompt: false,
ideContextState: null,
geminiMdFileCount: 0,
showToolDescriptions: false,
filteredConsoleMessages: [],
sessionStats: {
lastPromptTokenCount: 0,
sessionTokenCount: 0,
totalPrompts: 0,
},
branchName: 'main',
debugMessage: '',
corgiMode: false,
errorCount: 0,
nightly: false,
isTrustedFolder: true,
...overrides,
}) as UIState;
const createMockUIActions = (): UIActions =>
({
handleFinalSubmit: vi.fn(),
handleClearScreen: vi.fn(),
setShellModeActive: vi.fn(),
onEscapePromptChange: vi.fn(),
vimHandleInput: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any;
const createMockConfig = (overrides = {}) => ({
getModel: vi.fn(() => 'gemini-1.5-pro'),
getTargetDir: vi.fn(() => '/test/dir'),
getDebugMode: vi.fn(() => false),
getAccessibility: vi.fn(() => ({})),
getMcpServers: vi.fn(() => ({})),
getBlockedMcpServers: vi.fn(() => []),
...overrides,
});
const createMockSettings = (merged = {}) => ({
merged: {
hideFooter: false,
showMemoryUsage: false,
...merged,
},
});
/* eslint-disable @typescript-eslint/no-explicit-any */
const renderComposer = (
uiState: UIState,
settings = createMockSettings(),
config = createMockConfig(),
uiActions = createMockUIActions(),
) =>
render(
<ConfigContext.Provider value={config as any}>
<SettingsContext.Provider value={settings as any}>
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<Composer />
</UIActionsContext.Provider>
</UIStateContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>,
);
/* eslint-enable @typescript-eslint/no-explicit-any */
describe('Composer', () => {
describe('Footer Display Settings', () => {
it('renders Footer by default when hideFooter is false', () => {
const uiState = createMockUIState();
const settings = createMockSettings({ hideFooter: false });
const { lastFrame } = renderComposer(uiState, settings);
expect(lastFrame()).toContain('Footer');
});
it('does NOT render Footer when hideFooter is true', () => {
const uiState = createMockUIState();
const settings = createMockSettings({ hideFooter: true });
const { lastFrame } = renderComposer(uiState, settings);
// Check for content that only appears IN the Footer component itself
expect(lastFrame()).not.toContain('[NORMAL]'); // Vim mode indicator
expect(lastFrame()).not.toContain('(main'); // Branch name with parentheses
});
it('passes correct props to Footer including vim mode when enabled', async () => {
const uiState = createMockUIState({
branchName: 'feature-branch',
corgiMode: true,
errorCount: 2,
sessionStats: {
sessionId: 'test-session',
sessionStartTime: new Date(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metrics: {} as any,
lastPromptTokenCount: 150,
promptCount: 5,
},
});
const config = createMockConfig({
getModel: vi.fn(() => 'gemini-1.5-flash'),
getTargetDir: vi.fn(() => '/project/path'),
getDebugMode: vi.fn(() => true),
});
const settings = createMockSettings({
hideFooter: false,
showMemoryUsage: true,
});
// Mock vim mode for this test
const { useVimMode } = await import('../contexts/VimModeContext.js');
vi.mocked(useVimMode).mockReturnValueOnce({
vimEnabled: true,
vimMode: 'INSERT',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const { lastFrame } = renderComposer(uiState, settings, config);
expect(lastFrame()).toContain('Footer');
// Footer should be rendered with all the state passed through
});
});
describe('Loading Indicator', () => {
it('renders LoadingIndicator with thought when streaming', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: {
subject: 'Processing',
description: 'Processing your request...',
},
currentLoadingPhrase: 'Analyzing',
elapsedTime: 1500,
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
});
it('renders LoadingIndicator without thought when accessibility disables loading phrases', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: { subject: 'Hidden', description: 'Should not show' },
});
const config = createMockConfig({
getAccessibility: vi.fn(() => ({ disableLoadingPhrases: true })),
});
const { lastFrame } = renderComposer(uiState, undefined, config);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('Should not show');
});
it('suppresses thought when waiting for confirmation', () => {
const uiState = createMockUIState({
streamingState: StreamingState.WaitingForConfirmation,
thought: {
subject: 'Confirmation',
description: 'Should not show during confirmation',
},
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('Should not show during confirmation');
});
});
describe('Message Queue Display', () => {
it('displays queued messages when present', () => {
const uiState = createMockUIState({
messageQueue: [
'First queued message',
'Second queued message',
'Third queued message',
],
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('First queued message');
expect(output).toContain('Second queued message');
expect(output).toContain('Third queued message');
});
it('shows overflow indicator when more than 3 messages are queued', () => {
const uiState = createMockUIState({
messageQueue: [
'Message 1',
'Message 2',
'Message 3',
'Message 4',
'Message 5',
],
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('Message 1');
expect(output).toContain('Message 2');
expect(output).toContain('Message 3');
expect(output).toContain('... (+2 more)');
});
it('does not display message queue section when empty', () => {
const uiState = createMockUIState({
messageQueue: [],
});
const { lastFrame } = renderComposer(uiState);
// Should not contain queued message indicators
const output = lastFrame();
expect(output).not.toContain('more)');
});
});
describe('Context and Status Display', () => {
it('shows ContextSummaryDisplay in normal state', () => {
const uiState = createMockUIState({
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
showEscapePrompt: false,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('ContextSummaryDisplay');
});
it('shows Ctrl+C exit prompt when ctrlCPressedOnce is true', () => {
const uiState = createMockUIState({
ctrlCPressedOnce: true,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('Press Ctrl+C again to exit');
});
it('shows Ctrl+D exit prompt when ctrlDPressedOnce is true', () => {
const uiState = createMockUIState({
ctrlDPressedOnce: true,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('Press Ctrl+D again to exit');
});
it('shows escape prompt when showEscapePrompt is true', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('Press Esc again to clear');
});
});
describe('Input and Indicators', () => {
it('renders InputPrompt when input is active', () => {
const uiState = createMockUIState({
isInputActive: true,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('InputPrompt');
});
it('does not render InputPrompt when input is inactive', () => {
const uiState = createMockUIState({
isInputActive: false,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).not.toContain('InputPrompt');
});
it('shows AutoAcceptIndicator when approval mode is not default and shell mode is inactive', () => {
const uiState = createMockUIState({
showAutoAcceptIndicator: ApprovalMode.YOLO,
shellModeActive: false,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('AutoAcceptIndicator');
});
it('shows ShellModeIndicator when shell mode is active', () => {
const uiState = createMockUIState({
shellModeActive: true,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('ShellModeIndicator');
});
});
describe('Error Details Display', () => {
it('shows DetailedMessagesDisplay when showErrorDetails is true', () => {
const uiState = createMockUIState({
showErrorDetails: true,
filteredConsoleMessages: [
{ level: 'error', message: 'Test error', timestamp: new Date() },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('DetailedMessagesDisplay');
expect(lastFrame()).toContain('ShowMoreLines');
});
it('does not show error details when showErrorDetails is false', () => {
const uiState = createMockUIState({
showErrorDetails: false,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).not.toContain('DetailedMessagesDisplay');
});
});
});

View file

@ -0,0 +1,186 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { LoadingIndicator } from './LoadingIndicator.js';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { InputPrompt } from './InputPrompt.js';
import { Footer, type FooterProps } from './Footer.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { Colors } from '../colors.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { ApprovalMode } from '@google/gemini-cli-core';
import { StreamingState } from '../types.js';
const MAX_DISPLAYED_QUEUED_MESSAGES = 3;
export const Composer = () => {
const config = useConfig();
const settings = useSettings();
const uiState = useUIState();
const uiActions = useUIActions();
const { vimEnabled, vimMode } = useVimMode();
const terminalWidth = process.stdout.columns;
const isNarrow = isNarrowWidth(terminalWidth);
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
const { contextFileNames, showAutoAcceptIndicator } = uiState;
// Build footer props from context values
const footerProps: Omit<FooterProps, 'vimMode'> = {
model: config.getModel(),
targetDir: config.getTargetDir(),
debugMode: config.getDebugMode(),
branchName: uiState.branchName,
debugMessage: uiState.debugMessage,
corgiMode: uiState.corgiMode,
errorCount: uiState.errorCount,
showErrorDetails: uiState.showErrorDetails,
showMemoryUsage:
config.getDebugMode() || settings.merged.ui?.showMemoryUsage || false,
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
nightly: uiState.nightly,
isTrustedFolder: uiState.isTrustedFolder,
};
return (
<Box flexDirection="column">
<LoadingIndicator
thought={
uiState.streamingState === StreamingState.WaitingForConfirmation ||
config.getAccessibility()?.disableLoadingPhrases
? undefined
: uiState.thought
}
currentLoadingPhrase={
config.getAccessibility()?.disableLoadingPhrases
? undefined
: uiState.currentLoadingPhrase
}
elapsedTime={uiState.elapsedTime}
/>
{uiState.messageQueue.length > 0 && (
<Box flexDirection="column" marginTop={1}>
{uiState.messageQueue
.slice(0, MAX_DISPLAYED_QUEUED_MESSAGES)
.map((message, index) => {
const preview = message.replace(/\s+/g, ' ');
return (
<Box key={index} paddingLeft={2} width="100%">
<Text dimColor wrap="truncate">
{preview}
</Text>
</Box>
);
})}
{uiState.messageQueue.length > MAX_DISPLAYED_QUEUED_MESSAGES && (
<Box paddingLeft={2}>
<Text dimColor>
... (+
{uiState.messageQueue.length -
MAX_DISPLAYED_QUEUED_MESSAGES}{' '}
more)
</Text>
</Box>
)}
</Box>
)}
<Box
marginTop={1}
justifyContent="space-between"
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box>
{process.env['GEMINI_SYSTEM_MD'] && (
<Text color={Colors.AccentRed}>|_| </Text>
)}
{uiState.ctrlCPressedOnce ? (
<Text color={Colors.AccentYellow}>Press Ctrl+C again to exit.</Text>
) : uiState.ctrlDPressedOnce ? (
<Text color={Colors.AccentYellow}>Press Ctrl+D again to exit.</Text>
) : uiState.showEscapePrompt ? (
<Text color={Colors.Gray}>Press Esc again to clear.</Text>
) : (
!settings.merged.ui?.hideContextSummary && (
<ContextSummaryDisplay
ideContext={uiState.ideContextState}
geminiMdFileCount={uiState.geminiMdFileCount}
contextFileNames={contextFileNames}
mcpServers={config.getMcpServers()}
blockedMcpServers={config.getBlockedMcpServers()}
showToolDescriptions={uiState.showToolDescriptions}
/>
)
)}
</Box>
<Box paddingTop={isNarrow ? 1 : 0}>
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
!uiState.shellModeActive && (
<AutoAcceptIndicator approvalMode={showAutoAcceptIndicator} />
)}
{uiState.shellModeActive && <ShellModeIndicator />}
</Box>
</Box>
{uiState.showErrorDetails && (
<OverflowProvider>
<Box flexDirection="column">
<DetailedMessagesDisplay
messages={uiState.filteredConsoleMessages}
maxHeight={
uiState.constrainHeight ? debugConsoleMaxHeight : undefined
}
width={uiState.inputWidth}
/>
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</Box>
</OverflowProvider>
)}
{uiState.isInputActive && (
<InputPrompt
buffer={uiState.buffer}
inputWidth={uiState.inputWidth}
suggestionsWidth={uiState.suggestionsWidth}
onSubmit={uiActions.handleFinalSubmit}
userMessages={uiState.userMessages}
onClearScreen={uiActions.handleClearScreen}
config={config}
slashCommands={uiState.slashCommands}
commandContext={uiState.commandContext}
shellModeActive={uiState.shellModeActive}
setShellModeActive={uiActions.setShellModeActive}
onEscapePromptChange={uiActions.onEscapePromptChange}
focus={uiState.isFocused}
vimHandleInput={uiActions.vimHandleInput}
placeholder={
vimEnabled
? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode."
: ' Type your message or @path/to/file'
}
/>
)}
{!settings.merged.ui?.hideFooter && (
<Footer {...footerProps} vimMode={vimEnabled ? vimMode : undefined} />
)}
</Box>
);
};

View file

@ -0,0 +1,184 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { IdeIntegrationNudge } from '../IdeIntegrationNudge.js';
import { FolderTrustDialog } from './FolderTrustDialog.js';
import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js';
import { AuthInProgress } from '../auth/AuthInProgress.js';
import { AuthDialog } from '../auth/AuthDialog.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
import { ProQuotaDialog } from './ProQuotaDialog.js';
import { Colors } from '../colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '@google/gemini-cli-core';
import process from 'node:process';
// Props for DialogManager
export const DialogManager = () => {
const config = useConfig();
const settings = useSettings();
const uiState = useUIState();
const uiActions = useUIActions();
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
uiState;
if (uiState.showIdeRestartPrompt) {
return (
<Box borderStyle="round" borderColor={Colors.AccentYellow} paddingX={1}>
<Text color={Colors.AccentYellow}>
Workspace trust has changed. Press &apos;r&apos; to restart Gemini to
apply the changes.
</Text>
</Box>
);
}
if (uiState.showWorkspaceMigrationDialog) {
return (
<WorkspaceMigrationDialog
workspaceExtensions={uiState.workspaceExtensions}
onOpen={uiActions.onWorkspaceMigrationDialogOpen}
onClose={uiActions.onWorkspaceMigrationDialogClose}
/>
);
}
if (uiState.isProQuotaDialogOpen) {
return (
<ProQuotaDialog
currentModel={uiState.currentModel}
fallbackModel={DEFAULT_GEMINI_FLASH_MODEL}
onChoice={uiActions.handleProQuotaChoice}
/>
);
}
if (uiState.shouldShowIdePrompt) {
return (
<IdeIntegrationNudge
ide={uiState.currentIDE!}
onComplete={uiActions.handleIdePromptComplete}
/>
);
}
if (uiState.isFolderTrustDialogOpen) {
return (
<FolderTrustDialog
onSelect={uiActions.handleFolderTrustSelect}
isRestarting={uiState.isRestarting}
/>
);
}
if (uiState.shellConfirmationRequest) {
return (
<ShellConfirmationDialog request={uiState.shellConfirmationRequest} />
);
}
if (uiState.confirmationRequest) {
return (
<Box flexDirection="column">
{uiState.confirmationRequest.prompt}
<Box paddingY={1}>
<RadioButtonSelect
items={[
{ label: 'Yes', value: true },
{ label: 'No', value: false },
]}
onSelect={(value: boolean) => {
uiState.confirmationRequest!.onConfirm(value);
}}
/>
</Box>
</Box>
);
}
if (uiState.isThemeDialogOpen) {
return (
<Box flexDirection="column">
{uiState.themeError && (
<Box marginBottom={1}>
<Text color={Colors.AccentRed}>{uiState.themeError}</Text>
</Box>
)}
<ThemeDialog
onSelect={uiActions.handleThemeSelect}
onHighlight={uiActions.handleThemeHighlight}
settings={settings}
availableTerminalHeight={
constrainHeight ? terminalHeight - staticExtraHeight : undefined
}
terminalWidth={mainAreaWidth}
/>
</Box>
);
}
if (uiState.isSettingsDialogOpen) {
return (
<Box flexDirection="column">
<SettingsDialog
settings={settings}
onSelect={() => uiActions.closeSettingsDialog()}
onRestartRequest={() => process.exit(0)}
/>
</Box>
);
}
if (uiState.isAuthenticating) {
return (
<AuthInProgress
onTimeout={() => {
/* This is now handled in AppContainer */
}}
/>
);
}
if (uiState.isAuthDialogOpen) {
return (
<Box flexDirection="column">
<AuthDialog
config={config}
settings={settings}
setAuthState={uiActions.setAuthState}
authError={uiState.authError}
onAuthError={uiActions.onAuthError}
/>
</Box>
);
}
if (uiState.isEditorDialogOpen) {
return (
<Box flexDirection="column">
{uiState.editorError && (
<Box marginBottom={1}>
<Text color={Colors.AccentRed}>{uiState.editorError}</Text>
</Box>
)}
<EditorSettingsDialog
onSelect={uiActions.handleEditorSelect}
settings={settings}
onExit={uiActions.exitEditorDialog}
/>
</Box>
);
}
if (uiState.showPrivacyNotice) {
return (
<PrivacyNotice
onExit={() => uiActions.exitPrivacyNotice()}
config={config}
/>
);
}
return null;
};

View file

@ -156,31 +156,4 @@ describe('<Footer />', () => {
vi.unstubAllEnvs();
});
});
describe('visibility toggles', () => {
it('should hide CWD when hideCWD is true', () => {
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
hideCWD: true,
});
expect(lastFrame()).not.toContain(defaultProps.targetDir);
});
it('should hide sandbox status when hideSandboxStatus is true', () => {
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
isTrustedFolder: true,
hideSandboxStatus: true,
});
expect(lastFrame()).not.toContain('no sandbox');
});
it('should hide model info when hideModelInfo is true', () => {
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
hideModelInfo: true,
});
expect(lastFrame()).not.toContain(defaultProps.model);
});
});
});

View file

@ -19,7 +19,7 @@ import { DebugProfiler } from './DebugProfiler.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
interface FooterProps {
export interface FooterProps {
model: string;
targetDir: string;
branchName?: string;
@ -33,9 +33,6 @@ interface FooterProps {
nightly: boolean;
vimMode?: string;
isTrustedFolder?: boolean;
hideCWD?: boolean;
hideSandboxStatus?: boolean;
hideModelInfo?: boolean;
}
export const Footer: React.FC<FooterProps> = ({
@ -52,9 +49,6 @@ export const Footer: React.FC<FooterProps> = ({
nightly,
vimMode,
isTrustedFolder,
hideCWD = false,
hideSandboxStatus = false,
hideModelInfo = false,
}) => {
const { columns: terminalWidth } = useTerminalSize();
@ -66,93 +60,85 @@ export const Footer: React.FC<FooterProps> = ({
? path.basename(tildeifyPath(targetDir))
: shortenPath(tildeifyPath(targetDir), pathLength);
const justifyContent = hideCWD && hideModelInfo ? 'center' : 'space-between';
return (
<Box
justifyContent={justifyContent}
justifyContent="space-between"
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
{!hideCWD && (
<Box>
{debugMode && <DebugProfiler />}
{vimMode && <Text color={theme.text.secondary}>[{vimMode}] </Text>}
{nightly ? (
<Gradient colors={theme.ui.gradient}>
<Text>
{displayPath}
{branchName && <Text> ({branchName}*)</Text>}
</Text>
</Gradient>
) : (
<Text color={theme.text.link}>
<Box>
{debugMode && <DebugProfiler />}
{vimMode && <Text color={theme.text.secondary}>[{vimMode}] </Text>}
{nightly ? (
<Gradient colors={theme.ui.gradient}>
<Text>
{displayPath}
{branchName && (
<Text color={theme.text.secondary}> ({branchName}*)</Text>
)}
{branchName && <Text> ({branchName}*)</Text>}
</Text>
)}
{debugMode && (
<Text color={theme.status.error}>
{' ' + (debugMessage || '--debug')}
</Text>
)}
</Box>
)}
</Gradient>
) : (
<Text color={theme.text.link}>
{displayPath}
{branchName && (
<Text color={theme.text.secondary}> ({branchName}*)</Text>
)}
</Text>
)}
{debugMode && (
<Text color={theme.status.error}>
{' ' + (debugMessage || '--debug')}
</Text>
)}
</Box>
{/* Middle Section: Centered Trust/Sandbox Info */}
{!hideSandboxStatus && (
<Box
flexGrow={isNarrow || hideCWD || hideModelInfo ? 0 : 1}
alignItems="center"
justifyContent={isNarrow || hideCWD ? 'flex-start' : 'center'}
display="flex"
paddingX={isNarrow ? 0 : 1}
paddingTop={isNarrow ? 1 : 0}
>
{isTrustedFolder === false ? (
<Text color={theme.status.warning}>untrusted</Text>
) : process.env['SANDBOX'] &&
process.env['SANDBOX'] !== 'sandbox-exec' ? (
<Text color="green">
{process.env['SANDBOX'].replace(/^gemini-(?:cli-)?/, '')}
<Box
flexGrow={isNarrow ? 0 : 1}
alignItems="center"
justifyContent={isNarrow ? 'flex-start' : 'center'}
display="flex"
paddingX={isNarrow ? 0 : 1}
paddingTop={isNarrow ? 1 : 0}
>
{isTrustedFolder === false ? (
<Text color={theme.status.warning}>untrusted</Text>
) : process.env['SANDBOX'] &&
process.env['SANDBOX'] !== 'sandbox-exec' ? (
<Text color="green">
{process.env['SANDBOX'].replace(/^gemini-(?:cli-)?/, '')}
</Text>
) : process.env['SANDBOX'] === 'sandbox-exec' ? (
<Text color={theme.status.warning}>
macOS Seatbelt{' '}
<Text color={theme.text.secondary}>
({process.env['SEATBELT_PROFILE']})
</Text>
) : process.env['SANDBOX'] === 'sandbox-exec' ? (
<Text color={theme.status.warning}>
macOS Seatbelt{' '}
<Text color={theme.text.secondary}>
({process.env['SEATBELT_PROFILE']})
</Text>
</Text>
) : (
<Text color={theme.status.error}>
no sandbox <Text color={theme.text.secondary}>(see /docs)</Text>
</Text>
)}
</Box>
)}
</Text>
) : (
<Text color={theme.status.error}>
no sandbox <Text color={theme.text.secondary}>(see /docs)</Text>
</Text>
)}
</Box>
{/* Right Section: Gemini Label and Console Summary */}
<Box alignItems="center" paddingTop={isNarrow ? 1 : 0}>
{!hideModelInfo && (
<Box alignItems="center">
<Text color={theme.text.accent}>
{isNarrow ? '' : ' '}
{model}{' '}
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
model={model}
/>
</Text>
{showMemoryUsage && <MemoryUsageDisplay />}
</Box>
)}
<Box alignItems="center">
<Text color={theme.text.accent}>
{isNarrow ? '' : ' '}
{model}{' '}
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
model={model}
/>
</Text>
{showMemoryUsage && <MemoryUsageDisplay />}
</Box>
<Box alignItems="center" paddingLeft={2}>
{corgiMode && (
<Text>
{!hideModelInfo && <Text color={theme.ui.symbol}>| </Text>}
<Text color={theme.ui.symbol}>| </Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>(´</Text>
<Text color={theme.status.error}></Text>
@ -162,7 +148,7 @@ export const Footer: React.FC<FooterProps> = ({
)}
{!showErrorDetails && errorCount > 0 && (
<Box>
{!hideModelInfo && <Text color={theme.ui.symbol}>| </Text>}
<Text color={theme.ui.symbol}>| </Text>
<ConsoleSummaryDisplay errorCount={errorCount} />
</Box>
)}

View file

@ -20,7 +20,6 @@ import { StatsDisplay } from './StatsDisplay.js';
import { ModelStatsDisplay } from './ModelStatsDisplay.js';
import { ToolStatsDisplay } from './ToolStatsDisplay.js';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import type { Config } from '@google/gemini-cli-core';
import { Help } from './Help.js';
import type { SlashCommand } from '../commands/types.js';
@ -29,7 +28,6 @@ interface HistoryItemDisplayProps {
availableTerminalHeight?: number;
terminalWidth: number;
isPending: boolean;
config: Config;
isFocused?: boolean;
commands?: readonly SlashCommand[];
}
@ -39,7 +37,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
availableTerminalHeight,
terminalWidth,
isPending,
config,
commands,
isFocused = true,
}) => (
@ -87,7 +84,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
groupId={item.id}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
config={config}
isFocused={isFocused}
/>
)}

View file

@ -0,0 +1,64 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Static } from 'ink';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js';
export const MainContent = () => {
const { version } = useAppContext();
const uiState = useUIState();
const {
pendingHistoryItems,
mainAreaWidth,
staticAreaMaxItemHeight,
availableTerminalHeight,
} = uiState;
return (
<>
<Static
key={uiState.historyRemountKey}
items={[
<AppHeader key="app-header" version={version} />,
...uiState.history.map((h) => (
<HistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={staticAreaMaxItemHeight}
key={h.id}
item={h}
isPending={false}
commands={uiState.slashCommands}
/>
)),
]}
>
{(item) => item}
</Static>
<OverflowProvider>
<Box flexDirection="column">
{pendingHistoryItems.map((item, i) => (
<HistoryItemDisplay
key={i}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isFocused={!uiState.isEditorDialogOpen}
/>
))}
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</Box>
</OverflowProvider>
</>
);
};

View file

@ -0,0 +1,62 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useAppContext } from '../contexts/AppContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { Colors } from '../colors.js';
import { StreamingState } from '../types.js';
import { UpdateNotification } from './UpdateNotification.js';
export const Notifications = () => {
const { startupWarnings } = useAppContext();
const { initError, streamingState, updateInfo } = useUIState();
const showStartupWarnings = startupWarnings.length > 0;
const showInitError =
initError && streamingState !== StreamingState.Responding;
if (!showStartupWarnings && !showInitError && !updateInfo) {
return null;
}
return (
<>
{updateInfo && <UpdateNotification message={updateInfo.message} />}
{showStartupWarnings && (
<Box
borderStyle="round"
borderColor={Colors.AccentYellow}
paddingX={1}
marginY={1}
flexDirection="column"
>
{startupWarnings.map((warning, index) => (
<Text key={index} color={Colors.AccentYellow}>
{warning}
</Text>
))}
</Box>
)}
{showInitError && (
<Box
borderStyle="round"
borderColor={Colors.AccentRed}
paddingX={1}
marginBottom={1}
>
<Text color={Colors.AccentRed}>
Initialization Error: {initError}
</Text>
<Text color={Colors.AccentRed}>
{' '}
Please check API key and configuration.
</Text>
</Box>
)}
</>
);
};

View file

@ -0,0 +1,37 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
export const QuittingDisplay = () => {
const uiState = useUIState();
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
const availableTerminalHeight = terminalHeight;
if (!uiState.quittingMessages) {
return null;
}
return (
<Box flexDirection="column" marginBottom={1}>
{uiState.quittingMessages.map((item) => (
<HistoryItemDisplay
key={item.id}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={terminalWidth}
item={item}
isPending={false}
/>
))}
</Box>
);
};

View file

@ -7,13 +7,16 @@
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { Text } from 'ink';
import type React from 'react';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import { type IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import type {
Config,
ToolCallConfirmationDetails,
} from '@google/gemini-cli-core';
import { TOOL_STATUS } from '../../constants.js';
import { ConfigContext } from '../../contexts/ConfigContext.js';
// Mock child components to isolate ToolGroupMessage behavior
vi.mock('./ToolMessage.js', () => ({
@ -81,14 +84,21 @@ describe('<ToolGroupMessage />', () => {
const baseProps = {
groupId: 1,
terminalWidth: 80,
config: mockConfig,
isFocused: true,
};
// Helper to wrap component with required providers
const renderWithProviders = (component: React.ReactElement) =>
render(
<ConfigContext.Provider value={mockConfig}>
{component}
</ConfigContext.Provider>,
);
describe('Golden Snapshots', () => {
it('renders single successful tool call', () => {
const toolCalls = [createToolCall()];
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
@ -115,7 +125,7 @@ describe('<ToolGroupMessage />', () => {
status: ToolCallStatus.Error,
}),
];
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
@ -136,7 +146,7 @@ describe('<ToolGroupMessage />', () => {
},
}),
];
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
@ -151,7 +161,7 @@ describe('<ToolGroupMessage />', () => {
status: ToolCallStatus.Success,
}),
];
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
@ -178,7 +188,7 @@ describe('<ToolGroupMessage />', () => {
status: ToolCallStatus.Pending,
}),
];
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
@ -200,7 +210,7 @@ describe('<ToolGroupMessage />', () => {
resultDisplay: 'More output here',
}),
];
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
@ -212,7 +222,7 @@ describe('<ToolGroupMessage />', () => {
it('renders when not focused', () => {
const toolCalls = [createToolCall()];
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
@ -230,7 +240,7 @@ describe('<ToolGroupMessage />', () => {
'This is a very long description that might cause wrapping issues',
}),
];
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
@ -241,7 +251,7 @@ describe('<ToolGroupMessage />', () => {
});
it('renders empty tool calls array', () => {
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={[]} />,
);
expect(lastFrame()).toMatchSnapshot();
@ -251,7 +261,7 @@ describe('<ToolGroupMessage />', () => {
describe('Border Color Logic', () => {
it('uses yellow border when tools are pending', () => {
const toolCalls = [createToolCall({ status: ToolCallStatus.Pending })];
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
// The snapshot will capture the visual appearance including border color
@ -265,7 +275,7 @@ describe('<ToolGroupMessage />', () => {
status: ToolCallStatus.Success,
}),
];
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
@ -280,7 +290,7 @@ describe('<ToolGroupMessage />', () => {
status: ToolCallStatus.Success,
}),
];
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
@ -303,7 +313,7 @@ describe('<ToolGroupMessage />', () => {
resultDisplay: '', // No result
}),
];
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
@ -340,7 +350,7 @@ describe('<ToolGroupMessage />', () => {
},
}),
];
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
// Should only show confirmation for the first tool

View file

@ -11,16 +11,15 @@ import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import { Colors } from '../../colors.js';
import type { Config } from '@google/gemini-cli-core';
import { theme } from '../../semantic-colors.js';
import { SHELL_COMMAND_NAME } from '../../constants.js';
import { useConfig } from '../../contexts/ConfigContext.js';
interface ToolGroupMessageProps {
groupId: number;
toolCalls: IndividualToolCallDisplay[];
availableTerminalHeight?: number;
terminalWidth: number;
config: Config;
isFocused?: boolean;
}
@ -29,15 +28,15 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
toolCalls,
availableTerminalHeight,
terminalWidth,
config,
isFocused = true,
}) => {
const config = useConfig();
const hasPending = !toolCalls.every(
(t) => t.status === ToolCallStatus.Success,
);
const isShellCommand = toolCalls.some((t) => t.name === SHELL_COMMAND_NAME);
const borderColor =
hasPending || isShellCommand ? Colors.AccentYellow : Colors.Gray;
hasPending || isShellCommand ? theme.status.warning : theme.border.default;
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
// This is a bit of a magic number, but it accounts for the border and

View file

@ -0,0 +1,22 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createContext, useContext } from 'react';
export interface AppState {
version: string;
startupWarnings: string[];
}
export const AppContext = createContext<AppState | null>(null);
export const useAppContext = () => {
const context = useContext(AppContext);
if (!context) {
throw new Error('useAppContext must be used within an AppProvider');
}
return context;
};

View file

@ -0,0 +1,18 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useContext } from 'react';
import { type Config } from '@google/gemini-cli-core';
export const ConfigContext = React.createContext<Config | undefined>(undefined);
export const useConfig = () => {
const context = useContext(ConfigContext);
if (context === undefined) {
throw new Error('useConfig must be used within a ConfigProvider');
}
return context;
};

View file

@ -0,0 +1,56 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createContext, useContext } from 'react';
import { type Key } from '../hooks/useKeypress.js';
import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js';
import { type FolderTrustChoice } from '../components/FolderTrustDialog.js';
import { type AuthType, type EditorType } from '@google/gemini-cli-core';
import { type SettingScope } from '../../config/settings.js';
import type { AuthState } from '../types.js';
export interface UIActions {
handleThemeSelect: (
themeName: string | undefined,
scope: SettingScope,
) => void;
handleThemeHighlight: (themeName: string | undefined) => void;
handleAuthSelect: (
authType: AuthType | undefined,
scope: SettingScope,
) => void;
setAuthState: (state: AuthState) => void;
onAuthError: (error: string) => void;
handleEditorSelect: (
editorType: EditorType | undefined,
scope: SettingScope,
) => void;
exitEditorDialog: () => void;
exitPrivacyNotice: () => void;
closeSettingsDialog: () => void;
setShellModeActive: (value: boolean) => void;
vimHandleInput: (key: Key) => boolean;
handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void;
handleFolderTrustSelect: (choice: FolderTrustChoice) => void;
setConstrainHeight: (value: boolean) => void;
onEscapePromptChange: (show: boolean) => void;
refreshStatic: () => void;
handleFinalSubmit: (value: string) => void;
handleClearScreen: () => void;
onWorkspaceMigrationDialogOpen: () => void;
onWorkspaceMigrationDialogClose: () => void;
handleProQuotaChoice: (choice: 'auth' | 'continue') => void;
}
export const UIActionsContext = createContext<UIActions | null>(null);
export const useUIActions = () => {
const context = useContext(UIActionsContext);
if (!context) {
throw new Error('useUIActions must be used within a UIActionsProvider');
}
return context;
};

View file

@ -0,0 +1,112 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createContext, useContext } from 'react';
import type {
HistoryItem,
ThoughtSummary,
ConsoleMessageItem,
ShellConfirmationRequest,
ConfirmationRequest,
HistoryItemWithoutId,
StreamingState,
} from '../types.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { TextBuffer } from '../components/shared/text-buffer.js';
import type {
IdeContext,
ApprovalMode,
UserTierId,
DetectedIde,
} from '@google/gemini-cli-core';
import type { DOMElement } from 'ink';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import type { UpdateObject } from '../utils/updateCheck.js';
export interface UIState {
history: HistoryItem[];
isThemeDialogOpen: boolean;
themeError: string | null;
isAuthenticating: boolean;
authError: string | null;
isAuthDialogOpen: boolean;
editorError: string | null;
isEditorDialogOpen: boolean;
showPrivacyNotice: boolean;
corgiMode: boolean;
debugMessage: string;
quittingMessages: HistoryItem[] | null;
isSettingsDialogOpen: boolean;
slashCommands: readonly SlashCommand[];
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
commandContext: CommandContext;
shellConfirmationRequest: ShellConfirmationRequest | null;
confirmationRequest: ConfirmationRequest | null;
geminiMdFileCount: number;
streamingState: StreamingState;
initError: string | null;
pendingGeminiHistoryItems: HistoryItemWithoutId[];
thought: ThoughtSummary | null;
shellModeActive: boolean;
userMessages: string[];
buffer: TextBuffer;
inputWidth: number;
suggestionsWidth: number;
isInputActive: boolean;
shouldShowIdePrompt: boolean;
isFolderTrustDialogOpen: boolean;
isTrustedFolder: boolean | undefined;
constrainHeight: boolean;
showErrorDetails: boolean;
filteredConsoleMessages: ConsoleMessageItem[];
ideContextState: IdeContext | undefined;
showToolDescriptions: boolean;
ctrlCPressedOnce: boolean;
ctrlDPressedOnce: boolean;
showEscapePrompt: boolean;
isFocused: boolean;
elapsedTime: number;
currentLoadingPhrase: string;
historyRemountKey: number;
messageQueue: string[];
showAutoAcceptIndicator: ApprovalMode;
showWorkspaceMigrationDialog: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
workspaceExtensions: any[]; // Extension[]
// Quota-related state
userTier: UserTierId | undefined;
isProQuotaDialogOpen: boolean;
currentModel: string;
// New fields for complete state management
contextFileNames: string[];
errorCount: number;
availableTerminalHeight: number | undefined;
mainAreaWidth: number;
staticAreaMaxItemHeight: number;
staticExtraHeight: number;
dialogsVisible: boolean;
pendingHistoryItems: HistoryItemWithoutId[];
nightly: boolean;
branchName: string | undefined;
sessionStats: SessionStatsState;
terminalWidth: number;
terminalHeight: number;
mainControlsRef: React.MutableRefObject<DOMElement | null>;
currentIDE: DetectedIde | null;
updateInfo: UpdateObject | null;
showIdeRestartPrompt: boolean;
isRestarting: boolean;
}
export const UIStateContext = createContext<UIState | null>(null);
export const useUIState = () => {
const context = useContext(UIStateContext);
if (!context) {
throw new Error('useUIState must be used within a UIStateProvider');
}
return context;
};

View file

@ -15,7 +15,7 @@ import {
StandardFileSystemService,
ToolRegistry,
COMMON_IGNORE_PATTERNS,
DEFAULT_FILE_EXCLUDES,
// DEFAULT_FILE_EXCLUDES,
} from '@google/gemini-cli-core';
import * as os from 'node:os';
import { ToolCallStatus } from '../types.js';
@ -74,10 +74,10 @@ describe('handleAtCommand', () => {
getDebugMode: () => false,
getFileExclusions: () => ({
getCoreIgnorePatterns: () => COMMON_IGNORE_PATTERNS,
getDefaultExcludePatterns: () => DEFAULT_FILE_EXCLUDES,
getGlobExcludes: () => COMMON_IGNORE_PATTERNS,
buildExcludePatterns: () => DEFAULT_FILE_EXCLUDES,
getReadManyFilesExcludes: () => DEFAULT_FILE_EXCLUDES,
getDefaultExcludePatterns: () => [],
getGlobExcludes: () => [],
buildExcludePatterns: () => [],
getReadManyFilesExcludes: () => [],
}),
getUsageStatisticsEnabled: () => false,
} as unknown as Config;

View file

@ -137,16 +137,19 @@ describe('useSlashCommandProcessor', () => {
mockClearItems,
mockLoadHistory,
vi.fn(), // refreshStatic
vi.fn(), // onDebugMessage
mockOpenThemeDialog, // openThemeDialog
mockOpenAuthDialog,
vi.fn(), // openEditorDialog
vi.fn(), // toggleCorgiMode
mockSetQuittingMessages,
vi.fn(), // openPrivacyNotice
vi.fn(), // openSettingsDialog
vi.fn(), // toggleVimEnabled
setIsProcessing,
vi.fn(), // setGeminiMdFileCount
{
openAuthDialog: mockOpenAuthDialog,
openThemeDialog: mockOpenThemeDialog,
openEditorDialog: vi.fn(),
openPrivacyNotice: vi.fn(),
openSettingsDialog: vi.fn(),
quit: mockSetQuittingMessages,
setDebugMessage: vi.fn(),
toggleCorgiMode: vi.fn(),
},
),
);
@ -460,73 +463,24 @@ describe('useSlashCommandProcessor', () => {
});
});
describe('with fake timers', () => {
// This test needs to let the async `waitFor` complete with REAL timers
// before switching to FAKE timers to test setTimeout.
it('should handle a "quit" action', async () => {
const quitAction = vi
.fn()
.mockResolvedValue({ type: 'quit', messages: [] });
const command = createTestCommand({
name: 'exit',
action: quitAction,
});
const result = setupProcessorHook([command]);
it('should handle a "quit" action', async () => {
const quitAction = vi
.fn()
.mockResolvedValue({ type: 'quit', messages: ['bye'] });
const command = createTestCommand({
name: 'exit',
action: quitAction,
});
const result = setupProcessorHook([command]);
await waitFor(() =>
expect(result.current.slashCommands).toHaveLength(1),
);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
vi.useFakeTimers();
try {
await act(async () => {
await result.current.handleSlashCommand('/exit');
});
await act(async () => {
await vi.advanceTimersByTimeAsync(200);
});
expect(mockSetQuittingMessages).toHaveBeenCalledWith([]);
expect(mockProcessExit).toHaveBeenCalledWith(0);
} finally {
vi.useRealTimers();
}
await act(async () => {
await result.current.handleSlashCommand('/exit');
});
it('should call runExitCleanup when handling a "quit" action', async () => {
const quitAction = vi
.fn()
.mockResolvedValue({ type: 'quit', messages: [] });
const command = createTestCommand({
name: 'exit',
action: quitAction,
});
const result = setupProcessorHook([command]);
await waitFor(() =>
expect(result.current.slashCommands).toHaveLength(1),
);
vi.useFakeTimers();
try {
await act(async () => {
await result.current.handleSlashCommand('/exit');
});
await act(async () => {
await vi.advanceTimersByTimeAsync(200);
});
expect(mockRunExitCleanup).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']);
});
it('should handle "submit_prompt" action returned from a file-based command', async () => {
const fileCommand = createTestCommand(
{

View file

@ -20,13 +20,11 @@ import {
IdeClient,
} from '@google/gemini-cli-core';
import { useSessionStats } from '../contexts/SessionContext.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import {
type Message,
type HistoryItemWithoutId,
type HistoryItem,
type SlashCommandProcessorResult,
AuthState,
import type {
Message,
HistoryItemWithoutId,
SlashCommandProcessorResult,
HistoryItem,
} from '../types.js';
import { MessageType } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
@ -36,6 +34,17 @@ import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
interface SlashCommandProcessorActions {
openAuthDialog: () => void;
openThemeDialog: () => void;
openEditorDialog: () => void;
openPrivacyNotice: () => void;
openSettingsDialog: () => void;
quit: (messages: HistoryItem[]) => void;
setDebugMessage: (message: string) => void;
toggleCorgiMode: () => void;
}
/**
* Hook to define and process slash commands (e.g., /help, /clear).
*/
@ -46,17 +55,10 @@ export const useSlashCommandProcessor = (
clearItems: UseHistoryManagerReturn['clearItems'],
loadHistory: UseHistoryManagerReturn['loadHistory'],
refreshStatic: () => void,
onDebugMessage: (message: string) => void,
openThemeDialog: () => void,
setAuthState: (state: AuthState) => void,
openEditorDialog: () => void,
toggleCorgiMode: () => void,
setQuittingMessages: (message: HistoryItem[]) => void,
openPrivacyNotice: () => void,
openSettingsDialog: () => void,
toggleVimEnabled: () => Promise<boolean>,
setIsProcessing: (isProcessing: boolean) => void,
setGeminiMdFileCount: (count: number) => void,
actions: SlashCommandProcessorActions,
) => {
const session = useSessionStats();
const [commands, setCommands] = useState<readonly SlashCommand[]>([]);
@ -178,10 +180,10 @@ export const useSlashCommandProcessor = (
refreshStatic();
},
loadHistory,
setDebugMessage: onDebugMessage,
setDebugMessage: actions.setDebugMessage,
pendingItem: pendingCompressionItem,
setPendingItem: setPendingCompressionItem,
toggleCorgiMode,
toggleCorgiMode: actions.toggleCorgiMode,
toggleVimEnabled,
setGeminiMdFileCount,
reloadCommands,
@ -201,10 +203,9 @@ export const useSlashCommandProcessor = (
clearItems,
refreshStatic,
session.stats,
onDebugMessage,
actions,
pendingCompressionItem,
setPendingCompressionItem,
toggleCorgiMode,
toggleVimEnabled,
sessionShellAllowlist,
setGeminiMdFileCount,
@ -376,19 +377,19 @@ export const useSlashCommandProcessor = (
case 'dialog':
switch (result.dialog) {
case 'auth':
setAuthState(AuthState.Updating);
actions.openAuthDialog();
return { type: 'handled' };
case 'theme':
openThemeDialog();
actions.openThemeDialog();
return { type: 'handled' };
case 'editor':
openEditorDialog();
actions.openEditorDialog();
return { type: 'handled' };
case 'privacy':
openPrivacyNotice();
actions.openPrivacyNotice();
return { type: 'handled' };
case 'settings':
openSettingsDialog();
actions.openSettingsDialog();
return { type: 'handled' };
case 'help':
return { type: 'handled' };
@ -410,11 +411,7 @@ export const useSlashCommandProcessor = (
return { type: 'handled' };
}
case 'quit':
setQuittingMessages(result.messages);
setTimeout(async () => {
await runExitCleanup();
process.exit(0);
}, 100);
actions.quit(result.messages);
return { type: 'handled' };
case 'submit_prompt':
@ -555,15 +552,10 @@ export const useSlashCommandProcessor = (
[
config,
addItem,
setAuthState,
actions,
commands,
commandContext,
addMessage,
openThemeDialog,
openPrivacyNotice,
openEditorDialog,
setQuittingMessages,
openSettingsDialog,
setShellConfirmationRequest,
setSessionShellAllowlist,
setIsProcessing,

View file

@ -22,6 +22,7 @@ vi.mock('process', () => ({
describe('useFolderTrust', () => {
let mockSettings: LoadedSettings;
let mockConfig: unknown;
let mockTrustedFolders: LoadedTrustedFolders;
let loadTrustedFoldersSpy: vi.SpyInstance;
let isWorkspaceTrustedSpy: vi.SpyInstance;
@ -36,6 +37,8 @@ describe('useFolderTrust', () => {
setValue: vi.fn(),
} as unknown as LoadedSettings;
mockConfig = {} as unknown;
mockTrustedFolders = {
setValue: vi.fn(),
} as unknown as LoadedTrustedFolders;
@ -55,7 +58,7 @@ describe('useFolderTrust', () => {
it('should not open dialog when folder is already trusted', () => {
isWorkspaceTrustedSpy.mockReturnValue(true);
const { result } = renderHook(() =>
useFolderTrust(mockSettings, onTrustChange),
useFolderTrust(mockSettings, mockConfig, onTrustChange),
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
expect(onTrustChange).toHaveBeenCalledWith(true);
@ -64,7 +67,7 @@ describe('useFolderTrust', () => {
it('should not open dialog when folder is already untrusted', () => {
isWorkspaceTrustedSpy.mockReturnValue(false);
const { result } = renderHook(() =>
useFolderTrust(mockSettings, onTrustChange),
useFolderTrust(mockSettings, mockConfig, onTrustChange),
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
expect(onTrustChange).toHaveBeenCalledWith(false);
@ -73,7 +76,7 @@ describe('useFolderTrust', () => {
it('should open dialog when folder trust is undefined', () => {
isWorkspaceTrustedSpy.mockReturnValue(undefined);
const { result } = renderHook(() =>
useFolderTrust(mockSettings, onTrustChange),
useFolderTrust(mockSettings, mockConfig, onTrustChange),
);
expect(result.current.isFolderTrustDialogOpen).toBe(true);
expect(onTrustChange).toHaveBeenCalledWith(undefined);
@ -84,7 +87,7 @@ describe('useFolderTrust', () => {
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(true);
const { result } = renderHook(() =>
useFolderTrust(mockSettings, onTrustChange),
useFolderTrust(mockSettings, mockConfig, onTrustChange),
);
isWorkspaceTrustedSpy.mockReturnValue(true);
@ -106,7 +109,7 @@ describe('useFolderTrust', () => {
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(true);
const { result } = renderHook(() =>
useFolderTrust(mockSettings, onTrustChange),
useFolderTrust(mockSettings, mockConfig, onTrustChange),
);
act(() => {
@ -126,7 +129,7 @@ describe('useFolderTrust', () => {
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(false);
const { result } = renderHook(() =>
useFolderTrust(mockSettings, onTrustChange),
useFolderTrust(mockSettings, mockConfig, onTrustChange),
);
act(() => {
@ -138,14 +141,14 @@ describe('useFolderTrust', () => {
TrustLevel.DO_NOT_TRUST,
);
expect(onTrustChange).toHaveBeenLastCalledWith(false);
expect(result.current.isRestarting).toBe(true);
expect(result.current.isFolderTrustDialogOpen).toBe(true);
expect(result.current.isRestarting).toBe(false);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
});
it('should do nothing for default choice', () => {
isWorkspaceTrustedSpy.mockReturnValue(undefined);
const { result } = renderHook(() =>
useFolderTrust(mockSettings, onTrustChange),
useFolderTrust(mockSettings, mockConfig, onTrustChange),
);
act(() => {
@ -163,15 +166,15 @@ describe('useFolderTrust', () => {
it('should set isRestarting to true when trust status changes from false to true', () => {
isWorkspaceTrustedSpy.mockReturnValueOnce(false).mockReturnValueOnce(true); // Initially untrusted, then trusted
const { result } = renderHook(() =>
useFolderTrust(mockSettings, onTrustChange),
useFolderTrust(mockSettings, mockConfig, onTrustChange),
);
act(() => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER);
});
expect(result.current.isRestarting).toBe(true);
expect(result.current.isFolderTrustDialogOpen).toBe(true); // Dialog should stay open
expect(result.current.isRestarting).toBe(false);
expect(result.current.isFolderTrustDialogOpen).toBe(false); // Dialog should close after selection
});
it('should not set isRestarting to true when trust status does not change', () => {
@ -179,7 +182,7 @@ describe('useFolderTrust', () => {
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(true); // Initially undefined, then trust
const { result } = renderHook(() =>
useFolderTrust(mockSettings, onTrustChange),
useFolderTrust(mockSettings, mockConfig, onTrustChange),
);
act(() => {

View file

@ -5,7 +5,8 @@
*/
import { useState, useCallback, useEffect } from 'react';
import type { Settings, LoadedSettings } from '../../config/settings.js';
import { type Config } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
import { FolderTrustChoice } from '../components/FolderTrustDialog.js';
import {
loadTrustedFolders,
@ -16,26 +17,21 @@ import * as process from 'node:process';
export const useFolderTrust = (
settings: LoadedSettings,
config: Config,
onTrustChange: (isTrusted: boolean | undefined) => void,
) => {
const [isTrusted, setIsTrusted] = useState<boolean | undefined>(undefined);
const [isFolderTrustDialogOpen, setIsFolderTrustDialogOpen] = useState(false);
const [isRestarting, setIsRestarting] = useState(false);
const [isRestarting] = useState(false);
const folderTrust = settings.merged.security?.folderTrust?.enabled;
useEffect(() => {
const trusted = isWorkspaceTrusted({
security: {
folderTrust: {
enabled: folderTrust,
},
},
} as Settings);
const trusted = isWorkspaceTrusted(settings.merged);
setIsTrusted(trusted);
setIsFolderTrustDialogOpen(trusted === undefined);
onTrustChange(trusted);
}, [onTrustChange, folderTrust]);
}, [folderTrust, onTrustChange, settings.merged]);
const handleFolderTrustSelect = useCallback(
(choice: FolderTrustChoice) => {
@ -43,8 +39,6 @@ export const useFolderTrust = (
const cwd = process.cwd();
let trustLevel: TrustLevel;
const wasTrusted = isTrusted ?? true;
switch (choice) {
case FolderTrustChoice.TRUST_FOLDER:
trustLevel = TrustLevel.TRUST_FOLDER;
@ -60,21 +54,12 @@ export const useFolderTrust = (
}
trustedFolders.setValue(cwd, trustLevel);
const newIsTrusted =
trustLevel === TrustLevel.TRUST_FOLDER ||
trustLevel === TrustLevel.TRUST_PARENT;
setIsTrusted(newIsTrusted);
onTrustChange(newIsTrusted);
const needsRestart = wasTrusted !== newIsTrusted;
if (needsRestart) {
setIsRestarting(true);
setIsFolderTrustDialogOpen(true);
} else {
setIsFolderTrustDialogOpen(false);
}
const trusted = isWorkspaceTrusted(settings.merged);
setIsTrusted(trusted);
setIsFolderTrustDialogOpen(false);
onTrustChange(trusted);
},
[onTrustChange, isTrusted],
[settings.merged, onTrustChange],
);
return {

View file

@ -140,7 +140,8 @@ export function useReactToolScheduler(
getPreferredEditor,
config,
onEditorClose,
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any),
[
config,
outputUpdateHandler,

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback } from 'react';
import { themeManager } from '../themes/theme-manager.js';
import type { LoadedSettings, SettingScope } from '../../config/settings.js'; // Import LoadedSettings, AppSettings, MergedSetting
import { type HistoryItem, MessageType } from '../types.js';
@ -24,19 +24,10 @@ export const useThemeCommand = (
loadedSettings: LoadedSettings,
setThemeError: (error: string | null) => void,
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
initialThemeError: string | null,
): UseThemeCommandReturn => {
const [isThemeDialogOpen, setIsThemeDialogOpen] = useState(false);
// Check for invalid theme configuration on startup
useEffect(() => {
const effectiveTheme = loadedSettings.merged.ui?.theme;
if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) {
setIsThemeDialogOpen(true);
setThemeError(`Theme "${effectiveTheme}" not found.`);
} else {
setThemeError(null);
}
}, [loadedSettings.merged.ui?.theme, setThemeError]);
const [isThemeDialogOpen, setIsThemeDialogOpen] =
useState(!!initialThemeError);
const openThemeDialog = useCallback(() => {
if (process.env['NO_COLOR']) {

View file

@ -6,10 +6,15 @@
import type {
CompressionStatus,
ThoughtSummary,
ToolCallConfirmationDetails,
ToolConfirmationOutcome,
ToolResultDisplay,
} from '@google/gemini-cli-core';
import type { PartListUnion } from '@google/genai';
import { type ReactNode } from 'react';
export type { ThoughtSummary };
export enum AuthState {
// Attemtping to authenticate or re-authenticate
@ -266,3 +271,16 @@ export type SlashCommandProcessorResult =
type: 'handled'; // Indicates the command was processed and no further action is needed.
}
| SubmitPromptResult;
export interface ShellConfirmationRequest {
commands: string[];
onConfirm: (
outcome: ToolConfirmationOutcome,
approvedCommands?: string[],
) => void;
}
export interface ConfirmationRequest {
prompt: ReactNode;
onConfirm: (confirm: boolean) => void;
}

View file

@ -12,6 +12,11 @@ export {
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_EMBEDDING_MODEL,
} from './src/config/models.js';
export {
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
} from './src/config/config.js';
export { getIdeInfo } from './src/ide/detect-ide.js';
export { logIdeConnection } from './src/telemetry/loggers.js';
export {
IdeConnectionEvent,

View file

@ -77,6 +77,7 @@
"@types/minimatch": "^5.1.2",
"@types/picomatch": "^4.0.1",
"@types/ws": "^8.5.10",
"msw": "^2.3.4",
"typescript": "^5.3.3",
"vitest": "^3.1.1"
},