mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 18:14:29 -07:00
feat(ui): Introduce useUI Hook and UIContext (#5488)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
@@ -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']) {
|
||||
|
||||
Reference in New Issue
Block a user