feat: add custom theme with live preview and import/export

Add a fully customizable theme option to the settings theme picker:
- New CustomThemeBuilder component with mini UI preview showing all 13 colors
- Initialize from any existing theme as a starting point
- Color pickers for each theme property (bgMain, accent, textMain, etc.)
- Export theme to JSON file / Import from JSON file
- Custom theme persists across app restarts via useSettings
- Tab key navigation cycles through all themes including custom

Claude ID: fa8f6ebc-92e4-49e3-acfa-ef202e664cf0
Maestro ID: b9bc0d08-5be2-4fdf-93cd-5618a8d53b35
This commit is contained in:
Pedram Amini
2025-12-14 03:01:00 -06:00
parent 5203a4b45c
commit 00b0278ecb
8 changed files with 660 additions and 12 deletions

View File

@@ -41,6 +41,17 @@ vi.mock('../../../renderer/components/AICommandsPanel', () => ({
),
}));
// Mock CustomThemeBuilder
vi.mock('../../../renderer/components/CustomThemeBuilder', () => ({
CustomThemeBuilder: ({ isSelected, onSelect }: { isSelected: boolean; onSelect: () => void }) => (
<div data-testid="custom-theme-builder">
<button onClick={onSelect} data-theme-id="custom" className={isSelected ? 'ring-2' : ''}>
Custom Theme
</button>
</div>
),
}));
// Sample theme for testing
const mockTheme: Theme = {
id: 'dracula',
@@ -55,6 +66,7 @@ const mockTheme: Theme = {
textDim: '#6272a4',
accent: '#bd93f9',
accentDim: '#bd93f920',
accentText: '#ff79c6',
accentForeground: '#ffffff',
success: '#50fa7b',
warning: '#ffb86c',
@@ -75,6 +87,7 @@ const mockLightTheme: Theme = {
textDim: '#586069',
accent: '#0366d6',
accentDim: '#0366d620',
accentText: '#0366d6',
accentForeground: '#ffffff',
success: '#28a745',
warning: '#f59e0b',
@@ -95,6 +108,7 @@ const mockVibeTheme: Theme = {
textDim: '#a8a8a8',
accent: '#e94560',
accentDim: '#e9456020',
accentText: '#ff8dc7',
accentForeground: '#ffffff',
success: '#50fa7b',
warning: '#ffb86c',
@@ -121,6 +135,10 @@ const createDefaultProps = (overrides = {}) => ({
themes: mockThemes,
activeThemeId: 'dracula',
setActiveThemeId: vi.fn(),
customThemeColors: mockTheme.colors,
setCustomThemeColors: vi.fn(),
customThemeBaseId: 'dracula' as const,
setCustomThemeBaseId: vi.fn(),
llmProvider: 'openrouter',
setLlmProvider: vi.fn(),
modelSlug: '',

View File

@@ -330,6 +330,27 @@ export const THEMES: Record<ThemeId, Theme> = {
warning: '#cc0033',
error: '#cc0033'
}
},
// Custom theme - user-configurable, defaults to Dracula
custom: {
id: 'custom',
name: 'Custom',
mode: 'dark',
colors: {
bgMain: '#282a36',
bgSidebar: '#21222c',
bgActivity: '#343746',
border: '#44475a',
textMain: '#f8f8f2',
textDim: '#6272a4',
accent: '#bd93f9',
accentDim: 'rgba(189, 147, 249, 0.2)',
accentText: '#ff79c6',
accentForeground: '#282a36',
success: '#50fa7b',
warning: '#ffb86c',
error: '#ff5555'
}
}
};

View File

@@ -154,6 +154,8 @@ export default function MaestroConsole() {
fontFamily, setFontFamily,
fontSize, setFontSize,
activeThemeId, setActiveThemeId,
customThemeColors, setCustomThemeColors,
customThemeBaseId, setCustomThemeBaseId,
enterToSendAI, setEnterToSendAI,
enterToSendTerminal, setEnterToSendTerminal,
defaultSaveToHistory, setDefaultSaveToHistory,
@@ -1578,7 +1580,16 @@ export default function MaestroConsole() {
sessions.find(s => s.id === activeSessionId) || sessions[0] || null,
[sessions, activeSessionId]
);
const theme = THEMES[activeThemeId];
// Use custom colors when custom theme is selected, otherwise use the standard theme
const theme = useMemo(() => {
if (activeThemeId === 'custom') {
return {
...THEMES.custom,
colors: customThemeColors
};
}
return THEMES[activeThemeId];
}, [activeThemeId, customThemeColors]);
// Memoized cwd for git viewers (prevents re-renders from inline computation)
const gitViewerCwd = useMemo(() =>
@@ -5449,6 +5460,10 @@ export default function MaestroConsole() {
themes={THEMES}
activeThemeId={activeThemeId}
setActiveThemeId={setActiveThemeId}
customThemeColors={customThemeColors}
setCustomThemeColors={setCustomThemeColors}
customThemeBaseId={customThemeBaseId}
setCustomThemeBaseId={setCustomThemeBaseId}
llmProvider={llmProvider}
setLlmProvider={setLlmProvider}
modelSlug={modelSlug}

View File

@@ -0,0 +1,518 @@
import React, { useState, useCallback, useRef } from 'react';
import { Palette, Download, Upload, RotateCcw, Check, ChevronDown } from 'lucide-react';
import type { Theme, ThemeColors, ThemeId } from '../types';
import { THEMES, DEFAULT_CUSTOM_THEME_COLORS } from '../constants/themes';
interface CustomThemeBuilderProps {
theme: Theme; // Current active theme for styling the builder
customThemeColors: ThemeColors;
setCustomThemeColors: (colors: ThemeColors) => void;
customThemeBaseId: ThemeId;
setCustomThemeBaseId: (id: ThemeId) => void;
isSelected: boolean;
onSelect: () => void;
}
// Color picker labels with descriptions
const COLOR_CONFIG: { key: keyof ThemeColors; label: string; description: string }[] = [
{ key: 'bgMain', label: 'Main Background', description: 'Primary content area' },
{ key: 'bgSidebar', label: 'Sidebar Background', description: 'Left & right panels' },
{ key: 'bgActivity', label: 'Activity Background', description: 'Hover, active states' },
{ key: 'border', label: 'Border', description: 'Dividers & outlines' },
{ key: 'textMain', label: 'Main Text', description: 'Primary text color' },
{ key: 'textDim', label: 'Dimmed Text', description: 'Secondary text' },
{ key: 'accent', label: 'Accent', description: 'Highlights, links' },
{ key: 'accentDim', label: 'Accent Dim', description: 'Accent with transparency' },
{ key: 'accentText', label: 'Accent Text', description: 'Text in accent contexts' },
{ key: 'accentForeground', label: 'Accent Foreground', description: 'Text ON accent' },
{ key: 'success', label: 'Success', description: 'Green states' },
{ key: 'warning', label: 'Warning', description: 'Yellow/orange states' },
{ key: 'error', label: 'Error', description: 'Red states' },
];
// Mini UI Preview component
function MiniUIPreview({ colors }: { colors: ThemeColors }) {
return (
<div
className="rounded-lg overflow-hidden border"
style={{
borderColor: colors.border,
width: '100%',
height: 140
}}
>
{/* Mini UI layout */}
<div className="flex h-full">
{/* Left sidebar */}
<div
className="w-12 flex flex-col gap-1 p-1"
style={{ backgroundColor: colors.bgSidebar }}
>
{/* Session items */}
<div
className="h-4 rounded text-[6px] flex items-center justify-center"
style={{ backgroundColor: colors.bgActivity, color: colors.textDim }}
>
S1
</div>
<div
className="h-4 rounded text-[6px] flex items-center justify-center ring-1"
style={{
backgroundColor: colors.accentDim,
color: colors.accent,
ringColor: colors.accent
}}
>
S2
</div>
<div
className="h-4 rounded text-[6px] flex items-center justify-center"
style={{ backgroundColor: colors.bgActivity, color: colors.textDim }}
>
S3
</div>
</div>
{/* Main content */}
<div
className="flex-1 flex flex-col"
style={{ backgroundColor: colors.bgMain }}
>
{/* Header bar */}
<div
className="h-5 flex items-center px-2 border-b"
style={{ borderColor: colors.border }}
>
<span className="text-[7px] font-bold" style={{ color: colors.textMain }}>
AI Terminal
</span>
</div>
{/* Chat area */}
<div className="flex-1 p-1 space-y-1 overflow-hidden">
{/* User message */}
<div className="flex justify-end">
<div
className="rounded px-1.5 py-0.5 text-[6px] max-w-[80%]"
style={{ backgroundColor: colors.accentDim, color: colors.textMain }}
>
User message
</div>
</div>
{/* AI response */}
<div className="flex justify-start">
<div
className="rounded px-1.5 py-0.5 text-[6px] max-w-[80%]"
style={{ backgroundColor: colors.bgActivity, color: colors.textMain }}
>
AI response here
</div>
</div>
{/* Status indicators */}
<div className="flex gap-1 mt-1">
<span className="text-[5px] px-1 rounded" style={{ backgroundColor: colors.success + '30', color: colors.success }}>ready</span>
<span className="text-[5px] px-1 rounded" style={{ backgroundColor: colors.warning + '30', color: colors.warning }}>busy</span>
<span className="text-[5px] px-1 rounded" style={{ backgroundColor: colors.error + '30', color: colors.error }}>error</span>
</div>
</div>
{/* Input area */}
<div
className="h-6 border-t flex items-center px-1"
style={{ borderColor: colors.border }}
>
<div
className="flex-1 h-4 rounded border text-[6px] flex items-center px-1"
style={{
borderColor: colors.border,
backgroundColor: colors.bgActivity,
color: colors.textDim
}}
>
Type a message...
</div>
<div
className="ml-1 w-4 h-4 rounded flex items-center justify-center text-[6px]"
style={{ backgroundColor: colors.accent, color: colors.accentForeground }}
>
</div>
</div>
</div>
{/* Right panel */}
<div
className="w-10 flex flex-col border-l"
style={{ backgroundColor: colors.bgSidebar, borderColor: colors.border }}
>
<div
className="text-[5px] px-1 py-0.5 border-b text-center font-bold"
style={{ borderColor: colors.border, color: colors.accent }}
>
Files
</div>
<div className="p-0.5 space-y-0.5">
<div className="text-[5px] truncate" style={{ color: colors.textMain }}>src/</div>
<div className="text-[5px] truncate pl-1" style={{ color: colors.textDim }}>app.tsx</div>
<div className="text-[5px] truncate pl-1" style={{ color: colors.textDim }}>index.ts</div>
</div>
</div>
</div>
</div>
);
}
// Color input with label
function ColorInput({
colorKey,
label,
description,
value,
onChange,
theme
}: {
colorKey: keyof ThemeColors;
label: string;
description: string;
value: string;
onChange: (key: keyof ThemeColors, value: string) => void;
theme: Theme;
}) {
const [isEditing, setIsEditing] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// Handle rgba/hsla by showing the color picker for the base color
const isComplexColor = value.includes('rgba') || value.includes('hsla');
const displayValue = isComplexColor ? value : value;
return (
<div className="flex items-center gap-2 py-1">
<div className="relative">
<input
ref={inputRef}
type="color"
value={isComplexColor ? '#888888' : value}
onChange={(e) => onChange(colorKey, e.target.value)}
className="w-8 h-8 rounded cursor-pointer border-2"
style={{ borderColor: theme.colors.border }}
title={label}
/>
{isComplexColor && (
<div
className="absolute inset-0 rounded pointer-events-none flex items-center justify-center text-[8px] font-bold"
style={{ color: theme.colors.textMain }}
>
α
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium" style={{ color: theme.colors.textMain }}>
{label}
</div>
<div className="text-[10px]" style={{ color: theme.colors.textDim }}>
{description}
</div>
</div>
<div className="flex items-center gap-1">
{isEditing ? (
<input
type="text"
value={value}
onChange={(e) => onChange(colorKey, e.target.value)}
onBlur={() => setIsEditing(false)}
onKeyDown={(e) => e.key === 'Enter' && setIsEditing(false)}
className="w-32 px-1.5 py-0.5 rounded text-xs font-mono border"
style={{
backgroundColor: theme.colors.bgActivity,
borderColor: theme.colors.border,
color: theme.colors.textMain
}}
autoFocus
/>
) : (
<button
onClick={() => setIsEditing(true)}
className="px-1.5 py-0.5 rounded text-xs font-mono hover:opacity-80"
style={{
backgroundColor: theme.colors.bgActivity,
color: theme.colors.textDim
}}
>
{displayValue.length > 12 ? displayValue.slice(0, 12) + '...' : displayValue}
</button>
)}
</div>
</div>
);
}
export function CustomThemeBuilder({
theme,
customThemeColors,
setCustomThemeColors,
customThemeBaseId,
setCustomThemeBaseId,
isSelected,
onSelect
}: CustomThemeBuilderProps) {
const [showBaseSelector, setShowBaseSelector] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Get all themes except 'custom' for base selection
const baseThemes = Object.values(THEMES).filter(t => t.id !== 'custom');
const handleColorChange = useCallback((key: keyof ThemeColors, value: string) => {
setCustomThemeColors({
...customThemeColors,
[key]: value
});
}, [customThemeColors, setCustomThemeColors]);
const handleInitializeFromBase = useCallback((baseId: ThemeId) => {
const baseTheme = THEMES[baseId];
if (baseTheme) {
setCustomThemeColors({ ...baseTheme.colors });
setCustomThemeBaseId(baseId);
}
setShowBaseSelector(false);
}, [setCustomThemeColors, setCustomThemeBaseId]);
const handleReset = useCallback(() => {
setCustomThemeColors({ ...DEFAULT_CUSTOM_THEME_COLORS });
setCustomThemeBaseId('dracula');
}, [setCustomThemeColors, setCustomThemeBaseId]);
const handleExport = useCallback(() => {
const exportData = {
name: 'Custom Theme',
baseTheme: customThemeBaseId,
colors: customThemeColors,
exportedAt: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'maestro-custom-theme.json';
a.click();
URL.revokeObjectURL(url);
}, [customThemeColors, customThemeBaseId]);
const handleImport = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target?.result as string);
if (data.colors && typeof data.colors === 'object') {
// Validate all required color keys exist
const requiredKeys = COLOR_CONFIG.map(c => c.key);
const hasAllKeys = requiredKeys.every(key => key in data.colors);
if (hasAllKeys) {
setCustomThemeColors(data.colors);
if (data.baseTheme && THEMES[data.baseTheme as ThemeId]) {
setCustomThemeBaseId(data.baseTheme);
}
} else {
console.error('Invalid theme file: missing required color keys');
}
}
} catch (err) {
console.error('Failed to parse theme file:', err);
}
};
reader.readAsText(file);
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, [setCustomThemeColors, setCustomThemeBaseId]);
return (
<div>
{/* Custom Theme Header */}
<div className="text-xs font-bold uppercase mb-3 flex items-center gap-2" style={{ color: theme.colors.textDim }}>
<Palette className="w-3 h-3" />
Custom Theme
</div>
{/* Theme Selection Button + Controls */}
<div
className={`rounded-lg border transition-all ${isSelected ? 'ring-2' : ''}`}
style={{
borderColor: theme.colors.border,
backgroundColor: customThemeColors.bgSidebar,
ringColor: customThemeColors.accent
}}
>
{/* Clickable Header to Select Theme */}
<button
onClick={onSelect}
className="w-full p-3 text-left"
tabIndex={-1}
>
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-bold" style={{ color: customThemeColors.textMain }}>
Custom
</span>
{isSelected && <Check className="w-4 h-4" style={{ color: customThemeColors.accent }} />}
</div>
<div className="flex h-3 rounded overflow-hidden">
<div className="flex-1" style={{ backgroundColor: customThemeColors.bgMain }} />
<div className="flex-1" style={{ backgroundColor: customThemeColors.bgActivity }} />
<div className="flex-1" style={{ backgroundColor: customThemeColors.accent }} />
</div>
</button>
{/* Builder Controls (always visible but styled differently when not selected) */}
<div
className="px-3 pb-3 border-t"
style={{
borderColor: theme.colors.border,
opacity: isSelected ? 1 : 0.6
}}
>
{/* Mini Preview */}
<div className="py-3">
<div className="text-[10px] uppercase font-bold mb-2" style={{ color: theme.colors.textDim }}>
Preview
</div>
<MiniUIPreview colors={customThemeColors} />
</div>
{/* Action Buttons */}
<div className="flex gap-2 mb-3">
{/* Initialize From Base Theme */}
<div className="relative flex-1">
<button
onClick={() => setShowBaseSelector(!showBaseSelector)}
className="w-full flex items-center justify-center gap-1 px-2 py-1.5 rounded text-xs font-medium border hover:opacity-80"
style={{
backgroundColor: theme.colors.bgActivity,
borderColor: theme.colors.border,
color: theme.colors.textMain
}}
>
<RotateCcw className="w-3 h-3" />
Initialize
<ChevronDown className="w-3 h-3" />
</button>
{showBaseSelector && (
<div
className="absolute top-full left-0 right-0 mt-1 rounded-lg border shadow-lg z-10 max-h-48 overflow-y-auto"
style={{
backgroundColor: theme.colors.bgSidebar,
borderColor: theme.colors.border
}}
>
{baseThemes.map(t => (
<button
key={t.id}
onClick={() => handleInitializeFromBase(t.id)}
className="w-full px-2 py-1.5 text-left text-xs hover:opacity-80 flex items-center gap-2"
style={{
backgroundColor: customThemeBaseId === t.id ? theme.colors.accentDim : 'transparent',
color: theme.colors.textMain
}}
>
<div className="flex h-3 w-8 rounded overflow-hidden">
<div className="flex-1" style={{ backgroundColor: t.colors.bgMain }} />
<div className="flex-1" style={{ backgroundColor: t.colors.accent }} />
</div>
{t.name}
{customThemeBaseId === t.id && (
<span className="ml-auto text-[9px]" style={{ color: theme.colors.textDim }}>
current base
</span>
)}
</button>
))}
</div>
)}
</div>
{/* Export */}
<button
onClick={handleExport}
className="flex items-center justify-center gap-1 px-2 py-1.5 rounded text-xs font-medium border hover:opacity-80"
style={{
backgroundColor: theme.colors.bgActivity,
borderColor: theme.colors.border,
color: theme.colors.textMain
}}
title="Export theme"
>
<Download className="w-3 h-3" />
</button>
{/* Import */}
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center justify-center gap-1 px-2 py-1.5 rounded text-xs font-medium border hover:opacity-80"
style={{
backgroundColor: theme.colors.bgActivity,
borderColor: theme.colors.border,
color: theme.colors.textMain
}}
title="Import theme"
>
<Upload className="w-3 h-3" />
</button>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleImport}
className="hidden"
/>
{/* Reset */}
<button
onClick={handleReset}
className="flex items-center justify-center gap-1 px-2 py-1.5 rounded text-xs font-medium border hover:opacity-80"
style={{
backgroundColor: theme.colors.error + '20',
borderColor: theme.colors.error + '40',
color: theme.colors.error
}}
title="Reset to default"
>
<RotateCcw className="w-3 h-3" />
</button>
</div>
{/* Color Editors */}
<div className="text-[10px] uppercase font-bold mb-2" style={{ color: theme.colors.textDim }}>
Colors
</div>
<div
className="rounded-lg border p-2 max-h-64 overflow-y-auto"
style={{
backgroundColor: theme.colors.bgMain,
borderColor: theme.colors.border
}}
>
{COLOR_CONFIG.map(({ key, label, description }) => (
<ColorInput
key={key}
colorKey={key}
label={label}
description={description}
value={customThemeColors[key]}
onChange={handleColorChange}
theme={theme}
/>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef, memo } from 'react';
import { X, Key, Moon, Sun, Keyboard, Check, Terminal, Bell, Cpu, Settings, Palette, Sparkles, History, Download } from 'lucide-react';
import type { AgentConfig, Theme, Shortcut, ShellInfo, CustomAICommand } from '../types';
import type { AgentConfig, Theme, ThemeColors, ThemeId, Shortcut, ShellInfo, CustomAICommand } from '../types';
import { CustomThemeBuilder } from './CustomThemeBuilder';
import { useLayerStack } from '../contexts/LayerStackContext';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
import { AICommandsPanel } from './AICommandsPanel';
@@ -23,6 +24,10 @@ interface SettingsModalProps {
themes: Record<string, Theme>;
activeThemeId: string;
setActiveThemeId: (id: string) => void;
customThemeColors: ThemeColors;
setCustomThemeColors: (colors: ThemeColors) => void;
customThemeBaseId: ThemeId;
setCustomThemeBaseId: (id: ThemeId) => void;
llmProvider: string;
setLlmProvider: (provider: string) => void;
modelSlug: string;
@@ -431,8 +436,9 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
if (!isOpen) return null;
// Group themes by mode for the ThemePicker
// Group themes by mode for the ThemePicker (exclude 'custom' theme - it's handled separately)
const groupedThemes = Object.values(themes).reduce((acc: Record<string, Theme[]>, t: Theme) => {
if (t.id === 'custom') return acc; // Skip custom theme in regular grouping
if (!acc[t.mode]) acc[t.mode] = [];
acc[t.mode].push(t);
return acc;
@@ -442,21 +448,23 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
if (e.key === 'Tab') {
e.preventDefault();
e.stopPropagation();
// Create ordered array: dark themes first, then light, then vibe (cycling back to dark)
// Create ordered array: dark themes first, then light, then vibe, then custom (cycling back to dark)
const allThemes = [...(groupedThemes['dark'] || []), ...(groupedThemes['light'] || []), ...(groupedThemes['vibe'] || [])];
const currentIndex = allThemes.findIndex((t: Theme) => t.id === props.activeThemeId);
// Add 'custom' as the last item in the cycle
const allThemeIds = [...allThemes.map(t => t.id), 'custom'];
const currentIndex = allThemeIds.findIndex((id: string) => id === props.activeThemeId);
let newThemeId: string;
if (e.shiftKey) {
// Shift+Tab: go backwards
const prevIndex = currentIndex === 0 ? allThemes.length - 1 : currentIndex - 1;
newThemeId = allThemes[prevIndex].id;
const prevIndex = currentIndex === 0 ? allThemeIds.length - 1 : currentIndex - 1;
newThemeId = allThemeIds[prevIndex];
} else {
// Tab: go forward
const nextIndex = (currentIndex + 1) % allThemes.length;
newThemeId = allThemes[nextIndex].id;
const nextIndex = (currentIndex + 1) % allThemeIds.length;
newThemeId = allThemeIds[nextIndex];
}
props.setActiveThemeId(newThemeId);
props.setActiveThemeId(newThemeId as ThemeId);
// Scroll the newly selected theme button into view
setTimeout(() => {
@@ -508,6 +516,19 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
</div>
</div>
))}
{/* Custom Theme Builder */}
<div data-theme-id="custom">
<CustomThemeBuilder
theme={theme}
customThemeColors={props.customThemeColors}
setCustomThemeColors={props.setCustomThemeColors}
customThemeBaseId={props.customThemeBaseId}
setCustomThemeBaseId={props.setCustomThemeBaseId}
isSelected={props.activeThemeId === 'custom'}
onSelect={() => props.setActiveThemeId('custom')}
/>
</div>
</div>
);

View File

@@ -323,5 +323,29 @@ export const THEMES: Record<ThemeId, Theme> = {
warning: '#cc0033',
error: '#cc0033'
}
},
// Custom theme - user-configurable, defaults to Dracula
custom: {
id: 'custom',
name: 'Custom',
mode: 'dark',
colors: {
bgMain: '#282a36',
bgSidebar: '#21222c',
bgActivity: '#343746',
border: '#44475a',
textMain: '#f8f8f2',
textDim: '#6272a4',
accent: '#bd93f9',
accentDim: 'rgba(189, 147, 249, 0.2)',
accentText: '#ff79c6',
accentForeground: '#282a36',
success: '#50fa7b',
warning: '#ffb86c',
error: '#ff5555'
}
}
};
// Default custom theme colors (Dracula-based)
export const DEFAULT_CUSTOM_THEME_COLORS = THEMES.dracula.colors;

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import type { LLMProvider, ThemeId, Shortcut, CustomAICommand, GlobalStats, AutoRunStats, OnboardingStats, LeaderboardRegistration } from '../types';
import type { LLMProvider, ThemeId, ThemeColors, Shortcut, CustomAICommand, GlobalStats, AutoRunStats, OnboardingStats, LeaderboardRegistration } from '../types';
import { DEFAULT_CUSTOM_THEME_COLORS } from '../constants/themes';
import { DEFAULT_SHORTCUTS } from '../constants/shortcuts';
// Default global stats
@@ -114,6 +115,10 @@ export interface UseSettingsReturn {
// UI settings
activeThemeId: ThemeId;
setActiveThemeId: (value: ThemeId) => void;
customThemeColors: ThemeColors;
setCustomThemeColors: (value: ThemeColors) => void;
customThemeBaseId: ThemeId;
setCustomThemeBaseId: (value: ThemeId) => void;
enterToSendAI: boolean;
setEnterToSendAI: (value: boolean) => void;
enterToSendTerminal: boolean;
@@ -242,6 +247,8 @@ export function useSettings(): UseSettingsReturn {
// UI Config
const [activeThemeId, setActiveThemeIdState] = useState<ThemeId>('dracula');
const [customThemeColors, setCustomThemeColorsState] = useState<ThemeColors>(DEFAULT_CUSTOM_THEME_COLORS);
const [customThemeBaseId, setCustomThemeBaseIdState] = useState<ThemeId>('dracula');
const [enterToSendAI, setEnterToSendAIState] = useState(false); // AI mode defaults to Command+Enter
const [enterToSendTerminal, setEnterToSendTerminalState] = useState(true); // Terminal defaults to Enter
const [defaultSaveToHistory, setDefaultSaveToHistoryState] = useState(true); // History toggle defaults to on
@@ -350,6 +357,16 @@ export function useSettings(): UseSettingsReturn {
window.maestro.settings.set('activeThemeId', value);
}, []);
const setCustomThemeColors = useCallback((value: ThemeColors) => {
setCustomThemeColorsState(value);
window.maestro.settings.set('customThemeColors', value);
}, []);
const setCustomThemeBaseId = useCallback((value: ThemeId) => {
setCustomThemeBaseIdState(value);
window.maestro.settings.set('customThemeBaseId', value);
}, []);
const setEnterToSendAI = useCallback((value: boolean) => {
setEnterToSendAIState(value);
window.maestro.settings.set('enterToSendAI', value);
@@ -844,6 +861,8 @@ export function useSettings(): UseSettingsReturn {
const savedShowHiddenFiles = await window.maestro.settings.get('showHiddenFiles');
const savedShortcuts = await window.maestro.settings.get('shortcuts');
const savedActiveThemeId = await window.maestro.settings.get('activeThemeId');
const savedCustomThemeColors = await window.maestro.settings.get('customThemeColors');
const savedCustomThemeBaseId = await window.maestro.settings.get('customThemeBaseId');
const savedTerminalWidth = await window.maestro.settings.get('terminalWidth');
const savedLogLevel = await window.maestro.logger.getLogLevel();
const savedMaxLogBuffer = await window.maestro.logger.getMaxLogBuffer();
@@ -882,6 +901,8 @@ export function useSettings(): UseSettingsReturn {
if (savedMarkdownEditMode !== undefined) setMarkdownEditModeState(savedMarkdownEditMode);
if (savedShowHiddenFiles !== undefined) setShowHiddenFilesState(savedShowHiddenFiles);
if (savedActiveThemeId !== undefined) setActiveThemeIdState(savedActiveThemeId);
if (savedCustomThemeColors !== undefined) setCustomThemeColorsState(savedCustomThemeColors);
if (savedCustomThemeBaseId !== undefined) setCustomThemeBaseIdState(savedCustomThemeBaseId);
if (savedTerminalWidth !== undefined) setTerminalWidthState(savedTerminalWidth);
if (savedLogLevel !== undefined) setLogLevelState(savedLogLevel);
if (savedMaxLogBuffer !== undefined) setMaxLogBufferState(savedMaxLogBuffer);
@@ -1035,6 +1056,10 @@ export function useSettings(): UseSettingsReturn {
setCustomFonts,
activeThemeId,
setActiveThemeId,
customThemeColors,
setCustomThemeColors,
customThemeBaseId,
setCustomThemeBaseId,
enterToSendAI,
setEnterToSendAI,
enterToSendTerminal,
@@ -1116,6 +1141,8 @@ export function useSettings(): UseSettingsReturn {
fontSize,
customFonts,
activeThemeId,
customThemeColors,
customThemeBaseId,
enterToSendAI,
enterToSendTerminal,
defaultSaveToHistory,
@@ -1153,6 +1180,8 @@ export function useSettings(): UseSettingsReturn {
setFontSize,
setCustomFonts,
setActiveThemeId,
setCustomThemeColors,
setCustomThemeBaseId,
setEnterToSendAI,
setEnterToSendTerminal,
setDefaultSaveToHistory,

View File

@@ -28,7 +28,8 @@ export type ThemeId =
| 'pedurple'
| 'maestros-choice'
| 'dre-synth'
| 'inquest';
| 'inquest'
| 'custom';
/**
* Theme mode indicating the overall brightness/style
@@ -103,6 +104,7 @@ export function isValidThemeId(id: string): id is ThemeId {
'maestros-choice',
'dre-synth',
'inquest',
'custom',
];
return validIds.includes(id as ThemeId);
}