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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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