From d6f315a9dab8589d9065f0c3bc61757c4da046e1 Mon Sep 17 00:00:00 2001 From: Miguel Solorio Date: Fri, 5 Sep 2025 10:37:47 -0700 Subject: [PATCH 1/5] Add scope dialog --- .../cli/src/ui/components/ThemeDialog.tsx | 93 ++++++------------- .../ui/components/shared/ScopeSelector.tsx | 44 +++++++++ 2 files changed, 74 insertions(+), 63 deletions(-) create mode 100644 packages/cli/src/ui/components/shared/ScopeSelector.tsx diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index f4729f624f4..75271e1f57b 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,7 +71,6 @@ 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; @@ -83,8 +80,6 @@ export function ThemeDialog({ // 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 +94,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 +143,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 +158,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; @@ -220,37 +195,29 @@ export function ThemeDialog({ {/* Left Column: Selection */} - - {currentFocusedSection === 'theme' ? '> ' : ' '}Select Theme{' '} - {otherScopeModifiedMessage} - - - - {/* Scope Selection */} - {showScopeSelection && ( - - - {currentFocusedSection === 'scope' ? '> ' : ' '}Apply To + {mode === 'theme' ? ( + <> + + {mode === 'theme' ? '> ' : ' '}Select Theme{' '} + {otherScopeModifiedMessage} - + + ) : ( + )} @@ -288,7 +255,7 @@ def fibonacci(n): - (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/shared/ScopeSelector.tsx b/packages/cli/src/ui/components/shared/ScopeSelector.tsx new file mode 100644 index 00000000000..542ff93792f --- /dev/null +++ b/packages/cli/src/ui/components/shared/ScopeSelector.tsx @@ -0,0 +1,44 @@ +/** + * @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; +} + +export function ScopeSelector({ + onSelect, + onHighlight, + isFocused, +}: ScopeSelectorProps): React.JSX.Element { + const scopeItems = getScopeItems(); + + return ( + + + {isFocused ? '> ' : ' '}Apply To + + + + ); +} From e645b5c0db88e69a17b35d60f4dc960dad5cda35 Mon Sep 17 00:00:00 2001 From: Miguel Solorio Date: Fri, 5 Sep 2025 10:48:30 -0700 Subject: [PATCH 2/5] feat(ui): Refactor theme dialog scope selector into secondary view --- .../cli/src/ui/components/ThemeDialog.tsx | 126 +++++++++--------- 1 file changed, 62 insertions(+), 64 deletions(-) diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index 75271e1f57b..7595fb6536e 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -192,82 +192,80 @@ export function ThemeDialog({ paddingRight={1} width="100%" > - - {/* Left Column: Selection */} - - {mode === 'theme' ? ( - <> - - {mode === 'theme' ? '> ' : ' '}Select Theme{' '} - {otherScopeModifiedMessage} - - - - ) : ( - + {/* Left Column: Selection */} + + + {mode === 'theme' ? '> ' : ' '}Select Theme{' '} + {otherScopeModifiedMessage} + + - )} - + - {/* 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 {mode === 'theme' ? 'select' : 'apply scope'}, Tab to{' '} From f80ea49b450a575be88555fc5b91972f5b1ee8c3 Mon Sep 17 00:00:00 2001 From: Miguel Solorio Date: Fri, 5 Sep 2025 14:23:14 -0700 Subject: [PATCH 3/5] Adjust num of items shown in theme dialog --- packages/cli/src/ui/components/ThemeDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index 7595fb6536e..fa718e9dce2 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -206,7 +206,7 @@ export function ThemeDialog({ onSelect={handleThemeSelect} onHighlight={handleThemeHighlight} isFocused={mode === 'theme'} - maxItemsToShow={8} + maxItemsToShow={12} showScrollArrows={true} showNumbers={mode === 'theme'} /> From 387a8878a34a5fd6a54896a46883d6e03799e392 Mon Sep 17 00:00:00 2001 From: Miguel Solorio Date: Fri, 5 Sep 2025 14:47:46 -0700 Subject: [PATCH 4/5] feat(ui): preserve selection in theme and scope dialogs This commit enhances the user experience in the theme selection dialog by preserving the user's highlighted choice when they tab between the theme list and the scope selector. Specifically, the following changes were made: - The `ThemeDialog` now initializes the theme list with the last highlighted theme, preventing the selection from resetting to the saved theme. - The `ScopeSelector` now accepts an `initialScope` prop, allowing it to be initialized with the user's previous selection. - The `ThemeDialog` passes the currently selected scope to the `ScopeSelector` to maintain its state across tabs. --- packages/cli/src/ui/components/ThemeDialog.tsx | 4 ++-- .../cli/src/ui/components/shared/ScopeSelector.tsx | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index fa718e9dce2..21fc78161d9 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -73,9 +73,8 @@ export function ThemeDialog({ ]; // 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; @@ -264,6 +263,7 @@ def fibonacci(n): onSelect={handleScopeSelect} onHighlight={handleScopeHighlight} isFocused={mode === 'scope'} + initialScope={selectedScope} /> )} diff --git a/packages/cli/src/ui/components/shared/ScopeSelector.tsx b/packages/cli/src/ui/components/shared/ScopeSelector.tsx index 542ff93792f..8066d8c9ee0 100644 --- a/packages/cli/src/ui/components/shared/ScopeSelector.tsx +++ b/packages/cli/src/ui/components/shared/ScopeSelector.tsx @@ -17,15 +17,23 @@ interface ScopeSelectorProps { 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 ( @@ -33,7 +41,7 @@ export function ScopeSelector({ Date: Mon, 8 Sep 2025 10:30:50 -0700 Subject: [PATCH 5/5] feat(ui): add snapshot test for ThemeDialog --- .../src/ui/components/ThemeDialog.test.tsx | 98 +++++++++++++++++++ .../__snapshots__/ThemeDialog.test.tsx.snap | 38 +++++++ 2 files changed, 136 insertions(+) create mode 100644 packages/cli/src/ui/components/ThemeDialog.test.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap 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 00000000000..f2899e94a26 --- /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/__snapshots__/ThemeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap new file mode 100644 index 00000000000..b205bba4a5b --- /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) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`;