mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
feat(ui): improve startup warnings UX with dismissal and show-count limits (#19584)
This commit is contained in:
@@ -38,6 +38,8 @@ import { appEvents, AppEvent } from './utils/events.js';
|
||||
import {
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
type StartupWarning,
|
||||
WarningPriority,
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
AuthType,
|
||||
@@ -1193,7 +1195,9 @@ describe('startInteractiveUI', () => {
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
const mockStartupWarnings = ['warning1'];
|
||||
const mockStartupWarnings: StartupWarning[] = [
|
||||
{ id: 'w1', message: 'warning1', priority: WarningPriority.High },
|
||||
];
|
||||
const mockWorkspaceRoot = '/root';
|
||||
const mockInitializationResult = {
|
||||
authError: null,
|
||||
@@ -1226,7 +1230,7 @@ describe('startInteractiveUI', () => {
|
||||
async function startTestInteractiveUI(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
startupWarnings: string[],
|
||||
startupWarnings: StartupWarning[],
|
||||
workspaceRoot: string,
|
||||
resumedSessionData: ResumedSessionData | undefined,
|
||||
initializationResult: InitializationResult,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
import * as cliConfig from './config/config.js';
|
||||
import { readStdin } from './utils/readStdin.js';
|
||||
import { basename } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import v8 from 'node:v8';
|
||||
import os from 'node:os';
|
||||
import dns from 'node:dns';
|
||||
@@ -37,6 +38,8 @@ import {
|
||||
cleanupExpiredSessions,
|
||||
} from './utils/sessionCleanup.js';
|
||||
import {
|
||||
type StartupWarning,
|
||||
WarningPriority,
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
type OutputPayload,
|
||||
@@ -180,7 +183,7 @@ ${reason.stack}`
|
||||
export async function startInteractiveUI(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
startupWarnings: string[],
|
||||
startupWarnings: StartupWarning[],
|
||||
workspaceRoot: string = process.cwd(),
|
||||
resumedSessionData: ResumedSessionData | undefined,
|
||||
initializationResult: InitializationResult,
|
||||
@@ -668,8 +671,13 @@ export async function main() {
|
||||
}
|
||||
|
||||
let input = config.getQuestion();
|
||||
const startupWarnings = [
|
||||
...(await getStartupWarnings()),
|
||||
const rawStartupWarnings = await getStartupWarnings();
|
||||
const startupWarnings: StartupWarning[] = [
|
||||
...rawStartupWarnings.map((message) => ({
|
||||
id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`,
|
||||
message,
|
||||
priority: WarningPriority.High,
|
||||
})),
|
||||
...(await getUserStartupWarnings(settings.merged)),
|
||||
];
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
CoreEvent,
|
||||
type UserFeedbackPayload,
|
||||
type ResumedSessionData,
|
||||
type StartupWarning,
|
||||
WarningPriority,
|
||||
AuthType,
|
||||
type AgentDefinition,
|
||||
CoreToolCallStatus,
|
||||
@@ -248,7 +250,7 @@ describe('AppContainer State Management', () => {
|
||||
config?: Config;
|
||||
version?: string;
|
||||
initResult?: InitializationResult;
|
||||
startupWarnings?: string[];
|
||||
startupWarnings?: StartupWarning[];
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
} = {}) => (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
@@ -501,7 +503,18 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
it('renders with startup warnings', async () => {
|
||||
const startupWarnings = ['Warning 1', 'Warning 2'];
|
||||
const startupWarnings: StartupWarning[] = [
|
||||
{
|
||||
id: 'w1',
|
||||
message: 'Warning 1',
|
||||
priority: WarningPriority.High,
|
||||
},
|
||||
{
|
||||
id: 'w2',
|
||||
message: 'Warning 2',
|
||||
priority: WarningPriority.High,
|
||||
},
|
||||
];
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
|
||||
@@ -41,6 +41,7 @@ import { checkPermissions } from './hooks/atCommandProcessor.js';
|
||||
import { MessageType, StreamingState } from './types.js';
|
||||
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
|
||||
import {
|
||||
type StartupWarning,
|
||||
type EditorType,
|
||||
type Config,
|
||||
type IdeInfo,
|
||||
@@ -186,7 +187,7 @@ function isToolAwaitingConfirmation(
|
||||
|
||||
interface AppContainerProps {
|
||||
config: Config;
|
||||
startupWarnings?: string[];
|
||||
startupWarnings?: StartupWarning[];
|
||||
version: string;
|
||||
initializationResult: InitializationResult;
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render, persistentStateMock } from '../../test-utils/render.js';
|
||||
import {
|
||||
persistentStateMock,
|
||||
renderWithProviders,
|
||||
} from '../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { Notifications } from './Notifications.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
@@ -13,6 +18,7 @@ import { useUIState, type UIState } from '../contexts/UIStateContext.js';
|
||||
import { useIsScreenReaderEnabled } from 'ink';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { act } from 'react';
|
||||
import { WarningPriority } from '@google/gemini-cli-core';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../contexts/AppContext.js');
|
||||
@@ -61,22 +67,18 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
...actual,
|
||||
GEMINI_DIR: '.gemini',
|
||||
homedir: () => '/mock/home',
|
||||
WarningPriority: {
|
||||
Low: 'low',
|
||||
High: 'high',
|
||||
},
|
||||
Storage: {
|
||||
...actual.Storage,
|
||||
getGlobalTempDir: () => '/mock/temp',
|
||||
getGlobalSettingsPath: () => '/mock/home/.gemini/settings.json',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/settings.js', () => ({
|
||||
DEFAULT_MODEL_CONFIGS: {},
|
||||
LoadedSettings: class {
|
||||
constructor() {
|
||||
// this.merged = {};
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Notifications', () => {
|
||||
const mockUseAppContext = vi.mocked(useAppContext);
|
||||
const mockUseUIState = vi.mocked(useUIState);
|
||||
@@ -84,9 +86,14 @@ describe('Notifications', () => {
|
||||
const mockFsAccess = vi.mocked(fs.access);
|
||||
const mockFsUnlink = vi.mocked(fs.unlink);
|
||||
|
||||
let settings: LoadedSettings;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
persistentStateMock.reset();
|
||||
settings = createMockSettings({
|
||||
ui: { useAlternateBuffer: true },
|
||||
});
|
||||
mockUseAppContext.mockReturnValue({
|
||||
startupWarnings: [],
|
||||
version: '1.0.0',
|
||||
@@ -100,60 +107,195 @@ describe('Notifications', () => {
|
||||
});
|
||||
|
||||
it('renders nothing when no notifications', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each([[['Warning 1']], [['Warning 1', 'Warning 2']]])(
|
||||
'renders startup warnings: %s',
|
||||
async (warnings) => {
|
||||
mockUseAppContext.mockReturnValue({
|
||||
startupWarnings: warnings,
|
||||
version: '1.0.0',
|
||||
} as AppState);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
warnings.forEach((warning) => {
|
||||
expect(output).toContain(warning);
|
||||
});
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
[[{ id: 'w1', message: 'Warning 1', priority: WarningPriority.High }]],
|
||||
[
|
||||
[
|
||||
{ id: 'w1', message: 'Warning 1', priority: WarningPriority.High },
|
||||
{ id: 'w2', message: 'Warning 2', priority: WarningPriority.High },
|
||||
],
|
||||
],
|
||||
])('renders startup warnings: %s', async (warnings) => {
|
||||
const appState = {
|
||||
startupWarnings: warnings,
|
||||
version: '1.0.0',
|
||||
} as AppState;
|
||||
mockUseAppContext.mockReturnValue(appState);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
appState,
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
warnings.forEach((warning) => {
|
||||
expect(output).toContain(warning.message);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('increments show count for low priority warnings', async () => {
|
||||
const warnings = [
|
||||
{ id: 'low-1', message: 'Low priority 1', priority: WarningPriority.Low },
|
||||
];
|
||||
const appState = {
|
||||
startupWarnings: warnings,
|
||||
version: '1.0.0',
|
||||
} as AppState;
|
||||
mockUseAppContext.mockReturnValue(appState);
|
||||
|
||||
const { waitUntilReady, unmount } = renderWithProviders(<Notifications />, {
|
||||
appState,
|
||||
settings,
|
||||
width: 100,
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(persistentStateMock.set).toHaveBeenCalledWith(
|
||||
'startupWarningCounts',
|
||||
{ 'low-1': 1 },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('filters out low priority warnings that exceeded max show count', async () => {
|
||||
const warnings = [
|
||||
{ id: 'low-1', message: 'Low priority 1', priority: WarningPriority.Low },
|
||||
{
|
||||
id: 'high-1',
|
||||
message: 'High priority 1',
|
||||
priority: WarningPriority.High,
|
||||
},
|
||||
];
|
||||
const appState = {
|
||||
startupWarnings: warnings,
|
||||
version: '1.0.0',
|
||||
} as AppState;
|
||||
mockUseAppContext.mockReturnValue(appState);
|
||||
|
||||
persistentStateMock.setData({
|
||||
startupWarningCounts: { 'low-1': 3 },
|
||||
});
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
appState,
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Low priority 1');
|
||||
expect(output).toContain('High priority 1');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('dismisses warnings on keypress', async () => {
|
||||
const warnings = [
|
||||
{
|
||||
id: 'high-1',
|
||||
message: 'High priority 1',
|
||||
priority: WarningPriority.High,
|
||||
},
|
||||
];
|
||||
const appState = {
|
||||
startupWarnings: warnings,
|
||||
version: '1.0.0',
|
||||
} as AppState;
|
||||
mockUseAppContext.mockReturnValue(appState);
|
||||
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
appState,
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('High priority 1');
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('a');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame({ allowEmpty: true })).not.toContain('High priority 1');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders init error', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
const uiState = {
|
||||
initError: 'Something went wrong',
|
||||
streamingState: 'idle',
|
||||
updateInfo: null,
|
||||
} as unknown as UIState);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
} as unknown as UIState;
|
||||
mockUseUIState.mockReturnValue(uiState);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
uiState,
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not render init error when streaming', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
const uiState = {
|
||||
initError: 'Something went wrong',
|
||||
streamingState: 'responding',
|
||||
updateInfo: null,
|
||||
} as unknown as UIState);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
} as unknown as UIState;
|
||||
mockUseUIState.mockReturnValue(uiState);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
uiState,
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders update notification', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
const uiState = {
|
||||
initError: null,
|
||||
streamingState: 'idle',
|
||||
updateInfo: { message: 'Update available' },
|
||||
} as unknown as UIState);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
} as unknown as UIState;
|
||||
mockUseUIState.mockReturnValue(uiState);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
uiState,
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
@@ -164,7 +306,13 @@ describe('Notifications', () => {
|
||||
persistentStateMock.setData({ hasSeenScreenReaderNudge: false });
|
||||
mockFsAccess.mockRejectedValue(new Error('No legacy file'));
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('screen reader-friendly view');
|
||||
@@ -182,7 +330,10 @@ describe('Notifications', () => {
|
||||
persistentStateMock.setData({ hasSeenScreenReaderNudge: undefined });
|
||||
mockFsAccess.mockResolvedValue(undefined);
|
||||
|
||||
const { waitUntilReady, unmount } = render(<Notifications />);
|
||||
const { waitUntilReady, unmount } = renderWithProviders(<Notifications />, {
|
||||
settings,
|
||||
width: 100,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await waitUntilReady();
|
||||
@@ -201,7 +352,13 @@ describe('Notifications', () => {
|
||||
mockUseIsScreenReaderEnabled.mockReturnValue(true);
|
||||
persistentStateMock.setData({ hasSeenScreenReaderNudge: true });
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
|
||||
@@ -5,15 +5,22 @@
|
||||
*/
|
||||
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { UpdateNotification } from './UpdateNotification.js';
|
||||
import { persistentState } from '../../utils/persistentState.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { KeypressPriority } from '../contexts/KeypressContext.js';
|
||||
|
||||
import { GEMINI_DIR, Storage, homedir } from '@google/gemini-cli-core';
|
||||
import {
|
||||
GEMINI_DIR,
|
||||
Storage,
|
||||
homedir,
|
||||
WarningPriority,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -25,12 +32,13 @@ const screenReaderNudgeFilePath = path.join(
|
||||
'seen_screen_reader_nudge.json',
|
||||
);
|
||||
|
||||
const MAX_STARTUP_WARNING_SHOW_COUNT = 3;
|
||||
|
||||
export const Notifications = () => {
|
||||
const { startupWarnings } = useAppContext();
|
||||
const { initError, streamingState, updateInfo } = useUIState();
|
||||
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
const showStartupWarnings = startupWarnings.length > 0;
|
||||
const showInitError =
|
||||
initError && streamingState !== StreamingState.Responding;
|
||||
|
||||
@@ -38,6 +46,57 @@ export const Notifications = () => {
|
||||
persistentState.get('hasSeenScreenReaderNudge'),
|
||||
);
|
||||
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
// Track if we have already incremented the show count in this session
|
||||
const hasIncrementedRef = useRef(false);
|
||||
|
||||
// Filter warnings based on persistent state count if low priority
|
||||
const visibleWarnings = useMemo(() => {
|
||||
if (dismissed) return [];
|
||||
|
||||
const counts = persistentState.get('startupWarningCounts') || {};
|
||||
return startupWarnings.filter((w) => {
|
||||
if (w.priority === WarningPriority.Low) {
|
||||
const count = counts[w.id] || 0;
|
||||
return count < MAX_STARTUP_WARNING_SHOW_COUNT;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [startupWarnings, dismissed]);
|
||||
|
||||
const showStartupWarnings = visibleWarnings.length > 0;
|
||||
|
||||
// Increment counts for low priority warnings when shown
|
||||
useEffect(() => {
|
||||
if (visibleWarnings.length > 0 && !hasIncrementedRef.current) {
|
||||
const counts = { ...(persistentState.get('startupWarningCounts') || {}) };
|
||||
let changed = false;
|
||||
visibleWarnings.forEach((w) => {
|
||||
if (w.priority === WarningPriority.Low) {
|
||||
counts[w.id] = (counts[w.id] || 0) + 1;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
persistentState.set('startupWarningCounts', counts);
|
||||
}
|
||||
hasIncrementedRef.current = true;
|
||||
}
|
||||
}, [visibleWarnings]);
|
||||
|
||||
const handleKeyPress = useCallback(() => {
|
||||
if (showStartupWarnings) {
|
||||
setDismissed(true);
|
||||
}
|
||||
return false;
|
||||
}, [showStartupWarnings]);
|
||||
|
||||
useKeypress(handleKeyPress, {
|
||||
isActive: showStartupWarnings,
|
||||
priority: KeypressPriority.Critical,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const checkLegacyScreenReaderNudge = async () => {
|
||||
if (hasSeenScreenReaderNudge !== undefined) return;
|
||||
@@ -89,13 +148,13 @@ export const Notifications = () => {
|
||||
{updateInfo && <UpdateNotification message={updateInfo.message} />}
|
||||
{showStartupWarnings && (
|
||||
<Box marginY={1} flexDirection="column">
|
||||
{startupWarnings.map((warning, index) => (
|
||||
{visibleWarnings.map((warning, index) => (
|
||||
<Box key={index} flexDirection="row">
|
||||
<Box width={3}>
|
||||
<Text color={theme.status.warning}>⚠ </Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text color={theme.status.warning}>{warning}</Text>
|
||||
<Text color={theme.status.warning}>{warning.message}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { StartupWarning } from '@google/gemini-cli-core';
|
||||
|
||||
export interface AppState {
|
||||
version: string;
|
||||
startupWarnings: string[];
|
||||
startupWarnings: StartupWarning[];
|
||||
}
|
||||
|
||||
export const AppContext = createContext<AppState | null>(null);
|
||||
|
||||
@@ -15,6 +15,7 @@ interface PersistentStateData {
|
||||
tipsShown?: number;
|
||||
hasSeenScreenReaderNudge?: boolean;
|
||||
focusUiEnabled?: boolean;
|
||||
startupWarningCounts?: Record<string, number>;
|
||||
// Add other persistent state keys here as needed
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
isFolderTrustEnabled,
|
||||
isWorkspaceTrusted,
|
||||
} from '../config/trustedFolders.js';
|
||||
import { getCompatibilityWarnings } from '@google/gemini-cli-core';
|
||||
import {
|
||||
getCompatibilityWarnings,
|
||||
WarningPriority,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
// Mock os.homedir to control the home directory in tests
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
@@ -31,6 +34,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
...actual,
|
||||
homedir: () => os.homedir(),
|
||||
getCompatibilityWarnings: vi.fn().mockReturnValue([]),
|
||||
WarningPriority: {
|
||||
Low: 'low',
|
||||
High: 'high',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -65,12 +72,13 @@ describe('getUserStartupWarnings', () => {
|
||||
it('should return a warning when running in home directory', async () => {
|
||||
const warnings = await getUserStartupWarnings({}, homeDir);
|
||||
expect(warnings).toContainEqual(
|
||||
expect.stringContaining(
|
||||
'Warning you are running Gemini CLI in your home directory',
|
||||
),
|
||||
);
|
||||
expect(warnings).toContainEqual(
|
||||
expect.stringContaining('warning can be disabled in /settings'),
|
||||
expect.objectContaining({
|
||||
id: 'home-directory',
|
||||
message: expect.stringContaining(
|
||||
'Warning you are running Gemini CLI in your home directory',
|
||||
),
|
||||
priority: WarningPriority.Low,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -78,9 +86,7 @@ describe('getUserStartupWarnings', () => {
|
||||
const projectDir = path.join(testRootDir, 'project');
|
||||
await fs.mkdir(projectDir);
|
||||
const warnings = await getUserStartupWarnings({}, projectDir);
|
||||
expect(warnings).not.toContainEqual(
|
||||
expect.stringContaining('home directory'),
|
||||
);
|
||||
expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not return a warning when showHomeDirectoryWarning is false', async () => {
|
||||
@@ -88,9 +94,7 @@ describe('getUserStartupWarnings', () => {
|
||||
{ ui: { showHomeDirectoryWarning: false } },
|
||||
homeDir,
|
||||
);
|
||||
expect(warnings).not.toContainEqual(
|
||||
expect.stringContaining('home directory'),
|
||||
);
|
||||
expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not return a warning when folder trust is enabled and workspace is trusted', async () => {
|
||||
@@ -101,9 +105,7 @@ describe('getUserStartupWarnings', () => {
|
||||
});
|
||||
|
||||
const warnings = await getUserStartupWarnings({}, homeDir);
|
||||
expect(warnings).not.toContainEqual(
|
||||
expect.stringContaining('home directory'),
|
||||
);
|
||||
expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,10 +114,11 @@ describe('getUserStartupWarnings', () => {
|
||||
const rootDir = path.parse(testRootDir).root;
|
||||
const warnings = await getUserStartupWarnings({}, rootDir);
|
||||
expect(warnings).toContainEqual(
|
||||
expect.stringContaining('root directory'),
|
||||
);
|
||||
expect(warnings).toContainEqual(
|
||||
expect.stringContaining('folder structure will be used'),
|
||||
expect.objectContaining({
|
||||
id: 'root-directory',
|
||||
message: expect.stringContaining('root directory'),
|
||||
priority: WarningPriority.High,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -123,9 +126,7 @@ describe('getUserStartupWarnings', () => {
|
||||
const projectDir = path.join(testRootDir, 'project');
|
||||
await fs.mkdir(projectDir);
|
||||
const warnings = await getUserStartupWarnings({}, projectDir);
|
||||
expect(warnings).not.toContainEqual(
|
||||
expect.stringContaining('root directory'),
|
||||
);
|
||||
expect(warnings.find((w) => w.id === 'root-directory')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,24 +134,37 @@ describe('getUserStartupWarnings', () => {
|
||||
it('should handle errors when checking directory', async () => {
|
||||
const nonExistentPath = path.join(testRootDir, 'non-existent');
|
||||
const warnings = await getUserStartupWarnings({}, nonExistentPath);
|
||||
const expectedWarning =
|
||||
const expectedMessage =
|
||||
'Could not verify the current directory due to a file system error.';
|
||||
expect(warnings).toEqual([expectedWarning, expectedWarning]);
|
||||
expect(warnings).toEqual([
|
||||
expect.objectContaining({ message: expectedMessage }),
|
||||
expect.objectContaining({ message: expectedMessage }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compatibility warnings', () => {
|
||||
it('should include compatibility warnings by default', async () => {
|
||||
vi.mocked(getCompatibilityWarnings).mockReturnValue(['Comp warning 1']);
|
||||
const compWarning = {
|
||||
id: 'comp-1',
|
||||
message: 'Comp warning 1',
|
||||
priority: WarningPriority.High,
|
||||
};
|
||||
vi.mocked(getCompatibilityWarnings).mockReturnValue([compWarning]);
|
||||
const projectDir = path.join(testRootDir, 'project');
|
||||
await fs.mkdir(projectDir);
|
||||
|
||||
const warnings = await getUserStartupWarnings({}, projectDir);
|
||||
expect(warnings).toContain('Comp warning 1');
|
||||
expect(warnings).toContainEqual(compWarning);
|
||||
});
|
||||
|
||||
it('should not include compatibility warnings when showCompatibilityWarnings is false', async () => {
|
||||
vi.mocked(getCompatibilityWarnings).mockReturnValue(['Comp warning 1']);
|
||||
const compWarning = {
|
||||
id: 'comp-1',
|
||||
message: 'Comp warning 1',
|
||||
priority: WarningPriority.High,
|
||||
};
|
||||
vi.mocked(getCompatibilityWarnings).mockReturnValue([compWarning]);
|
||||
const projectDir = path.join(testRootDir, 'project');
|
||||
await fs.mkdir(projectDir);
|
||||
|
||||
@@ -158,7 +172,7 @@ describe('getUserStartupWarnings', () => {
|
||||
{ ui: { showCompatibilityWarnings: false } },
|
||||
projectDir,
|
||||
);
|
||||
expect(warnings).not.toContain('Comp warning 1');
|
||||
expect(warnings).not.toContainEqual(compWarning);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { homedir, getCompatibilityWarnings } from '@google/gemini-cli-core';
|
||||
import {
|
||||
homedir,
|
||||
getCompatibilityWarnings,
|
||||
WarningPriority,
|
||||
type StartupWarning,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Settings } from '../config/settingsSchema.js';
|
||||
import {
|
||||
isFolderTrustEnabled,
|
||||
@@ -17,11 +22,13 @@ import {
|
||||
type WarningCheck = {
|
||||
id: string;
|
||||
check: (workspaceRoot: string, settings: Settings) => Promise<string | null>;
|
||||
priority: WarningPriority;
|
||||
};
|
||||
|
||||
// Individual warning checks
|
||||
const homeDirectoryCheck: WarningCheck = {
|
||||
id: 'home-directory',
|
||||
priority: WarningPriority.Low,
|
||||
check: async (workspaceRoot: string, settings: Settings) => {
|
||||
if (settings.ui?.showHomeDirectoryWarning === false) {
|
||||
return null;
|
||||
@@ -53,6 +60,7 @@ const homeDirectoryCheck: WarningCheck = {
|
||||
|
||||
const rootDirectoryCheck: WarningCheck = {
|
||||
id: 'root-directory',
|
||||
priority: WarningPriority.High,
|
||||
check: async (workspaceRoot: string, _settings: Settings) => {
|
||||
try {
|
||||
const workspaceRealPath = await fs.realpath(workspaceRoot);
|
||||
@@ -80,11 +88,21 @@ const WARNING_CHECKS: readonly WarningCheck[] = [
|
||||
export async function getUserStartupWarnings(
|
||||
settings: Settings,
|
||||
workspaceRoot: string = process.cwd(),
|
||||
): Promise<string[]> {
|
||||
): Promise<StartupWarning[]> {
|
||||
const results = await Promise.all(
|
||||
WARNING_CHECKS.map((check) => check.check(workspaceRoot, settings)),
|
||||
WARNING_CHECKS.map(async (check) => {
|
||||
const message = await check.check(workspaceRoot, settings);
|
||||
if (message) {
|
||||
return {
|
||||
id: check.id,
|
||||
message,
|
||||
priority: check.priority,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
const warnings = results.filter((msg) => msg !== null);
|
||||
const warnings = results.filter((w): w is StartupWarning => w !== null);
|
||||
|
||||
if (settings.ui?.showCompatibilityWarnings !== false) {
|
||||
warnings.push(...getCompatibilityWarnings());
|
||||
|
||||
@@ -9,6 +9,7 @@ import os from 'node:os';
|
||||
import {
|
||||
isWindows10,
|
||||
isJetBrainsTerminal,
|
||||
supports256Colors,
|
||||
supportsTrueColor,
|
||||
getCompatibilityWarnings,
|
||||
} from './compatibility.js';
|
||||
@@ -66,6 +67,25 @@ describe('compatibility', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('supports256Colors', () => {
|
||||
it('should return true when getColorDepth returns >= 8', () => {
|
||||
process.stdout.getColorDepth = vi.fn().mockReturnValue(8);
|
||||
expect(supports256Colors()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when TERM contains 256color', () => {
|
||||
process.stdout.getColorDepth = vi.fn().mockReturnValue(4);
|
||||
vi.stubEnv('TERM', 'xterm-256color');
|
||||
expect(supports256Colors()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when 256 colors are not supported', () => {
|
||||
process.stdout.getColorDepth = vi.fn().mockReturnValue(4);
|
||||
vi.stubEnv('TERM', 'xterm');
|
||||
expect(supports256Colors()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsTrueColor', () => {
|
||||
it('should return true when COLORTERM is truecolor', () => {
|
||||
vi.stubEnv('COLORTERM', 'truecolor');
|
||||
@@ -103,8 +123,11 @@ describe('compatibility', () => {
|
||||
vi.stubEnv('TERMINAL_EMULATOR', '');
|
||||
|
||||
const warnings = getCompatibilityWarnings();
|
||||
expect(warnings).toContain(
|
||||
'Warning: Windows 10 detected. Some UI features like smooth scrolling may be degraded. Windows 11 is recommended for the best experience.',
|
||||
expect(warnings).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'windows-10',
|
||||
message: expect.stringContaining('Windows 10 detected'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -113,35 +136,78 @@ describe('compatibility', () => {
|
||||
vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm');
|
||||
|
||||
const warnings = getCompatibilityWarnings();
|
||||
expect(warnings).toContain(
|
||||
'Warning: JetBrains terminal detected. You may experience rendering or scrolling issues. Using an external terminal (e.g., Windows Terminal, iTerm2) is recommended.',
|
||||
expect(warnings).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'jetbrains-terminal',
|
||||
message: expect.stringContaining('JetBrains terminal detected'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return true color warning when not supported', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
it('should return 256-color warning when 256 colors are not supported', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.stubEnv('TERMINAL_EMULATOR', '');
|
||||
vi.stubEnv('COLORTERM', '');
|
||||
vi.stubEnv('TERM', 'xterm');
|
||||
process.stdout.getColorDepth = vi.fn().mockReturnValue(4);
|
||||
|
||||
const warnings = getCompatibilityWarnings();
|
||||
expect(warnings).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: '256-color',
|
||||
message: expect.stringContaining('256-color support not detected'),
|
||||
priority: 'high',
|
||||
}),
|
||||
);
|
||||
// Should NOT show true-color warning if 256-color warning is shown
|
||||
expect(warnings.find((w) => w.id === 'true-color')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return true color warning when 256 colors are supported but true color is not, and not Apple Terminal', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.stubEnv('TERMINAL_EMULATOR', '');
|
||||
vi.stubEnv('COLORTERM', '');
|
||||
vi.stubEnv('TERM_PROGRAM', 'xterm');
|
||||
process.stdout.getColorDepth = vi.fn().mockReturnValue(8);
|
||||
|
||||
const warnings = getCompatibilityWarnings();
|
||||
expect(warnings).toContain(
|
||||
'Warning: True color (24-bit) support not detected. Using a terminal with true color enabled will result in a better visual experience.',
|
||||
expect(warnings).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'true-color',
|
||||
message: expect.stringContaining(
|
||||
'True color (24-bit) support not detected',
|
||||
),
|
||||
priority: 'low',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT return true color warning for Apple Terminal', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.stubEnv('TERMINAL_EMULATOR', '');
|
||||
vi.stubEnv('COLORTERM', '');
|
||||
vi.stubEnv('TERM_PROGRAM', 'Apple_Terminal');
|
||||
process.stdout.getColorDepth = vi.fn().mockReturnValue(8);
|
||||
|
||||
const warnings = getCompatibilityWarnings();
|
||||
expect(warnings.find((w) => w.id === 'true-color')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return all warnings when all are detected', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
vi.mocked(os.release).mockReturnValue('10.0.19041');
|
||||
vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm');
|
||||
vi.stubEnv('COLORTERM', '');
|
||||
vi.stubEnv('TERM_PROGRAM', 'xterm');
|
||||
process.stdout.getColorDepth = vi.fn().mockReturnValue(8);
|
||||
|
||||
const warnings = getCompatibilityWarnings();
|
||||
expect(warnings).toHaveLength(3);
|
||||
expect(warnings[0]).toContain('Windows 10 detected');
|
||||
expect(warnings[1]).toContain('JetBrains terminal detected');
|
||||
expect(warnings[2]).toContain('True color (24-bit) support not detected');
|
||||
expect(warnings[0].message).toContain('Windows 10 detected');
|
||||
expect(warnings[1].message).toContain('JetBrains terminal detected');
|
||||
expect(warnings[2].message).toContain(
|
||||
'True color (24-bit) support not detected',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return no warnings in a standard environment with true color', () => {
|
||||
|
||||
@@ -30,6 +30,31 @@ export function isJetBrainsTerminal(): boolean {
|
||||
return process.env['TERMINAL_EMULATOR'] === 'JetBrains-JediTerm';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if the current terminal is the default Apple Terminal.app.
|
||||
*/
|
||||
export function isAppleTerminal(): boolean {
|
||||
return process.env['TERM_PROGRAM'] === 'Apple_Terminal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if the current terminal supports 256 colors (8-bit).
|
||||
*/
|
||||
export function supports256Colors(): boolean {
|
||||
// Check if stdout supports at least 8-bit color depth
|
||||
if (process.stdout.getColorDepth && process.stdout.getColorDepth() >= 8) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check TERM environment variable
|
||||
const term = process.env['TERM'] || '';
|
||||
if (term.includes('256color')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if the current terminal supports true color (24-bit).
|
||||
*/
|
||||
@@ -53,25 +78,52 @@ export function supportsTrueColor(): boolean {
|
||||
/**
|
||||
* Returns a list of compatibility warnings based on the current environment.
|
||||
*/
|
||||
export function getCompatibilityWarnings(): string[] {
|
||||
const warnings: string[] = [];
|
||||
export enum WarningPriority {
|
||||
Low = 'low',
|
||||
High = 'high',
|
||||
}
|
||||
|
||||
export interface StartupWarning {
|
||||
id: string;
|
||||
message: string;
|
||||
priority: WarningPriority;
|
||||
}
|
||||
|
||||
export function getCompatibilityWarnings(): StartupWarning[] {
|
||||
const warnings: StartupWarning[] = [];
|
||||
|
||||
if (isWindows10()) {
|
||||
warnings.push(
|
||||
'Warning: Windows 10 detected. Some UI features like smooth scrolling may be degraded. Windows 11 is recommended for the best experience.',
|
||||
);
|
||||
warnings.push({
|
||||
id: 'windows-10',
|
||||
message:
|
||||
'Warning: Windows 10 detected. Some UI features like smooth scrolling may be degraded. Windows 11 is recommended for the best experience.',
|
||||
priority: WarningPriority.High,
|
||||
});
|
||||
}
|
||||
|
||||
if (isJetBrainsTerminal()) {
|
||||
warnings.push(
|
||||
'Warning: JetBrains terminal detected. You may experience rendering or scrolling issues. Using an external terminal (e.g., Windows Terminal, iTerm2) is recommended.',
|
||||
);
|
||||
warnings.push({
|
||||
id: 'jetbrains-terminal',
|
||||
message:
|
||||
'Warning: JetBrains terminal detected. You may experience rendering or scrolling issues. Using an external terminal (e.g., Windows Terminal, iTerm2) is recommended.',
|
||||
priority: WarningPriority.High,
|
||||
});
|
||||
}
|
||||
|
||||
if (!supportsTrueColor()) {
|
||||
warnings.push(
|
||||
'Warning: True color (24-bit) support not detected. Using a terminal with true color enabled will result in a better visual experience.',
|
||||
);
|
||||
if (!supports256Colors()) {
|
||||
warnings.push({
|
||||
id: '256-color',
|
||||
message:
|
||||
'Warning: 256-color support not detected. Using a terminal with at least 256-color support is recommended for a better visual experience.',
|
||||
priority: WarningPriority.High,
|
||||
});
|
||||
} else if (!supportsTrueColor() && !isAppleTerminal()) {
|
||||
warnings.push({
|
||||
id: 'true-color',
|
||||
message:
|
||||
'Warning: True color (24-bit) support not detected. Using a terminal with true color enabled will result in a better visual experience.',
|
||||
priority: WarningPriority.Low,
|
||||
});
|
||||
}
|
||||
|
||||
return warnings;
|
||||
|
||||
Reference in New Issue
Block a user