feat(ui): improve startup warnings UX with dismissal and show-count limits (#19584)

This commit is contained in:
Spencer
2026-02-20 13:22:45 -05:00
committed by GitHub
parent d24f10b087
commit fe428936d5
12 changed files with 503 additions and 109 deletions
+6 -2
View File
@@ -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 -3
View File
@@ -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)),
];
+15 -2
View File
@@ -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 () => {
+2 -1
View File
@@ -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>
))}
+2 -1
View File
@@ -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);
});
});
});
+22 -4
View File
@@ -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());
+77 -11
View File
@@ -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', () => {
+64 -12
View File
@@ -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;