mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat(cli): Allow themes to be specified as file paths (#6828)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
This commit is contained in:
@@ -28,6 +28,8 @@ Gemini CLI comes with a selection of pre-defined themes, which you can list usin
|
|||||||
3. Using the arrow keys, select a theme. Some interfaces might offer a live preview or highlight as you select.
|
3. Using the arrow keys, select a theme. Some interfaces might offer a live preview or highlight as you select.
|
||||||
4. Confirm your selection to apply the theme.
|
4. Confirm your selection to apply the theme.
|
||||||
|
|
||||||
|
**Note:** If a theme is defined in your `settings.json` file (either by name or by a file path), you must remove the `"theme"` setting from the file before you can change the theme using the `/theme` command.
|
||||||
|
|
||||||
### Theme Persistence
|
### Theme Persistence
|
||||||
|
|
||||||
Selected themes are saved in Gemini CLI's [configuration](./configuration.md) so your preference is remembered across sessions.
|
Selected themes are saved in Gemini CLI's [configuration](./configuration.md) so your preference is remembered across sessions.
|
||||||
@@ -105,6 +107,46 @@ You can use either hex codes (e.g., `#FF0000`) **or** standard CSS color names (
|
|||||||
|
|
||||||
You can define multiple custom themes by adding more entries to the `customThemes` object.
|
You can define multiple custom themes by adding more entries to the `customThemes` object.
|
||||||
|
|
||||||
|
### Loading Themes from a File
|
||||||
|
|
||||||
|
In addition to defining custom themes in `settings.json`, you can also load a theme directly from a JSON file by specifying the file path in your `settings.json`. This is useful for sharing themes or keeping them separate from your main configuration.
|
||||||
|
|
||||||
|
To load a theme from a file, set the `theme` property in your `settings.json` to the path of your theme file:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"theme": "/path/to/your/theme.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The theme file must be a valid JSON file that follows the same structure as a custom theme defined in `settings.json`.
|
||||||
|
|
||||||
|
**Example `my-theme.json`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "My File Theme",
|
||||||
|
"type": "custom",
|
||||||
|
"Background": "#282A36",
|
||||||
|
"Foreground": "#F8F8F2",
|
||||||
|
"LightBlue": "#82AAFF",
|
||||||
|
"AccentBlue": "#61AFEF",
|
||||||
|
"AccentPurple": "#BD93F9",
|
||||||
|
"AccentCyan": "#8BE9FD",
|
||||||
|
"AccentGreen": "#50FA7B",
|
||||||
|
"AccentYellow": "#F1FA8C",
|
||||||
|
"AccentRed": "#FF5555",
|
||||||
|
"Comment": "#6272A4",
|
||||||
|
"Gray": "#ABB2BF",
|
||||||
|
"DiffAdded": "#A6E3A1",
|
||||||
|
"DiffRemoved": "#F38BA8",
|
||||||
|
"DiffModified": "#89B4FA",
|
||||||
|
"GradientColors": ["#4796E4", "#847ACE", "#C3677F"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security Note:** For your safety, Gemini CLI will only load theme files that are located within your home directory. If you attempt to load a theme from outside your home directory, a warning will be displayed and the theme will not be loaded. This is to prevent loading potentially malicious theme files from untrusted sources.
|
||||||
|
|
||||||
### Example Custom Theme
|
### Example Custom Theme
|
||||||
|
|
||||||
<img src="../assets/theme-custom.png" alt="Custom theme example" width="600" />
|
<img src="../assets/theme-custom.png" alt="Custom theme example" width="600" />
|
||||||
|
|||||||
@@ -9,9 +9,22 @@ if (process.env['NO_COLOR'] !== undefined) {
|
|||||||
delete process.env['NO_COLOR'];
|
delete process.env['NO_COLOR'];
|
||||||
}
|
}
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
import { themeManager, DEFAULT_THEME } from './theme-manager.js';
|
import { themeManager, DEFAULT_THEME } from './theme-manager.js';
|
||||||
import { CustomTheme } from './theme.js';
|
import { CustomTheme } from './theme.js';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import type * as osActual from 'node:os';
|
||||||
|
|
||||||
|
vi.mock('node:fs');
|
||||||
|
vi.mock('node:os', async (importOriginal) => {
|
||||||
|
const actualOs = await importOriginal<typeof osActual>();
|
||||||
|
return {
|
||||||
|
...actualOs,
|
||||||
|
homedir: vi.fn(),
|
||||||
|
platform: vi.fn(() => 'linux'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const validCustomTheme: CustomTheme = {
|
const validCustomTheme: CustomTheme = {
|
||||||
type: 'custom',
|
type: 'custom',
|
||||||
@@ -38,6 +51,10 @@ describe('ThemeManager', () => {
|
|||||||
themeManager.setActiveTheme(DEFAULT_THEME.name);
|
themeManager.setActiveTheme(DEFAULT_THEME.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('should load valid custom themes', () => {
|
it('should load valid custom themes', () => {
|
||||||
themeManager.loadCustomThemes({ MyCustomTheme: validCustomTheme });
|
themeManager.loadCustomThemes({ MyCustomTheme: validCustomTheme });
|
||||||
expect(themeManager.getCustomThemeNames()).toContain('MyCustomTheme');
|
expect(themeManager.getCustomThemeNames()).toContain('MyCustomTheme');
|
||||||
@@ -96,4 +113,69 @@ describe('ThemeManager', () => {
|
|||||||
process.env['NO_COLOR'] = original;
|
process.env['NO_COLOR'] = original;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('when loading a theme from a file', () => {
|
||||||
|
const mockThemePath = './my-theme.json';
|
||||||
|
const mockTheme: CustomTheme = {
|
||||||
|
...validCustomTheme,
|
||||||
|
name: 'My File Theme',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(os.homedir).mockReturnValue('/home/user');
|
||||||
|
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => p as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load a theme from a valid file path', () => {
|
||||||
|
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||||
|
vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockTheme));
|
||||||
|
|
||||||
|
const result = themeManager.setActiveTheme('/home/user/my-theme.json');
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
const activeTheme = themeManager.getActiveTheme();
|
||||||
|
expect(activeTheme.name).toBe('My File Theme');
|
||||||
|
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('my-theme.json'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not load a theme if the file does not exist', () => {
|
||||||
|
vi.spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||||
|
|
||||||
|
const result = themeManager.setActiveTheme(mockThemePath);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not load a theme from a file with invalid JSON', () => {
|
||||||
|
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||||
|
vi.spyOn(fs, 'readFileSync').mockReturnValue('invalid json');
|
||||||
|
|
||||||
|
const result = themeManager.setActiveTheme(mockThemePath);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not load a theme from an untrusted file path and log a message', () => {
|
||||||
|
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||||
|
vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockTheme));
|
||||||
|
const consoleWarnSpy = vi
|
||||||
|
.spyOn(console, 'warn')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = themeManager.setActiveTheme('/untrusted/my-theme.json');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name);
|
||||||
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('is outside your home directory'),
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleWarnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import { DefaultLight } from './default-light.js';
|
|||||||
import { DefaultDark } from './default.js';
|
import { DefaultDark } from './default.js';
|
||||||
import { ShadesOfPurple } from './shades-of-purple.js';
|
import { ShadesOfPurple } from './shades-of-purple.js';
|
||||||
import { XCode } from './xcode.js';
|
import { XCode } from './xcode.js';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import * as os from 'node:os';
|
||||||
import {
|
import {
|
||||||
Theme,
|
Theme,
|
||||||
ThemeType,
|
ThemeType,
|
||||||
@@ -128,10 +131,22 @@ class ThemeManager {
|
|||||||
if (process.env['NO_COLOR']) {
|
if (process.env['NO_COLOR']) {
|
||||||
return NoColorTheme;
|
return NoColorTheme;
|
||||||
}
|
}
|
||||||
// Ensure the active theme is always valid (fall back to default if not)
|
|
||||||
if (!this.activeTheme || !this.findThemeByName(this.activeTheme.name)) {
|
if (this.activeTheme) {
|
||||||
this.activeTheme = DEFAULT_THEME;
|
const isBuiltIn = this.availableThemes.some(
|
||||||
|
(t) => t.name === this.activeTheme.name,
|
||||||
|
);
|
||||||
|
const isCustom = [...this.customThemes.values()].includes(
|
||||||
|
this.activeTheme,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isBuiltIn || isCustom) {
|
||||||
|
return this.activeTheme;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback to default if no active theme or if it's no longer valid.
|
||||||
|
this.activeTheme = DEFAULT_THEME;
|
||||||
return this.activeTheme;
|
return this.activeTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +230,73 @@ class ThemeManager {
|
|||||||
return this.findThemeByName(themeName);
|
return this.findThemeByName(themeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isPath(themeName: string): boolean {
|
||||||
|
return (
|
||||||
|
themeName.endsWith('.json') ||
|
||||||
|
themeName.startsWith('.') ||
|
||||||
|
path.isAbsolute(themeName)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadThemeFromFile(themePath: string): Theme | undefined {
|
||||||
|
try {
|
||||||
|
// realpathSync resolves the path and throws if it doesn't exist.
|
||||||
|
const canonicalPath = fs.realpathSync(path.resolve(themePath));
|
||||||
|
|
||||||
|
// 1. Check cache using the canonical path.
|
||||||
|
if (this.customThemes.has(canonicalPath)) {
|
||||||
|
return this.customThemes.get(canonicalPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Perform security check.
|
||||||
|
const homeDir = path.resolve(os.homedir());
|
||||||
|
if (!canonicalPath.startsWith(homeDir)) {
|
||||||
|
console.warn(
|
||||||
|
`Theme file at "${themePath}" is outside your home directory. ` +
|
||||||
|
`Only load themes from trusted sources.`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Read, parse, and validate the theme file.
|
||||||
|
const themeContent = fs.readFileSync(canonicalPath, 'utf-8');
|
||||||
|
const customThemeConfig = JSON.parse(themeContent) as CustomTheme;
|
||||||
|
|
||||||
|
const validation = validateCustomTheme(customThemeConfig);
|
||||||
|
if (!validation.isValid) {
|
||||||
|
console.warn(
|
||||||
|
`Invalid custom theme from file "${themePath}": ${validation.error}`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validation.warning) {
|
||||||
|
console.warn(`Theme from "${themePath}": ${validation.warning}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create and cache the theme.
|
||||||
|
const themeWithDefaults: CustomTheme = {
|
||||||
|
...DEFAULT_THEME.colors,
|
||||||
|
...customThemeConfig,
|
||||||
|
name: customThemeConfig.name || canonicalPath,
|
||||||
|
type: 'custom',
|
||||||
|
};
|
||||||
|
|
||||||
|
const theme = createCustomTheme(themeWithDefaults);
|
||||||
|
this.customThemes.set(canonicalPath, theme); // Cache by canonical path
|
||||||
|
return theme;
|
||||||
|
} catch (error) {
|
||||||
|
// Any error in the process (file not found, bad JSON, etc.) is caught here.
|
||||||
|
// We can return undefined silently for file-not-found, and warn for others.
|
||||||
|
if (
|
||||||
|
!(error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
||||||
|
) {
|
||||||
|
console.warn(`Could not load theme from file "${themePath}":`, error);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
findThemeByName(themeName: string | undefined): Theme | undefined {
|
findThemeByName(themeName: string | undefined): Theme | undefined {
|
||||||
if (!themeName) {
|
if (!themeName) {
|
||||||
return DEFAULT_THEME;
|
return DEFAULT_THEME;
|
||||||
@@ -228,8 +310,18 @@ class ThemeManager {
|
|||||||
return builtInTheme;
|
return builtInTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then check custom themes
|
// Then check custom themes that have been loaded from settings, or file paths
|
||||||
return this.customThemes.get(themeName);
|
if (this.isPath(themeName)) {
|
||||||
|
return this.loadThemeFromFile(themeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.customThemes.has(themeName)) {
|
||||||
|
return this.customThemes.get(themeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's not a built-in, not in cache, and not a valid file path,
|
||||||
|
// it's not a valid theme.
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user