Add theming support.

- Added a number of common themes to our support matrix:
 - AtomOneDark
 - Dracula
 - VS
 - GitHub
 - GoogleCode
 - XCode
 - ... Admittedly these all were randomly picked, we could probably curate these better...
- Added a new `ThemeDialog` UI that can be accessed via `/theme`. It shows your currentlyt available themes and allows you to change them freely. It does **not**:
 - Save the theme between sessions
 - Allow you to hit escape
 - Show a preview prior to selection.
- These themes are from reacts highlight js library.

Fixes https://b.corp.google.com/issues/412797985
This commit is contained in:
Taylor Mullen 2025-04-22 18:57:47 -07:00 committed by N. Taylor Mullen
parent e163e02499
commit 4c2a5045a0
11 changed files with 876 additions and 22 deletions

View File

@ -4,17 +4,19 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo, useCallback } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { StreamingState, type HistoryItem } from './types.js'; import { StreamingState, type HistoryItem } from './types.js';
import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useInputHistory } from './hooks/useInputHistory.js'; import { useInputHistory } from './hooks/useInputHistory.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { Header } from './components/Header.js'; import { Header } from './components/Header.js';
import { HistoryDisplay } from './components/HistoryDisplay.js'; import { HistoryDisplay } from './components/HistoryDisplay.js';
import { LoadingIndicator } from './components/LoadingIndicator.js'; import { LoadingIndicator } from './components/LoadingIndicator.js';
import { InputPrompt } from './components/InputPrompt.js'; import { InputPrompt } from './components/InputPrompt.js';
import { Footer } from './components/Footer.js'; import { Footer } from './components/Footer.js';
import { ThemeDialog } from './components/ThemeDialog.js';
import { ITermDetectionWarning } from './utils/itermDetection.js'; import { ITermDetectionWarning } from './utils/itermDetection.js';
import { import {
useStartupWarnings, useStartupWarnings,
@ -22,13 +24,13 @@ import {
} from './hooks/useAppEffects.js'; } from './hooks/useAppEffects.js';
import { shortenPath, type Config } from '@gemini-code/server'; import { shortenPath, type Config } from '@gemini-code/server';
import { Colors } from './colors.js'; import { Colors } from './colors.js';
import { Tips } from './components/Tips.js';
interface AppProps { interface AppProps {
config: Config; config: Config;
} }
export const App = ({ config }: AppProps) => { export const App = ({ config }: AppProps) => {
// Destructured prop
const [history, setHistory] = useState<HistoryItem[]>([]); const [history, setHistory] = useState<HistoryItem[]>([]);
const [startupWarnings, setStartupWarnings] = useState<string[]>([]); const [startupWarnings, setStartupWarnings] = useState<string[]>([]);
const { streamingState, submitQuery, initError, debugMessage } = const { streamingState, submitQuery, initError, debugMessage } =
@ -36,9 +38,24 @@ export const App = ({ config }: AppProps) => {
const { elapsedTime, currentLoadingPhrase } = const { elapsedTime, currentLoadingPhrase } =
useLoadingIndicator(streamingState); useLoadingIndicator(streamingState);
const { isThemeDialogOpen, openThemeDialog, handleThemeSelect } =
useThemeCommand();
useStartupWarnings(setStartupWarnings); useStartupWarnings(setStartupWarnings);
useInitializationErrorEffect(initError, history, setHistory); useInitializationErrorEffect(initError, history, setHistory);
const handleFinalSubmit = useCallback(
(submittedValue: string) => {
const trimmedValue = submittedValue.trim();
if (trimmedValue === '/theme') {
openThemeDialog();
} else if (trimmedValue.length > 0) {
submitQuery(submittedValue);
}
},
[openThemeDialog, submitQuery],
);
const userMessages = useMemo( const userMessages = useMemo(
() => () =>
history history
@ -56,13 +73,16 @@ export const App = ({ config }: AppProps) => {
const { query, handleSubmit: handleHistorySubmit } = useInputHistory({ const { query, handleSubmit: handleHistorySubmit } = useInputHistory({
userMessages, userMessages,
onSubmit: submitQuery, onSubmit: handleFinalSubmit,
isActive: isInputActive, isActive: isInputActive,
}); });
// --- Render Logic ---
return ( return (
<Box flexDirection="column" marginBottom={1} width="100%"> <Box flexDirection="column" marginBottom={1} width="100%">
<Header /> <Header />
<Tips />
{startupWarnings.length > 0 && ( {startupWarnings.length > 0 && (
<Box <Box
@ -112,6 +132,10 @@ export const App = ({ config }: AppProps) => {
</Box> </Box>
)} )}
{isThemeDialogOpen ? (
<ThemeDialog onSelect={handleThemeSelect} />
) : (
<>
<Box flexDirection="column"> <Box flexDirection="column">
<HistoryDisplay history={history} onSubmit={submitQuery} /> <HistoryDisplay history={history} onSubmit={submitQuery} />
<LoadingIndicator <LoadingIndicator
@ -133,6 +157,8 @@ export const App = ({ config }: AppProps) => {
<InputPrompt onSubmit={handleHistorySubmit} /> <InputPrompt onSubmit={handleHistorySubmit} />
</> </>
)} )}
</>
)}
<Footer <Footer
config={config} config={config}

View File

@ -7,7 +7,6 @@
import React from 'react'; import React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import Gradient from 'ink-gradient'; import Gradient from 'ink-gradient';
import { Tips } from './Tips.js';
const gradientColors = ['#4796E4', '#847ACE', '#C3677F']; const gradientColors = ['#4796E4', '#847ACE', '#C3677F'];
@ -32,6 +31,5 @@ export const Header: React.FC = () => (
`}</Text> `}</Text>
</Gradient> </Gradient>
</Box> </Box>
<Tips />
</> </>
); );

View File

@ -0,0 +1,49 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { themeManager } from '../themes/theme-manager.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
interface ThemeDialogProps {
/** Callback function when a theme is selected */
onSelect: (themeName: string) => void;
}
export function ThemeDialog({ onSelect }: ThemeDialogProps): React.JSX.Element {
const themeItems = themeManager.getAvailableThemes().map((theme) => ({
label: theme.active ? `${theme.name} (Active)` : theme.name,
value: theme.name,
}));
const initialIndex = themeItems.findIndex(
(item) => item.value === themeManager.getActiveTheme().name,
);
return (
<Box
borderStyle="round"
borderColor={Colors.AccentCyan}
flexDirection="column"
padding={1}
width="50%"
>
<Box marginBottom={1}>
<Text bold>Select Theme</Text>
</Box>
<RadioButtonSelect
items={themeItems}
initialIndex={initialIndex}
onSelect={onSelect}
/>
<Box marginTop={1}>
<Text color={Colors.SubtleComment}>
(Use / arrows and Enter to select)
</Text>
</Box>
</Box>
);
}

View File

@ -0,0 +1,40 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
import { themeManager } from '../themes/theme-manager.js';
interface UseThemeCommandReturn {
isThemeDialogOpen: boolean;
openThemeDialog: () => void;
handleThemeSelect: (themeName: string) => void;
}
export const useThemeCommand = (): UseThemeCommandReturn => {
const [isThemeDialogOpen, setIsThemeDialogOpen] = useState(false);
const [, setForceRender] = useState(0);
const openThemeDialog = useCallback(() => {
setIsThemeDialogOpen(true);
}, []);
const handleThemeSelect = useCallback((themeName: string) => {
try {
themeManager.setActiveTheme(themeName);
setForceRender((v) => v + 1); // Trigger potential re-render
} catch (error) {
console.error(`Error setting theme: ${error}`);
} finally {
setIsThemeDialogOpen(false); // Close the dialog
}
}, []);
return {
isThemeDialogOpen,
openThemeDialog,
handleThemeSelect,
};
};

View File

@ -0,0 +1,122 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Theme } from './theme.js';
export const AtomOneDark: Theme = new Theme('Atom One Dark', {
hljs: {
display: 'block',
overflowX: 'auto',
padding: '0.5em',
color: '#abb2bf',
background: '#282c34',
},
'hljs-comment': {
color: '#5c6370',
fontStyle: 'italic',
},
'hljs-quote': {
color: '#5c6370',
fontStyle: 'italic',
},
'hljs-doctag': {
color: '#c678dd',
},
'hljs-keyword': {
color: '#c678dd',
},
'hljs-formula': {
color: '#c678dd',
},
'hljs-section': {
color: '#e06c75',
},
'hljs-name': {
color: '#e06c75',
},
'hljs-selector-tag': {
color: '#e06c75',
},
'hljs-deletion': {
color: '#e06c75',
},
'hljs-subst': {
color: '#e06c75',
},
'hljs-literal': {
color: '#56b6c2',
},
'hljs-string': {
color: '#98c379',
},
'hljs-regexp': {
color: '#98c379',
},
'hljs-addition': {
color: '#98c379',
},
'hljs-attribute': {
color: '#98c379',
},
'hljs-meta-string': {
color: '#98c379',
},
'hljs-built_in': {
color: '#e6c07b',
},
'hljs-class .hljs-title': {
color: '#e6c07b',
},
'hljs-attr': {
color: '#d19a66',
},
'hljs-variable': {
color: '#d19a66',
},
'hljs-template-variable': {
color: '#d19a66',
},
'hljs-type': {
color: '#d19a66',
},
'hljs-selector-class': {
color: '#d19a66',
},
'hljs-selector-attr': {
color: '#d19a66',
},
'hljs-selector-pseudo': {
color: '#d19a66',
},
'hljs-number': {
color: '#d19a66',
},
'hljs-symbol': {
color: '#61aeee',
},
'hljs-bullet': {
color: '#61aeee',
},
'hljs-link': {
color: '#61aeee',
textDecoration: 'underline',
},
'hljs-meta': {
color: '#61aeee',
},
'hljs-selector-id': {
color: '#61aeee',
},
'hljs-title': {
color: '#61aeee',
},
'hljs-emphasis': {
fontStyle: 'italic',
},
'hljs-strong': {
fontWeight: 'bold',
},
});

View File

@ -0,0 +1,99 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Theme } from './theme.js';
export const Dracula: Theme = new Theme('Dracula', {
hljs: {
display: 'block',
overflowX: 'auto',
padding: '0.5em',
background: '#282a36',
color: '#f8f8f2',
},
'hljs-keyword': {
color: '#8be9fd',
fontWeight: 'bold',
},
'hljs-selector-tag': {
color: '#8be9fd',
fontWeight: 'bold',
},
'hljs-literal': {
color: '#8be9fd',
fontWeight: 'bold',
},
'hljs-section': {
color: '#8be9fd',
fontWeight: 'bold',
},
'hljs-link': {
color: '#8be9fd',
},
'hljs-function .hljs-keyword': {
color: '#ff79c6',
},
'hljs-subst': {
color: '#f8f8f2',
},
'hljs-string': {
color: '#f1fa8c',
},
'hljs-title': {
color: '#f1fa8c',
fontWeight: 'bold',
},
'hljs-name': {
color: '#f1fa8c',
fontWeight: 'bold',
},
'hljs-type': {
color: '#f1fa8c',
fontWeight: 'bold',
},
'hljs-attribute': {
color: '#f1fa8c',
},
'hljs-symbol': {
color: '#f1fa8c',
},
'hljs-bullet': {
color: '#f1fa8c',
},
'hljs-addition': {
color: '#f1fa8c',
},
'hljs-variable': {
color: '#f1fa8c',
},
'hljs-template-tag': {
color: '#f1fa8c',
},
'hljs-template-variable': {
color: '#f1fa8c',
},
'hljs-comment': {
color: '#6272a4',
},
'hljs-quote': {
color: '#6272a4',
},
'hljs-deletion': {
color: '#6272a4',
},
'hljs-meta': {
color: '#6272a4',
},
'hljs-doctag': {
fontWeight: 'bold',
},
'hljs-strong': {
fontWeight: 'bold',
},
'hljs-emphasis': {
fontStyle: 'italic',
},
});

View File

@ -0,0 +1,124 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Theme } from './theme.js';
export const GitHub: Theme = new Theme('GitHub', {
hljs: {
display: 'block',
overflowX: 'auto',
padding: '0.5em',
color: '#333',
background: '#f8f8f8',
},
'hljs-comment': {
color: '#998',
fontStyle: 'italic',
},
'hljs-quote': {
color: '#998',
fontStyle: 'italic',
},
'hljs-keyword': {
color: '#333',
fontWeight: 'bold',
},
'hljs-selector-tag': {
color: '#333',
fontWeight: 'bold',
},
'hljs-subst': {
color: '#333',
fontWeight: 'normal',
},
'hljs-number': {
color: '#008080',
},
'hljs-literal': {
color: '#008080',
},
'hljs-variable': {
color: '#008080',
},
'hljs-template-variable': {
color: '#008080',
},
'hljs-tag .hljs-attr': {
color: '#008080',
},
'hljs-string': {
color: '#d14',
},
'hljs-doctag': {
color: '#d14',
},
'hljs-title': {
color: '#900',
fontWeight: 'bold',
},
'hljs-section': {
color: '#900',
fontWeight: 'bold',
},
'hljs-selector-id': {
color: '#900',
fontWeight: 'bold',
},
'hljs-type': {
color: '#458',
fontWeight: 'bold',
},
'hljs-class .hljs-title': {
color: '#458',
fontWeight: 'bold',
},
'hljs-tag': {
color: '#000080',
fontWeight: 'normal',
},
'hljs-name': {
color: '#000080',
fontWeight: 'normal',
},
'hljs-attribute': {
color: '#000080',
fontWeight: 'normal',
},
'hljs-regexp': {
color: '#009926',
},
'hljs-link': {
color: '#009926',
},
'hljs-symbol': {
color: '#990073',
},
'hljs-bullet': {
color: '#990073',
},
'hljs-built_in': {
color: '#0086b3',
},
'hljs-builtin-name': {
color: '#0086b3',
},
'hljs-meta': {
color: '#999',
fontWeight: 'bold',
},
'hljs-deletion': {
background: '#fdd',
},
'hljs-addition': {
background: '#dfd',
},
'hljs-emphasis': {
fontStyle: 'italic',
},
'hljs-strong': {
fontWeight: 'bold',
},
});

View File

@ -0,0 +1,121 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Theme } from './theme.js';
export const GoogleCode: Theme = new Theme('Google Code', {
hljs: {
display: 'block',
overflowX: 'auto',
padding: '0.5em',
background: 'white',
color: 'black',
},
'hljs-comment': {
color: '#800',
},
'hljs-quote': {
color: '#800',
},
'hljs-keyword': {
color: '#008',
},
'hljs-selector-tag': {
color: '#008',
},
'hljs-section': {
color: '#008',
},
'hljs-title': {
color: '#606',
},
'hljs-name': {
color: '#008',
},
'hljs-variable': {
color: '#660',
},
'hljs-template-variable': {
color: '#660',
},
'hljs-string': {
color: '#080',
},
'hljs-selector-attr': {
color: '#080',
},
'hljs-selector-pseudo': {
color: '#080',
},
'hljs-regexp': {
color: '#080',
},
'hljs-literal': {
color: '#066',
},
'hljs-symbol': {
color: '#066',
},
'hljs-bullet': {
color: '#066',
},
'hljs-meta': {
color: '#066',
},
'hljs-number': {
color: '#066',
},
'hljs-link': {
color: '#066',
},
'hljs-doctag': {
color: '#606',
fontWeight: 'bold',
},
'hljs-type': {
color: '#606',
},
'hljs-attr': {
color: '#606',
},
'hljs-built_in': {
color: '#606',
},
'hljs-builtin-name': {
color: '#606',
},
'hljs-params': {
color: '#606',
},
'hljs-attribute': {
color: '#000',
},
'hljs-subst': {
color: '#000',
},
'hljs-formula': {
backgroundColor: '#eee',
fontStyle: 'italic',
},
'hljs-selector-id': {
color: '#9B703F',
},
'hljs-selector-class': {
color: '#9B703F',
},
'hljs-addition': {
backgroundColor: '#baeeba',
},
'hljs-deletion': {
backgroundColor: '#ffc8bd',
},
'hljs-strong': {
fontWeight: 'bold',
},
'hljs-emphasis': {
fontStyle: 'italic',
},
});

View File

@ -4,19 +4,64 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { AtomOneDark } from './atom-one-dark.js';
import { Dracula } from './dracula.js';
import { GitHub } from './github.js';
import { GoogleCode } from './googlecode.js';
import { VS } from './vs.js';
import { VS2015 } from './vs2015.js'; import { VS2015 } from './vs2015.js';
import { XCode } from './xcode.js';
import { Theme } from './theme.js'; import { Theme } from './theme.js';
export interface ThemeDisplay {
name: string;
active: boolean;
}
class ThemeManager { class ThemeManager {
private static readonly DEFAULT_THEME: Theme = VS2015; private static readonly DEFAULT_THEME: Theme = VS2015;
private readonly availableThemes: Theme[]; private readonly availableThemes: Theme[];
private activeTheme: Theme; private activeTheme: Theme;
constructor() { constructor() {
this.availableThemes = [VS2015]; this.availableThemes = [
AtomOneDark,
Dracula,
VS,
VS2015,
GitHub,
GoogleCode,
XCode,
];
this.activeTheme = ThemeManager.DEFAULT_THEME; this.activeTheme = ThemeManager.DEFAULT_THEME;
} }
/**
* Returns a list of available theme names.
*/
getAvailableThemes(): ThemeDisplay[] {
return this.availableThemes.map((theme) => ({
name: theme.name,
active: theme === this.activeTheme,
}));
}
/**
* Sets the active theme.
* @param themeName The name of the theme to activate.
*/
setActiveTheme(themeName: string): void {
const foundTheme = this.availableThemes.find(
(theme) => theme.name === themeName,
);
if (foundTheme) {
this.activeTheme = foundTheme;
} else {
throw new Error(`Theme "${themeName}" not found.`);
}
}
/** /**
* Returns the currently active theme object. * Returns the currently active theme object.
*/ */

View File

@ -0,0 +1,101 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Theme } from './theme.js';
export const VS: Theme = new Theme('VS', {
hljs: {
display: 'block',
overflowX: 'auto',
padding: '0.5em',
background: 'white',
color: 'black',
},
'hljs-comment': {
color: '#008000',
},
'hljs-quote': {
color: '#008000',
},
'hljs-variable': {
color: '#008000',
},
'hljs-keyword': {
color: '#00f',
},
'hljs-selector-tag': {
color: '#00f',
},
'hljs-built_in': {
color: '#00f',
},
'hljs-name': {
color: '#00f',
},
'hljs-tag': {
color: '#00f',
},
'hljs-string': {
color: '#a31515',
},
'hljs-title': {
color: '#a31515',
},
'hljs-section': {
color: '#a31515',
},
'hljs-attribute': {
color: '#a31515',
},
'hljs-literal': {
color: '#a31515',
},
'hljs-template-tag': {
color: '#a31515',
},
'hljs-template-variable': {
color: '#a31515',
},
'hljs-type': {
color: '#a31515',
},
'hljs-addition': {
color: '#a31515',
},
'hljs-deletion': {
color: '#2b91af',
},
'hljs-selector-attr': {
color: '#2b91af',
},
'hljs-selector-pseudo': {
color: '#2b91af',
},
'hljs-meta': {
color: '#2b91af',
},
'hljs-doctag': {
color: '#808080',
},
'hljs-attr': {
color: '#f00',
},
'hljs-symbol': {
color: '#00b0e8',
},
'hljs-bullet': {
color: '#00b0e8',
},
'hljs-link': {
color: '#00b0e8',
},
'hljs-emphasis': {
fontStyle: 'italic',
},
'hljs-strong': {
fontWeight: 'bold',
},
});

View File

@ -0,0 +1,129 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Theme } from './theme.js';
export const XCode: Theme = new Theme('XCode', {
hljs: {
display: 'block',
overflowX: 'auto',
padding: '0.5em',
background: '#fff',
color: 'black',
},
'xml .hljs-meta': {
color: '#c0c0c0',
},
'hljs-comment': {
color: '#007400',
},
'hljs-quote': {
color: '#007400',
},
'hljs-tag': {
color: '#aa0d91',
},
'hljs-attribute': {
color: '#aa0d91',
},
'hljs-keyword': {
color: '#aa0d91',
},
'hljs-selector-tag': {
color: '#aa0d91',
},
'hljs-literal': {
color: '#aa0d91',
},
'hljs-name': {
color: '#aa0d91',
},
'hljs-variable': {
color: '#3F6E74',
},
'hljs-template-variable': {
color: '#3F6E74',
},
'hljs-code': {
color: '#c41a16',
},
'hljs-string': {
color: '#c41a16',
},
'hljs-meta-string': {
color: '#c41a16',
},
'hljs-regexp': {
color: '#0E0EFF',
},
'hljs-link': {
color: '#0E0EFF',
},
'hljs-title': {
color: '#1c00cf',
},
'hljs-symbol': {
color: '#1c00cf',
},
'hljs-bullet': {
color: '#1c00cf',
},
'hljs-number': {
color: '#1c00cf',
},
'hljs-section': {
color: '#643820',
},
'hljs-meta': {
color: '#643820',
},
'hljs-class .hljs-title': {
color: '#5c2699',
},
'hljs-type': {
color: '#5c2699',
},
'hljs-built_in': {
color: '#5c2699',
},
'hljs-builtin-name': {
color: '#5c2699',
},
'hljs-params': {
color: '#5c2699',
},
'hljs-attr': {
color: '#836C28',
},
'hljs-subst': {
color: '#000',
},
'hljs-formula': {
backgroundColor: '#eee',
fontStyle: 'italic',
},
'hljs-addition': {
backgroundColor: '#baeeba',
},
'hljs-deletion': {
backgroundColor: '#ffc8bd',
},
'hljs-selector-id': {
color: '#9b703f',
},
'hljs-selector-class': {
color: '#9b703f',
},
'hljs-doctag': {
fontWeight: 'bold',
},
'hljs-strong': {
fontWeight: 'bold',
},
'hljs-emphasis': {
fontStyle: 'italic',
},
});