mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
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:
@@ -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: '',
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
518
src/renderer/components/CustomThemeBuilder.tsx
Normal file
518
src/renderer/components/CustomThemeBuilder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user