diff --git a/packages/cli/src/ui/components/ThemeDialog.test.tsx b/packages/cli/src/ui/components/ThemeDialog.test.tsx
new file mode 100644
index 0000000000..f2899e94a2
--- /dev/null
+++ b/packages/cli/src/ui/components/ThemeDialog.test.tsx
@@ -0,0 +1,98 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from 'ink-testing-library';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { ThemeDialog } from './ThemeDialog.js';
+import { LoadedSettings } from '../../config/settings.js';
+import { KeypressProvider } from '../contexts/KeypressContext.js';
+import { SettingsContext } from '../contexts/SettingsContext.js';
+import { DEFAULT_THEME, themeManager } from '../themes/theme-manager.js';
+import { act } from 'react';
+
+const createMockSettings = (
+ userSettings = {},
+ workspaceSettings = {},
+ systemSettings = {},
+): LoadedSettings =>
+ new LoadedSettings(
+ {
+ settings: { ui: { customThemes: {} }, ...systemSettings },
+ path: '/system/settings.json',
+ },
+ {
+ settings: {},
+ path: '/system/system-defaults.json',
+ },
+ {
+ settings: {
+ ui: { customThemes: {} },
+ ...userSettings,
+ },
+ path: '/user/settings.json',
+ },
+ {
+ settings: {
+ ui: { customThemes: {} },
+ ...workspaceSettings,
+ },
+ path: '/workspace/settings.json',
+ },
+ true,
+ new Set(),
+ );
+
+describe('ThemeDialog Snapshots', () => {
+ const baseProps = {
+ onSelect: vi.fn(),
+ onHighlight: vi.fn(),
+ availableTerminalHeight: 40,
+ terminalWidth: 120,
+ };
+
+ beforeEach(() => {
+ // Reset theme manager to a known state
+ themeManager.setActiveTheme(DEFAULT_THEME.name);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should render correctly in theme selection mode', () => {
+ const settings = createMockSettings();
+ const { lastFrame } = render(
+
+
+
+
+ ,
+ );
+
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('should render correctly in scope selector mode', async () => {
+ const settings = createMockSettings();
+ const { lastFrame, stdin } = render(
+
+
+
+
+ ,
+ );
+
+ // Press Tab to switch to scope selector mode
+ act(() => {
+ stdin.write('\t');
+ });
+
+ // Need to wait for the state update to propagate
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ expect(lastFrame()).toMatchSnapshot();
+ });
+});
diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx
index f4729f624f..21fc78161d 100644
--- a/packages/cli/src/ui/components/ThemeDialog.tsx
+++ b/packages/cli/src/ui/components/ThemeDialog.tsx
@@ -14,11 +14,9 @@ import { DiffRenderer } from './messages/DiffRenderer.js';
import { colorizeCode } from '../utils/CodeColorizer.js';
import type { LoadedSettings } from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
-import {
- getScopeItems,
- getScopeMessageForSetting,
-} from '../../utils/dialogScopeUtils.js';
+import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
import { useKeypress } from '../hooks/useKeypress.js';
+import { ScopeSelector } from './shared/ScopeSelector.js';
interface ThemeDialogProps {
/** Callback function when a theme is selected */
@@ -73,18 +71,14 @@ export function ThemeDialog({
themeTypeDisplay: 'Custom',
})),
];
- const [selectInputKey, setSelectInputKey] = useState(Date.now());
// Find the index of the selected theme, but only if it exists in the list
- const selectedThemeName = settings.merged.ui?.theme || DEFAULT_THEME.name;
const initialThemeIndex = themeItems.findIndex(
- (item) => item.value === selectedThemeName,
+ (item) => item.value === highlightedThemeName,
);
// If not found, fall back to the first theme
const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0;
- const scopeItems = getScopeItems();
-
const handleThemeSelect = useCallback(
(themeName: string) => {
onSelect(themeName, selectedScope);
@@ -99,25 +93,21 @@ export function ThemeDialog({
const handleScopeHighlight = useCallback((scope: SettingScope) => {
setSelectedScope(scope);
- setSelectInputKey(Date.now());
}, []);
const handleScopeSelect = useCallback(
(scope: SettingScope) => {
- handleScopeHighlight(scope);
- setFocusedSection('theme'); // Reset focus to theme section
+ onSelect(highlightedThemeName, scope);
},
- [handleScopeHighlight],
+ [onSelect, highlightedThemeName],
);
- const [focusedSection, setFocusedSection] = useState<'theme' | 'scope'>(
- 'theme',
- );
+ const [mode, setMode] = useState<'theme' | 'scope'>('theme');
useKeypress(
(key) => {
if (key.name === 'tab') {
- setFocusedSection((prev) => (prev === 'theme' ? 'scope' : 'theme'));
+ setMode((prev) => (prev === 'theme' ? 'scope' : 'theme'));
}
if (key.name === 'escape') {
onSelect(undefined, selectedScope);
@@ -152,20 +142,13 @@ export function ThemeDialog({
const DIALOG_PADDING = 2;
const selectThemeHeight = themeItems.length + 1;
- const SCOPE_SELECTION_HEIGHT = 4; // Height for the scope selection section + margin.
- const SPACE_BETWEEN_THEME_SELECTION_AND_APPLY_TO = 1;
const TAB_TO_SELECT_HEIGHT = 2;
availableTerminalHeight = availableTerminalHeight ?? Number.MAX_SAFE_INTEGER;
availableTerminalHeight -= 2; // Top and bottom borders.
availableTerminalHeight -= TAB_TO_SELECT_HEIGHT;
- let totalLeftHandSideHeight =
- DIALOG_PADDING +
- selectThemeHeight +
- SCOPE_SELECTION_HEIGHT +
- SPACE_BETWEEN_THEME_SELECTION_AND_APPLY_TO;
+ let totalLeftHandSideHeight = DIALOG_PADDING + selectThemeHeight;
- let showScopeSelection = true;
let includePadding = true;
// Remove content from the LHS that can be omitted if it exceeds the available height.
@@ -174,15 +157,6 @@ export function ThemeDialog({
totalLeftHandSideHeight -= DIALOG_PADDING;
}
- if (totalLeftHandSideHeight > availableTerminalHeight) {
- // First, try hiding the scope selection
- totalLeftHandSideHeight -= SCOPE_SELECTION_HEIGHT;
- showScopeSelection = false;
- }
-
- // Don't focus the scope selection if it is hidden due to height constraints.
- const currentFocusedSection = !showScopeSelection ? 'theme' : focusedSection;
-
// Vertical space taken by elements other than the two code blocks in the preview pane.
// Includes "Preview" title, borders, and margin between blocks.
const PREVIEW_PANE_FIXED_VERTICAL_SPACE = 8;
@@ -217,94 +191,85 @@ export function ThemeDialog({
paddingRight={1}
width="100%"
>
-
- {/* Left Column: Selection */}
-
-
- {currentFocusedSection === 'theme' ? '> ' : ' '}Select Theme{' '}
- {otherScopeModifiedMessage}
-
-
+ {mode === 'theme' ? (
+
+ {/* Left Column: Selection */}
+
+
+ {mode === 'theme' ? '> ' : ' '}Select Theme{' '}
+ {otherScopeModifiedMessage}
+
+
+
- {/* Scope Selection */}
- {showScopeSelection && (
-
-
- {currentFocusedSection === 'scope' ? '> ' : ' '}Apply To
-
-
-
- )}
-
-
- {/* Right Column: Preview */}
-
- Preview
- {/* Get the Theme object for the highlighted theme, fall back to default if not found */}
- {(() => {
- const previewTheme =
- themeManager.getTheme(
- highlightedThemeName || DEFAULT_THEME.name,
- ) || DEFAULT_THEME;
- return (
-
- {colorizeCode(
- `# function
+ {/* Right Column: Preview */}
+
+ Preview
+ {/* Get the Theme object for the highlighted theme, fall back to default if not found */}
+ {(() => {
+ const previewTheme =
+ themeManager.getTheme(
+ highlightedThemeName || DEFAULT_THEME.name,
+ ) || DEFAULT_THEME;
+ return (
+
+ {colorizeCode(
+ `# function
def fibonacci(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a`,
- 'python',
- codeBlockHeight,
- colorizeCodeWidth,
- )}
-
-
+
-
- );
- })()}
+ availableTerminalHeight={diffHeight}
+ terminalWidth={colorizeCodeWidth}
+ theme={previewTheme}
+ />
+
+ );
+ })()}
+
-
+ ) : (
+
+ )}
- (Use Enter to select
- {showScopeSelection ? ', Tab to change focus' : ''})
+ (Use Enter to {mode === 'theme' ? 'select' : 'apply scope'}, Tab to{' '}
+ {mode === 'theme' ? 'configure scope' : 'select theme'})
diff --git a/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap
new file mode 100644
index 0000000000..b205bba4a5
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap
@@ -0,0 +1,38 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`ThemeDialog Snapshots > should render correctly in scope selector mode 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Apply To │
+│ ● 1. User Settings │
+│ 2. Workspace Settings │
+│ 3. System Settings │
+│ │
+│ (Use Enter to apply scope, Tab to select theme) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`ThemeDialog Snapshots > should render correctly in theme selection mode 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. Shades Of Purple Dark │ 6 return a │ │
+│ 8. ANSI Light Light │ │ │
+│ 9. Ayu Light Light │ 1 - print("Hello, " + name) │ │
+│ 10. Default Light Light │ 1 + print(f"Hello, {name}!") │ │
+│ 11. GitHub Light Light │ │ │
+│ 12. Google Code Light └─────────────────────────────────────────────────┘ │
+│ ▼ │
+│ │
+│ (Use Enter to select, Tab to configure scope) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
diff --git a/packages/cli/src/ui/components/shared/ScopeSelector.tsx b/packages/cli/src/ui/components/shared/ScopeSelector.tsx
new file mode 100644
index 0000000000..8066d8c9ee
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/ScopeSelector.tsx
@@ -0,0 +1,52 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Box, Text } from 'ink';
+import type { SettingScope } from '../../../config/settings.js';
+import { getScopeItems } from '../../../utils/dialogScopeUtils.js';
+import { RadioButtonSelect } from './RadioButtonSelect.js';
+
+interface ScopeSelectorProps {
+ /** Callback function when a scope is selected */
+ onSelect: (scope: SettingScope) => void;
+ /** Callback function when a scope is highlighted */
+ onHighlight: (scope: SettingScope) => void;
+ /** Whether the component is focused */
+ isFocused: boolean;
+ /** The initial scope to select */
+ initialScope: SettingScope;
+}
+
+export function ScopeSelector({
+ onSelect,
+ onHighlight,
+ isFocused,
+ initialScope,
+}: ScopeSelectorProps): React.JSX.Element {
+ const scopeItems = getScopeItems();
+
+ const initialIndex = scopeItems.findIndex(
+ (item) => item.value === initialScope,
+ );
+ const safeInitialIndex = initialIndex >= 0 ? initialIndex : 0;
+
+ return (
+
+
+ {isFocused ? '> ' : ' '}Apply To
+
+
+
+ );
+}