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