mirror of
https://github.com/google-gemini/gemini-cli
synced 2026-04-21 13:37:17 +00:00
feat(ui): Introduce useUI Hook and UIContext (#5488)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
parent
fe15b04f33
commit
885af07ddb
40 changed files with 3443 additions and 3388 deletions
103
docs/mermaid/context.mmd
Normal file
103
docs/mermaid/context.mmd
Normal 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
|
||||
64
docs/mermaid/render-path.mmd
Normal file
64
docs/mermaid/render-path.mmd
Normal 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
15
package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
36
packages/cli/src/core/auth.ts
Normal file
36
packages/cli/src/core/auth.ts
Normal 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;
|
||||
}
|
||||
45
packages/cli/src/core/initializer.ts
Normal file
45
packages/cli/src/core/initializer.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
21
packages/cli/src/core/theme.ts
Normal file
21
packages/cli/src/core/theme.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
331
packages/cli/src/ui/AppContainer.test.tsx
Normal file
331
packages/cli/src/ui/AppContainer.test.tsx
Normal 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
|
||||
1260
packages/cli/src/ui/AppContainer.tsx
Normal file
1260
packages/cli/src/ui/AppContainer.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -20,9 +20,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|||
IdeClient: {
|
||||
getInstance: vi.fn(),
|
||||
},
|
||||
ideContext: {
|
||||
getIdeContext: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
32
packages/cli/src/ui/components/AppHeader.tsx
Normal file
32
packages/cli/src/ui/components/AppHeader.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
433
packages/cli/src/ui/components/Composer.test.tsx
Normal file
433
packages/cli/src/ui/components/Composer.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
186
packages/cli/src/ui/components/Composer.tsx
Normal file
186
packages/cli/src/ui/components/Composer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
184
packages/cli/src/ui/components/DialogManager.tsx
Normal file
184
packages/cli/src/ui/components/DialogManager.tsx
Normal 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 'r' 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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
64
packages/cli/src/ui/components/MainContent.tsx
Normal file
64
packages/cli/src/ui/components/MainContent.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
62
packages/cli/src/ui/components/Notifications.tsx
Normal file
62
packages/cli/src/ui/components/Notifications.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
37
packages/cli/src/ui/components/QuittingDisplay.tsx
Normal file
37
packages/cli/src/ui/components/QuittingDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
22
packages/cli/src/ui/contexts/AppContext.tsx
Normal file
22
packages/cli/src/ui/contexts/AppContext.tsx
Normal 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;
|
||||
};
|
||||
18
packages/cli/src/ui/contexts/ConfigContext.tsx
Normal file
18
packages/cli/src/ui/contexts/ConfigContext.tsx
Normal 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;
|
||||
};
|
||||
56
packages/cli/src/ui/contexts/UIActionsContext.tsx
Normal file
56
packages/cli/src/ui/contexts/UIActionsContext.tsx
Normal 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;
|
||||
};
|
||||
112
packages/cli/src/ui/contexts/UIStateContext.tsx
Normal file
112
packages/cli/src/ui/contexts/UIStateContext.tsx
Normal 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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -140,7 +140,8 @@ export function useReactToolScheduler(
|
|||
getPreferredEditor,
|
||||
config,
|
||||
onEditorClose,
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any),
|
||||
[
|
||||
config,
|
||||
outputUpdateHandler,
|
||||
|
|
|
|||
|
|
@ -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']) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue