feat(cli): support independent light and dark mode themes

This commit is contained in:
Dan Zaharia
2026-02-26 16:50:26 -05:00
parent 717660997d
commit 98a0a2f0ef
25 changed files with 548 additions and 330 deletions
+144 -91
View File
@@ -9,7 +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, type Theme } from '../themes/theme.js';
import { type Theme } from '../themes/theme.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { DiffRenderer } from './messages/DiffRenderer.js';
import { colorizeCode } from '../utils/CodeColorizer.js';
@@ -44,7 +44,10 @@ interface ThemeDialogProps {
terminalWidth: number;
}
import { resolveColor } from '../themes/color-utils.js';
import { resolveColor , getThemeTypeFromBackgroundColor } from '../themes/color-utils.js';
import { DefaultLight } from '../themes/default-light.js';
import { DefaultDark } from '../themes/default.js';
function generateThemeItem(
name: string,
@@ -52,10 +55,6 @@ function generateThemeItem(
fullTheme: Theme | undefined,
terminalBackgroundColor: string | undefined,
) {
const isCompatible = fullTheme
? themeManager.isThemeCompatible(fullTheme, terminalBackgroundColor)
: true;
const themeBackground = fullTheme
? resolveColor(fullTheme.colors.Background)
: undefined;
@@ -70,10 +69,9 @@ function generateThemeItem(
value: name,
themeNameDisplay: name,
themeTypeDisplay: typeDisplay,
themeWarning: isCompatible ? '' : ' (Incompatible)',
themeMatch: isBackgroundMatch ? ' (Matches terminal)' : '',
key: name,
isCompatible,
type: fullTheme?.type,
};
}
@@ -91,50 +89,44 @@ export function ThemeDialog({
SettingScope.User,
);
// Track the currently highlighted theme name
const [highlightedThemeName, setHighlightedThemeName] = useState<string>(
() => {
// If a theme is already set, use it.
if (settings.merged.ui.theme) {
return settings.merged.ui.theme;
}
const initialTab =
terminalBackgroundColor &&
getThemeTypeFromBackgroundColor(terminalBackgroundColor) === 'dark'
? 'dark'
: 'light';
const [activeTab, setActiveTab] = useState<'light' | 'dark'>(initialTab);
// Otherwise, try to pick a theme that matches the terminal background.
return pickDefaultThemeName(
terminalBackgroundColor,
themeManager.getAllThemes(),
DEFAULT_THEME.name,
'Default Light',
);
},
);
const [highlightedThemeNameLight, setHighlightedThemeNameLight] =
useState<string>(() => settings.merged.ui.themeLight || DefaultLight.name);
const [highlightedThemeNameDark, setHighlightedThemeNameDark] =
useState<string>(() => settings.merged.ui.themeDark || DefaultDark.name);
const highlightedThemeName =
activeTab === 'light'
? highlightedThemeNameLight
: highlightedThemeNameDark;
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
// Generate theme items
const themeItems = themeManager
.getAvailableThemes()
.map((theme) => {
const fullTheme = themeManager.getTheme(theme.name);
const capitalizedType = capitalize(theme.type);
const typeDisplay = theme.name.endsWith(capitalizedType)
? ''
: capitalizedType;
const allThemeItems = themeManager.getAvailableThemes().map((theme) => {
const fullTheme = themeManager.getTheme(theme.name);
const capitalizedType = capitalize(theme.type);
const typeDisplay = theme.name.endsWith(capitalizedType)
? ''
: capitalizedType;
return generateThemeItem(
theme.name,
typeDisplay,
fullTheme,
terminalBackgroundColor,
);
})
.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);
});
return generateThemeItem(
theme.name,
typeDisplay,
fullTheme,
terminalBackgroundColor,
);
});
const themeItems = allThemeItems
.filter((item) => item.type === activeTab || !item.type) // Filter by tab, allow untyped
.sort((a, b) => a.label.localeCompare(b.label));
// Find the index of the selected theme, but only if it exists in the list
const initialThemeIndex = themeItems.findIndex(
@@ -145,13 +137,18 @@ export function ThemeDialog({
const handleThemeSelect = useCallback(
async (themeName: string) => {
await onSelect(themeName, selectedScope);
// @ts-expect-error adding extra argument for the updated hook
await onSelect(themeName, selectedScope, activeTab);
},
[onSelect, selectedScope],
[onSelect, selectedScope, activeTab],
);
const handleThemeHighlight = (themeName: string) => {
setHighlightedThemeName(themeName);
if (activeTab === 'light') {
setHighlightedThemeNameLight(themeName);
} else {
setHighlightedThemeNameDark(themeName);
}
onHighlight(themeName);
};
@@ -161,9 +158,10 @@ export function ThemeDialog({
const handleScopeSelect = useCallback(
async (scope: LoadableSettingScope) => {
await onSelect(highlightedThemeName, scope);
// @ts-expect-error adding extra argument
await onSelect(highlightedThemeName, scope, activeTab);
},
[onSelect, highlightedThemeName],
[onSelect, highlightedThemeName, activeTab],
);
const [mode, setMode] = useState<'theme' | 'scope'>('theme');
@@ -178,6 +176,18 @@ export function ThemeDialog({
onCancel();
return true;
}
if (mode === 'theme') {
if (key.name === 'left' && activeTab === 'dark') {
setActiveTab('light');
onHighlight(highlightedThemeNameLight);
return true;
}
if (key.name === 'right' && activeTab === 'light') {
setActiveTab('dark');
onHighlight(highlightedThemeNameDark);
return true;
}
}
return false;
},
{ isActive: true },
@@ -185,7 +195,7 @@ export function ThemeDialog({
// Generate scope message for theme setting
const otherScopeModifiedMessage = getScopeMessageForSetting(
'ui.theme',
activeTab === 'light' ? 'ui.themeLight' : 'ui.themeDark',
selectedScope,
settings,
);
@@ -273,6 +283,38 @@ export function ThemeDialog({
{otherScopeModifiedMessage}
</Text>
</Text>
<Box
flexDirection="row"
paddingLeft={2}
paddingTop={1}
paddingBottom={1}
>
<Text
color={
activeTab === 'light'
? theme.text.primary
: theme.text.secondary
}
underline={activeTab === 'light'}
bold={activeTab === 'light'}
>
{' '}
Light{' '}
</Text>
<Text> | </Text>
<Text
color={
activeTab === 'dark'
? theme.text.primary
: theme.text.secondary
}
underline={activeTab === 'dark'}
bold={activeTab === 'dark'}
>
{' '}
Dark{' '}
</Text>
</Box>
<RadioButtonSelect
items={themeItems}
initialIndex={safeInitialThemeIndex}
@@ -283,9 +325,8 @@ export function ThemeDialog({
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
// We know item has themeMatch because we put it there, but we need to cast or access safely
const itemWithExtras = item as typeof item & {
themeWarning?: string;
themeMatch?: string;
};
@@ -312,11 +353,6 @@ export function ThemeDialog({
{itemWithExtras.themeMatch}
</Text>
)}
{itemWithExtras.themeWarning && (
<Text color={theme.status.warning}>
{itemWithExtras.themeWarning}
</Text>
)}
</Text>
);
}
@@ -335,48 +371,65 @@ export function ThemeDialog({
<Text bold color={theme.text.primary}>
Preview
</Text>
<Box
borderStyle="single"
borderColor={theme.border.default}
paddingTop={includePadding ? 1 : 0}
paddingBottom={includePadding ? 1 : 0}
paddingLeft={1}
paddingRight={1}
flexDirection="column"
>
{colorizeCode({
code: `# function
{/* 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;
const effectiveBackground =
activeTab === 'light' ? '#ffffff' : '#000000';
return (
<>
<Box
borderStyle="single"
borderColor={theme.border.default}
paddingTop={includePadding ? 1 : 0}
paddingBottom={includePadding ? 1 : 0}
paddingLeft={1}
paddingRight={1}
flexDirection="column"
backgroundColor={effectiveBackground}
>
{colorizeCode({
code: `# function
def fibonacci(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a`,
language: 'python',
availableHeight:
isAlternateBuffer === false ? codeBlockHeight : undefined,
maxWidth: colorizeCodeWidth,
settings,
})}
<Box marginTop={1} />
<DiffRenderer
diffContent={`--- a/util.py
language: 'python',
availableHeight:
isAlternateBuffer === false ? codeBlockHeight : undefined,
maxWidth: colorizeCodeWidth,
settings,
theme: previewTheme,
})}
<Box marginTop={1} />
<DiffRenderer
diffContent={`--- a/util.py
+++ b/util.py
@@ -1,2 +1,2 @@
- print("Hello, " + name)
+ print(f"Hello, {name}!")
`}
availableTerminalHeight={
isAlternateBuffer === false ? diffHeight : undefined
}
terminalWidth={colorizeCodeWidth}
theme={previewTheme}
/>
</Box>
{isDevelopment && (
<Box marginTop={1}>
<ColorsDisplay activeTheme={previewTheme} />
</Box>
)}
availableTerminalHeight={
isAlternateBuffer === false ? diffHeight : undefined
}
terminalWidth={colorizeCodeWidth}
theme={previewTheme}
/>
</Box>
{isDevelopment && (
<Box marginTop={1}>
<ColorsDisplay activeTheme={previewTheme} />
</Box>
)}
</>
);
})()}
</Box>
</Box>
) : (
@@ -4,20 +4,19 @@ exports[`Initial Theme Selection > should default to a dark theme when terminal
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > 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. Solarized Dark │ 1 - print("Hello, " + name) │ │
10. ANSI Light │ 1 + print(f"Hello, {name}!") │ │
11. Ayu Light │ │ │
12. Default Light └─────────────────────────────────────────────────┘ │
│ ▼ │
┌─────────────────────────────────────────────────┐ │
Light | Dark │ │ │
│ 1 # function │ │
1. ANSI Dark │ 2 def fibonacci(n): │ │
2. Atom One Dark │ 3 a, b = 0, 1 │ │
3. Ayu Dark │ 4 for _ in range(n): │ │
● 4. Default Dark │ 5 a, b = b, a + b │ │
5. Dracula Dark │ 6 return a │ │
6. GitHub Dark │ │ │
7. Holiday Dark │ 1 - print("Hello, " + name) │ │
8. Shades Of Purple Dark │ 1 + print(f"Hello, {name}!") │ │
9. Solarized Dark │ │ │
└─────────────────────────────────────────────────┘ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
│ │
@@ -29,20 +28,19 @@ exports[`Initial Theme Selection > should default to a light theme when terminal
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Select Theme Preview │
┌─────────────────────────────────────────────────┐ │
1. ANSI Light │ │ │
2. Ayu Light │ 1 # function │ │
3. Default Light │ 2 def fibonacci(n): │ │
4. GitHub Light │ 3 a, b = 0, 1 │ │
5. Google Code Light │ 4 for _ in range(n): │ │
6. Solarized Light │ 5 a, b = b, a + b │ │
7. Xcode Light │ 6 return a │ │
8. ANSI Dark (Incompatible) │ │ │
9. Atom One Dark (Incompatible) │ 1 - print("Hello, " + name) │ │
10. Ayu Dark (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
11. Default Dark (Incompatible) │ │ │
12. Dracula Dark (Incompatible) └─────────────────────────────────────────────────┘ │
│ ▼ │
┌─────────────────────────────────────────────────┐ │
Light | Dark │ │ │
│ 1 # function │ │
1. ANSI Light │ 2 def fibonacci(n): │ │
2. Ayu Light │ 3 a, b = 0, 1 │ │
● 3. Default Light │ 4 for _ in range(n): │ │
4. GitHub Light │ 5 a, b = b, a + b │ │
5. Google Code Light │ 6 return a │ │
6. Solarized Light │ │ │
7. Xcode Light │ 1 - print("Hello, " + name) │ │
│ 1 + print(f"Hello, {name}!") │ │
│ │ │
└─────────────────────────────────────────────────┘ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
│ │
@@ -54,20 +52,19 @@ exports[`Initial Theme Selection > should use the theme from settings even if te
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > 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. Solarized Dark │ 1 - print("Hello, " + name) │ │
10. ANSI Light │ 1 + print(f"Hello, {name}!") │ │
11. Ayu Light │ │ │
12. Default Light └─────────────────────────────────────────────────┘ │
│ ▼ │
┌─────────────────────────────────────────────────┐ │
Light | Dark │ │ │
│ 1 # function │ │
1. ANSI Dark │ 2 def fibonacci(n): │ │
2. Atom One Dark │ 3 a, b = 0, 1 │ │
3. Ayu Dark │ 4 for _ in range(n): │ │
● 4. Default Dark │ 5 a, b = b, a + b │ │
5. Dracula Dark │ 6 return a │ │
6. GitHub Dark │ │ │
7. Holiday Dark │ 1 - print("Hello, " + name) │ │
8. Shades Of Purple Dark │ 1 + print(f"Hello, {name}!") │ │
9. Solarized Dark │ │ │
└─────────────────────────────────────────────────┘ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
│ │
@@ -93,20 +90,19 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Select Theme Preview │
┌─────────────────────────────────────────────────┐ │
1. ANSI Dark (Matches terminal) │ │ │
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. Solarized Dark │ 1 - print("Hello, " + name) │ │
10. ANSI Light │ 1 + print(f"Hello, {name}!") │ │
11. Ayu Light │ │ │
12. Default Light └─────────────────────────────────────────────────┘ │
│ ▼ │
┌─────────────────────────────────────────────────┐ │
Light | Dark │ │ │
│ 1 # function │ │
1. ANSI Dark (Matches terminal) │ 2 def fibonacci(n): │ │
2. Atom One Dark │ 3 a, b = 0, 1 │ │
3. Ayu Dark │ 4 for _ in range(n): │ │
● 4. Default Dark │ 5 a, b = b, a + b │ │
5. Dracula Dark │ 6 return a │ │
6. GitHub Dark │ │ │
7. Holiday Dark │ 1 - print("Hello, " + name) │ │
8. Shades Of Purple Dark │ 1 + print(f"Hello, {name}!") │ │
9. Solarized Dark │ │ │
└─────────────────────────────────────────────────┘ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
│ │
@@ -11,6 +11,7 @@ import crypto from 'node:crypto';
import { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { theme as semanticTheme } from '../../semantic-colors.js';
import { themeManager } from '../../themes/theme-manager.js';
import type { Theme } from '../../themes/theme.js';
import { useSettings } from '../../contexts/SettingsContext.js';
@@ -177,6 +178,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
tabWidth,
availableTerminalHeight,
terminalWidth,
theme,
);
}
}, [
@@ -201,7 +203,11 @@ const renderDiffContent = (
tabWidth = DEFAULT_TAB_WIDTH,
availableTerminalHeight: number | undefined,
terminalWidth: number,
theme?: Theme,
) => {
const activeTheme = theme || themeManager.getActiveTheme();
const semanticColors = activeTheme.semanticColors;
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
const normalizedLines = parsedLines.map((line) => ({
...line,
@@ -217,7 +223,7 @@ const renderDiffContent = (
return (
<Box
borderStyle="round"
borderColor={semanticTheme.border.default}
borderColor={semanticColors.border.default}
padding={1}
>
<Text dimColor>No changes detected.</Text>
@@ -284,7 +290,7 @@ const renderDiffContent = (
borderRight={false}
borderBottom={false}
width={terminalWidth}
borderColor={semanticTheme.text.secondary}
borderColor={semanticColors.text.secondary}
></Box>
</Box>,
);
@@ -323,10 +329,16 @@ const renderDiffContent = (
const backgroundColor =
line.type === 'add'
? semanticTheme.background.diff.added
? semanticColors.background.diff.added
: line.type === 'del'
? semanticTheme.background.diff.removed
? semanticColors.background.diff.removed
: undefined;
const effectiveDefaultColor =
activeTheme.defaultColor !== ''
? activeTheme.defaultColor
: activeTheme.colors.Foreground;
acc.push(
<Box key={lineKey} flexDirection="row">
<Box
@@ -336,32 +348,35 @@ const renderDiffContent = (
backgroundColor={backgroundColor}
justifyContent="flex-end"
>
<Text color={semanticTheme.text.secondary}>{gutterNumStr}</Text>
<Text color={semanticColors.text.secondary}>{gutterNumStr}</Text>
</Box>
{line.type === 'context' ? (
<>
<Text color={effectiveDefaultColor}>
<Text>{prefixSymbol} </Text>
<Text wrap="wrap">{colorizeLine(displayContent, language)}</Text>
</>
<Text wrap="wrap">
{colorizeLine(displayContent, language, activeTheme)}
</Text>
</Text>
) : (
<Text
backgroundColor={
line.type === 'add'
? semanticTheme.background.diff.added
: semanticTheme.background.diff.removed
? semanticColors.background.diff.added
: semanticColors.background.diff.removed
}
color={effectiveDefaultColor}
wrap="wrap"
>
<Text
color={
line.type === 'add'
? semanticTheme.status.success
: semanticTheme.status.error
? semanticColors.status.success
: semanticColors.status.error
}
>
{prefixSymbol}
</Text>{' '}
{colorizeLine(displayContent, language)}
{colorizeLine(displayContent, language, activeTheme)}
</Text>
)}
</Box>,
@@ -14,8 +14,8 @@ exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlterna
'test';
21 + const anotherNew =
'test';
22 console.log('end of second
hunk');
22 console.log('end of
second hunk');
"
`;
@@ -107,8 +107,8 @@ exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlterna
'test';
21 + const anotherNew =
'test';
22 console.log('end of second
hunk');
22 console.log('end of
second hunk');
"
`;