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
@@ -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(() => {
+11 -26
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 {
@@ -140,7 +140,8 @@ export function useReactToolScheduler(
getPreferredEditor,
config,
onEditorClose,
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any),
[
config,
outputUpdateHandler,
+4 -13
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']) {