mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-13 07:30:52 -07:00
feat: Detect background color (#15132)
This commit is contained in:
@@ -143,3 +143,96 @@ describe('ThemeDialog Snapshots', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Initial Theme Selection', () => {
|
||||
const baseProps = {
|
||||
onSelect: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
onHighlight: vi.fn(),
|
||||
availableTerminalHeight: 40,
|
||||
terminalWidth: 120,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should default to a light theme when terminal background is light and no theme is set', () => {
|
||||
const settings = createMockSettings(); // No theme set
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ThemeDialog {...baseProps} settings={settings} />,
|
||||
{
|
||||
settings,
|
||||
uiState: { terminalBackgroundColor: '#FFFFFF' }, // Light background
|
||||
},
|
||||
);
|
||||
|
||||
// The snapshot will show which theme is highlighted.
|
||||
// We expect 'DefaultLight' to be the one with the '>' indicator.
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should default to a dark theme when terminal background is dark and no theme is set', () => {
|
||||
const settings = createMockSettings(); // No theme set
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ThemeDialog {...baseProps} settings={settings} />,
|
||||
{
|
||||
settings,
|
||||
uiState: { terminalBackgroundColor: '#000000' }, // Dark background
|
||||
},
|
||||
);
|
||||
|
||||
// We expect 'DefaultDark' to be highlighted.
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should use the theme from settings even if terminal background suggests a different theme type', () => {
|
||||
const settings = createMockSettings({ ui: { theme: 'DefaultLight' } }); // Light theme set
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ThemeDialog {...baseProps} settings={settings} />,
|
||||
{
|
||||
settings,
|
||||
uiState: { terminalBackgroundColor: '#000000' }, // Dark background
|
||||
},
|
||||
);
|
||||
|
||||
// We expect 'DefaultLight' to be highlighted, respecting the settings.
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hint Visibility', () => {
|
||||
const baseProps = {
|
||||
onSelect: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
onHighlight: vi.fn(),
|
||||
availableTerminalHeight: 40,
|
||||
terminalWidth: 120,
|
||||
};
|
||||
|
||||
it('should show hint when theme background matches terminal background', () => {
|
||||
const settings = createMockSettings({ ui: { theme: 'Default' } });
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ThemeDialog {...baseProps} settings={settings} />,
|
||||
{
|
||||
settings,
|
||||
uiState: { terminalBackgroundColor: '#1E1E2E' },
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('(Matches terminal)');
|
||||
});
|
||||
|
||||
it('should not show hint when theme background does not match terminal background', () => {
|
||||
const settings = createMockSettings({ ui: { theme: 'Default' } });
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ThemeDialog {...baseProps} settings={settings} />,
|
||||
{
|
||||
settings,
|
||||
uiState: { terminalBackgroundColor: '#FFFFFF' },
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('(Matches terminal)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useCallback, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
|
||||
import { pickDefaultThemeName } from '../themes/theme.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { DiffRenderer } from './messages/DiffRenderer.js';
|
||||
import { colorizeCode } from '../utils/CodeColorizer.js';
|
||||
@@ -22,6 +23,7 @@ import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { ScopeSelector } from './shared/ScopeSelector.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
interface ThemeDialogProps {
|
||||
/** Callback function when a theme is selected */
|
||||
@@ -38,6 +40,42 @@ interface ThemeDialogProps {
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
import {
|
||||
getThemeTypeFromBackgroundColor,
|
||||
resolveColor,
|
||||
} from '../themes/color-utils.js';
|
||||
|
||||
function generateThemeItem(
|
||||
name: string,
|
||||
typeDisplay: string,
|
||||
themeType: string,
|
||||
themeBackground: string | undefined,
|
||||
terminalBackgroundColor: string | undefined,
|
||||
terminalThemeType: 'light' | 'dark' | undefined,
|
||||
) {
|
||||
const isCompatible =
|
||||
themeType === 'custom' ||
|
||||
terminalThemeType === undefined ||
|
||||
themeType === 'ansi' ||
|
||||
themeType === terminalThemeType;
|
||||
|
||||
const isBackgroundMatch =
|
||||
terminalBackgroundColor &&
|
||||
themeBackground &&
|
||||
terminalBackgroundColor.toLowerCase() === themeBackground.toLowerCase();
|
||||
|
||||
return {
|
||||
label: name,
|
||||
value: name,
|
||||
themeNameDisplay: name,
|
||||
themeTypeDisplay: typeDisplay,
|
||||
themeWarning: isCompatible ? '' : ' (Incompatible)',
|
||||
themeMatch: isBackgroundMatch ? ' (Matches terminal)' : '',
|
||||
key: name,
|
||||
isCompatible,
|
||||
};
|
||||
}
|
||||
|
||||
export function ThemeDialog({
|
||||
onSelect,
|
||||
onCancel,
|
||||
@@ -48,13 +86,27 @@ export function ThemeDialog({
|
||||
}: ThemeDialogProps): React.JSX.Element {
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const { refreshStatic } = useUIActions();
|
||||
const { terminalBackgroundColor } = useUIState();
|
||||
const [selectedScope, setSelectedScope] = useState<LoadableSettingScope>(
|
||||
SettingScope.User,
|
||||
);
|
||||
|
||||
// Track the currently highlighted theme name
|
||||
const [highlightedThemeName, setHighlightedThemeName] = useState<string>(
|
||||
settings.merged.ui?.theme || DEFAULT_THEME.name,
|
||||
() => {
|
||||
// If a theme is already set, use it.
|
||||
if (settings.merged.ui?.theme) {
|
||||
return settings.merged.ui.theme;
|
||||
}
|
||||
|
||||
// Otherwise, try to pick a theme that matches the terminal background.
|
||||
return pickDefaultThemeName(
|
||||
terminalBackgroundColor,
|
||||
themeManager.getAllThemes(),
|
||||
DEFAULT_THEME.name,
|
||||
'Default Light',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Generate theme items filtered by selected scope
|
||||
@@ -67,23 +119,49 @@ export function ThemeDialog({
|
||||
.filter((theme) => theme.type !== 'custom');
|
||||
const customThemeNames = Object.keys(customThemes);
|
||||
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
const terminalThemeType = getThemeTypeFromBackgroundColor(
|
||||
terminalBackgroundColor,
|
||||
);
|
||||
|
||||
// Generate theme items
|
||||
const themeItems = [
|
||||
...builtInThemes.map((theme) => ({
|
||||
label: theme.name,
|
||||
value: theme.name,
|
||||
themeNameDisplay: theme.name,
|
||||
themeTypeDisplay: capitalize(theme.type),
|
||||
key: theme.name,
|
||||
})),
|
||||
...customThemeNames.map((name) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
themeNameDisplay: name,
|
||||
themeTypeDisplay: 'Custom',
|
||||
key: name,
|
||||
})),
|
||||
];
|
||||
...builtInThemes.map((theme) => {
|
||||
const fullTheme = themeManager.getTheme(theme.name);
|
||||
const themeBackground = fullTheme
|
||||
? resolveColor(fullTheme.colors.Background)
|
||||
: undefined;
|
||||
|
||||
return generateThemeItem(
|
||||
theme.name,
|
||||
capitalize(theme.type),
|
||||
theme.type,
|
||||
themeBackground,
|
||||
terminalBackgroundColor,
|
||||
terminalThemeType,
|
||||
);
|
||||
}),
|
||||
...customThemeNames.map((name) => {
|
||||
const themeConfig = customThemes[name];
|
||||
const bg = themeConfig.background?.primary ?? themeConfig.Background;
|
||||
const themeBackground = bg ? resolveColor(bg) : undefined;
|
||||
|
||||
return generateThemeItem(
|
||||
name,
|
||||
'Custom',
|
||||
'custom',
|
||||
themeBackground,
|
||||
terminalBackgroundColor,
|
||||
terminalThemeType,
|
||||
);
|
||||
}),
|
||||
].sort((a, b) => {
|
||||
// Show compatible themes first
|
||||
if (a.isCompatible && !b.isCompatible) return -1;
|
||||
if (!a.isCompatible && b.isCompatible) return 1;
|
||||
// Then sort by name
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
// Find the index of the selected theme, but only if it exists in the list
|
||||
const initialThemeIndex = themeItems.findIndex(
|
||||
@@ -225,6 +303,40 @@ export function ThemeDialog({
|
||||
maxItemsToShow={12}
|
||||
showScrollArrows={true}
|
||||
showNumbers={mode === 'theme'}
|
||||
renderItem={(item, { titleColor }) => {
|
||||
// We know item has themeWarning because we put it there, but we need to cast or access safely
|
||||
const itemWithExtras = item as typeof item & {
|
||||
themeWarning?: string;
|
||||
themeMatch?: string;
|
||||
};
|
||||
|
||||
if (item.themeNameDisplay && item.themeTypeDisplay) {
|
||||
return (
|
||||
<Text color={titleColor} wrap="truncate" key={item.key}>
|
||||
{item.themeNameDisplay}{' '}
|
||||
<Text color={theme.text.secondary}>
|
||||
{item.themeTypeDisplay}
|
||||
</Text>
|
||||
{itemWithExtras.themeMatch && (
|
||||
<Text color={theme.status.success}>
|
||||
{itemWithExtras.themeMatch}
|
||||
</Text>
|
||||
)}
|
||||
{itemWithExtras.themeWarning && (
|
||||
<Text color={theme.status.warning}>
|
||||
{itemWithExtras.themeWarning}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
// Regular label display
|
||||
return (
|
||||
<Text color={titleColor} wrap="truncate">
|
||||
{item.label}
|
||||
</Text>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -239,6 +351,7 @@ export function ThemeDialog({
|
||||
themeManager.getTheme(
|
||||
highlightedThemeName || DEFAULT_THEME.name,
|
||||
) || DEFAULT_THEME;
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="single"
|
||||
|
||||
@@ -1,5 +1,77 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Initial Theme Selection > should default to a dark theme when terminal background is dark and no theme is set 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Select Theme Preview │
|
||||
│ ▲ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ 1. ANSI Dark │ │ │
|
||||
│ 2. Atom One Dark │ 1 # function │ │
|
||||
│ 3. Ayu Dark │ 2 def fibonacci(n): │ │
|
||||
│ ● 4. Default Dark │ 3 a, b = 0, 1 │ │
|
||||
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
|
||||
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
|
||||
│ 7. Holiday Dark │ 6 return a │ │
|
||||
│ 8. Shades Of Purple Dark │ │ │
|
||||
│ 9. ANSI Light Light (Incompatible) │ 1 - print("Hello, " + name) │ │
|
||||
│ 10. Ayu Light Light (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. Default Light Light (Incompatible) │ │ │
|
||||
│ 12. GitHub Light Light (Incompatible) └────────────────────────────────────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`Initial Theme Selection > should default to a light theme when terminal background is light and no theme is set 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Select Theme Preview │
|
||||
│ ▲ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ 1. ANSI Light Light │ │ │
|
||||
│ 2. Ayu Light Light │ 1 # function │ │
|
||||
│ ● 3. Default Light Light │ 2 def fibonacci(n): │ │
|
||||
│ 4. GitHub Light Light │ 3 a, b = 0, 1 │ │
|
||||
│ 5. Google Code Light │ 4 for _ in range(n): │ │
|
||||
│ 6. Xcode Light │ 5 a, b = b, a + b │ │
|
||||
│ 7. ANSI Dark (Incompatible) │ 6 return a │ │
|
||||
│ 8. Atom One Dark (Incompatible) │ │ │
|
||||
│ 9. Ayu Dark (Incompatible) │ 1 - print("Hello, " + name) │ │
|
||||
│ 10. Default Dark (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. Dracula Dark (Incompatible) │ │ │
|
||||
│ 12. GitHub Dark (Incompatible) └────────────────────────────────────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`Initial Theme Selection > should use the theme from settings even if terminal background suggests a different theme type 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Select Theme Preview │
|
||||
│ ▲ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ ● 1. ANSI Dark │ │ │
|
||||
│ 2. Atom One Dark │ 1 # function │ │
|
||||
│ 3. Ayu Dark │ 2 def fibonacci(n): │ │
|
||||
│ 4. Default Dark │ 3 a, b = 0, 1 │ │
|
||||
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
|
||||
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
|
||||
│ 7. Holiday Dark │ 6 return a │ │
|
||||
│ 8. Shades Of Purple Dark │ │ │
|
||||
│ 9. ANSI Light Light (Incompatible) │ 1 - print("Hello, " + name) │ │
|
||||
│ 10. Ayu Light Light (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. Default Light Light (Incompatible) │ │ │
|
||||
│ 12. GitHub Light Light (Incompatible) └────────────────────────────────────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`ThemeDialog Snapshots > should render correctly in scope selector mode 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
@@ -19,17 +91,17 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
|
||||
│ > Select Theme Preview │
|
||||
│ ▲ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ 1. ANSI Dark │ │ │
|
||||
│ 2. Atom One Dark │ 1 # function │ │
|
||||
│ 3. Ayu Dark │ 2 def fibonacci(n): │ │
|
||||
│ ● 4. Default Dark │ 3 a, b = 0, 1 │ │
|
||||
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
|
||||
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
|
||||
│ 7. Holiday Dark │ 6 return a │ │
|
||||
│ 8. Shades Of Purple Dark │ │ │
|
||||
│ 9. ANSI Light Light │ 1 - print("Hello, " + name) │ │
|
||||
│ 10. Ayu Light Light │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. Default Light Light │ │ │
|
||||
│ 12. GitHub Light Light └────────────────────────────────────────────────────────────┘ │
|
||||
│ 2. ANSI Light Light │ 1 # function │ │
|
||||
│ 3. Atom One Dark │ 2 def fibonacci(n): │ │
|
||||
│ 4. Ayu Dark │ 3 a, b = 0, 1 │ │
|
||||
│ 5. Ayu Light Light │ 4 for _ in range(n): │ │
|
||||
│ ● 6. Default Dark │ 5 a, b = b, a + b │ │
|
||||
│ 7. Default Light Light │ 6 return a │ │
|
||||
│ 8. Dracula Dark │ │ │
|
||||
│ 9. GitHub Dark │ 1 - print("Hello, " + name) │ │
|
||||
│ 10. GitHub Light Light │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. Google Code Light │ │ │
|
||||
│ 12. Holiday Dark └────────────────────────────────────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope, Esc to close) │
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
import type React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { BaseSelectionList } from './BaseSelectionList.js';
|
||||
import {
|
||||
BaseSelectionList,
|
||||
type RenderItemContext,
|
||||
} from './BaseSelectionList.js';
|
||||
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
|
||||
|
||||
/**
|
||||
@@ -41,6 +44,11 @@ export interface RadioButtonSelectProps<T> {
|
||||
maxItemsToShow?: number;
|
||||
/** Whether to show numbers next to items. */
|
||||
showNumbers?: boolean;
|
||||
/** Optional custom renderer for items. */
|
||||
renderItem?: (
|
||||
item: RadioSelectItem<T>,
|
||||
context: RenderItemContext,
|
||||
) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +66,7 @@ export function RadioButtonSelect<T>({
|
||||
showScrollArrows = false,
|
||||
maxItemsToShow = 10,
|
||||
showNumbers = true,
|
||||
renderItem,
|
||||
}: RadioButtonSelectProps<T>): React.JSX.Element {
|
||||
return (
|
||||
<BaseSelectionList<T, RadioSelectItem<T>>
|
||||
@@ -69,23 +78,28 @@ export function RadioButtonSelect<T>({
|
||||
showNumbers={showNumbers}
|
||||
showScrollArrows={showScrollArrows}
|
||||
maxItemsToShow={maxItemsToShow}
|
||||
renderItem={(item, { titleColor }) => {
|
||||
// Handle special theme display case for ThemeDialog compatibility
|
||||
if (item.themeNameDisplay && item.themeTypeDisplay) {
|
||||
renderItem={
|
||||
renderItem ||
|
||||
((item, { titleColor }) => {
|
||||
// Handle special theme display case for ThemeDialog compatibility
|
||||
if (item.themeNameDisplay && item.themeTypeDisplay) {
|
||||
return (
|
||||
<Text color={titleColor} wrap="truncate" key={item.key}>
|
||||
{item.themeNameDisplay}{' '}
|
||||
<Text color={theme.text.secondary}>
|
||||
{item.themeTypeDisplay}
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
// Regular label display
|
||||
return (
|
||||
<Text color={titleColor} wrap="truncate" key={item.key}>
|
||||
{item.themeNameDisplay}{' '}
|
||||
<Text color={theme.text.secondary}>{item.themeTypeDisplay}</Text>
|
||||
<Text color={titleColor} wrap="truncate">
|
||||
{item.label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
// Regular label display
|
||||
return (
|
||||
<Text color={titleColor} wrap="truncate">
|
||||
{item.label}
|
||||
</Text>
|
||||
);
|
||||
}}
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import { parsePastedPaths } from '../../utils/clipboardUtils.js';
|
||||
import type { Key } from '../../contexts/KeypressContext.js';
|
||||
import type { VimAction } from './vim-buffer-actions.js';
|
||||
import { handleVimAction } from './vim-buffer-actions.js';
|
||||
import { enableSupportedProtocol } from '../../utils/kittyProtocolDetector.js';
|
||||
|
||||
export type Direction =
|
||||
| 'left'
|
||||
@@ -1914,7 +1913,6 @@ export function useTextBuffer({
|
||||
} catch (err) {
|
||||
console.error('[useTextBuffer] external editor error', err);
|
||||
} finally {
|
||||
enableSupportedProtocol();
|
||||
coreEvents.emit(CoreEvent.ExternalEditorClosed);
|
||||
if (wasRaw) setRawMode?.(true);
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user