mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
- Bumped Maestro version to 0.13.1 for the latest improvements 🚀 - Added Document Graph IPC handlers to enable live file watching 📈 - Introduced SSH Remote IPC handlers for managing saved SSH configurations 🛰️ - Added a dedicated SSH tab in Settings for cleaner navigation 🗂️ - Updated Settings keyboard tab-cycling to include the new SSH section ⌨️ - Refined Settings UI with a new Server icon for SSH tab branding 🖥️ - Moved SSH Remote hosts configuration into its own SSH Settings panel 🔐
2003 lines
90 KiB
TypeScript
2003 lines
90 KiB
TypeScript
import React, { useState, useEffect, useRef, memo } from 'react';
|
|
import { X, Key, Moon, Sun, Keyboard, Check, Terminal, Bell, Cpu, Settings, Palette, Sparkles, History, Download, Bug, Cloud, FolderSync, RotateCcw, Folder, ChevronDown, Plus, Trash2, Brain, AlertTriangle, FlaskConical, Database, Server } from 'lucide-react';
|
|
import { useSettings } from '../hooks';
|
|
import type { Theme, ThemeColors, ThemeId, Shortcut, ShellInfo, CustomAICommand, LLMProvider } from '../types';
|
|
import { CustomThemeBuilder } from './CustomThemeBuilder';
|
|
import { useLayerStack } from '../contexts/LayerStackContext';
|
|
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
|
import { AICommandsPanel } from './AICommandsPanel';
|
|
import { SpecKitCommandsPanel } from './SpecKitCommandsPanel';
|
|
import { OpenSpecCommandsPanel } from './OpenSpecCommandsPanel';
|
|
import { formatShortcutKeys } from '../utils/shortcutFormatter';
|
|
import { ToggleButtonGroup } from './ToggleButtonGroup';
|
|
import { SettingCheckbox } from './SettingCheckbox';
|
|
import { FontConfigurationPanel } from './FontConfigurationPanel';
|
|
import { NotificationsPanel } from './NotificationsPanel';
|
|
import { SshRemotesSection } from './Settings/SshRemotesSection';
|
|
|
|
// Feature flags - set to true to enable dormant features
|
|
const FEATURE_FLAGS = {
|
|
LLM_SETTINGS: false, // LLM provider configuration (OpenRouter, Anthropic, Ollama)
|
|
};
|
|
|
|
// Environment Variables Editor - uses stable indices to prevent focus loss during key editing
|
|
interface EnvVarEntry {
|
|
id: number;
|
|
key: string;
|
|
value: string;
|
|
}
|
|
|
|
interface EnvVarsEditorProps {
|
|
envVars: Record<string, string>;
|
|
setEnvVars: (vars: Record<string, string>) => void;
|
|
theme: Theme;
|
|
}
|
|
|
|
function EnvVarsEditor({ envVars, setEnvVars, theme }: EnvVarsEditorProps) {
|
|
// Convert object to array with stable IDs for editing
|
|
const [entries, setEntries] = useState<EnvVarEntry[]>(() => {
|
|
return Object.entries(envVars).map(([key, value], index) => ({
|
|
id: index,
|
|
key,
|
|
value,
|
|
}));
|
|
});
|
|
const [nextId, setNextId] = useState(Object.keys(envVars).length);
|
|
|
|
// Sync entries back to parent when they change (but debounced to avoid focus issues)
|
|
const commitChanges = (newEntries: EnvVarEntry[]) => {
|
|
const newEnvVars: Record<string, string> = {};
|
|
newEntries.forEach(entry => {
|
|
if (entry.key.trim()) {
|
|
newEnvVars[entry.key] = entry.value;
|
|
}
|
|
});
|
|
setEnvVars(newEnvVars);
|
|
};
|
|
|
|
// Sync from parent when envVars changes externally (e.g., on modal open)
|
|
useEffect(() => {
|
|
const parentEntries = Object.entries(envVars);
|
|
// Only reset if the keys/values actually differ
|
|
const currentKeys = entries.filter(e => e.key.trim()).map(e => `${e.key}=${e.value}`).sort().join(',');
|
|
const parentKeys = parentEntries.map(([k, v]) => `${k}=${v}`).sort().join(',');
|
|
if (currentKeys !== parentKeys) {
|
|
setEntries(parentEntries.map(([key, value], index) => ({
|
|
id: index,
|
|
key,
|
|
value,
|
|
})));
|
|
setNextId(parentEntries.length);
|
|
}
|
|
}, [envVars]);
|
|
|
|
const updateEntry = (id: number, field: 'key' | 'value', newValue: string) => {
|
|
setEntries(prev => {
|
|
const updated = prev.map(entry =>
|
|
entry.id === id ? { ...entry, [field]: newValue } : entry
|
|
);
|
|
// Commit changes on every update for value field, but for key field
|
|
// only commit valid keys to avoid issues with empty keys
|
|
commitChanges(updated);
|
|
return updated;
|
|
});
|
|
};
|
|
|
|
const removeEntry = (id: number) => {
|
|
setEntries(prev => {
|
|
const updated = prev.filter(entry => entry.id !== id);
|
|
commitChanges(updated);
|
|
return updated;
|
|
});
|
|
};
|
|
|
|
const addEntry = () => {
|
|
// Generate a unique default key name
|
|
let newKey = 'VAR';
|
|
let counter = 1;
|
|
const existingKeys = new Set(entries.map(e => e.key));
|
|
while (existingKeys.has(newKey)) {
|
|
newKey = `VAR_${counter}`;
|
|
counter++;
|
|
}
|
|
setEntries(prev => [...prev, { id: nextId, key: newKey, value: '' }]);
|
|
setNextId(prev => prev + 1);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<label className="block text-xs opacity-60 mb-1">Environment Variables (optional)</label>
|
|
<div className="space-y-2">
|
|
{entries.map((entry) => (
|
|
<div key={entry.id} className="flex gap-2 items-center">
|
|
<input
|
|
type="text"
|
|
value={entry.key}
|
|
onChange={(e) => updateEntry(entry.id, 'key', e.target.value)}
|
|
placeholder="VARIABLE"
|
|
className="flex-1 p-2 rounded border bg-transparent outline-none text-xs font-mono"
|
|
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
|
|
/>
|
|
<span className="text-xs" style={{ color: theme.colors.textDim }}>=</span>
|
|
<input
|
|
type="text"
|
|
value={entry.value}
|
|
onChange={(e) => updateEntry(entry.id, 'value', e.target.value)}
|
|
placeholder="value"
|
|
className="flex-[2] p-2 rounded border bg-transparent outline-none text-xs font-mono"
|
|
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
|
|
/>
|
|
<button
|
|
onClick={() => removeEntry(entry.id)}
|
|
className="p-2 rounded hover:bg-white/10 transition-colors"
|
|
title="Remove variable"
|
|
style={{ color: theme.colors.textDim }}
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
onClick={addEntry}
|
|
className="flex items-center gap-1 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
|
|
style={{ color: theme.colors.textDim }}
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
Add Variable
|
|
</button>
|
|
</div>
|
|
<p className="text-xs opacity-50 mt-1">
|
|
Environment variables passed to every shell session.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface SettingsModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
theme: Theme;
|
|
themes: Record<string, Theme>;
|
|
activeThemeId: ThemeId;
|
|
setActiveThemeId: (id: ThemeId) => void;
|
|
customThemeColors: ThemeColors;
|
|
setCustomThemeColors: (colors: ThemeColors) => void;
|
|
customThemeBaseId: ThemeId;
|
|
setCustomThemeBaseId: (id: ThemeId) => void;
|
|
llmProvider: LLMProvider;
|
|
setLlmProvider: (provider: LLMProvider) => void;
|
|
modelSlug: string;
|
|
setModelSlug: (slug: string) => void;
|
|
apiKey: string;
|
|
setApiKey: (key: string) => void;
|
|
shortcuts: Record<string, Shortcut>;
|
|
setShortcuts: (shortcuts: Record<string, Shortcut>) => void;
|
|
tabShortcuts: Record<string, Shortcut>;
|
|
setTabShortcuts: (shortcuts: Record<string, Shortcut>) => void;
|
|
fontFamily: string;
|
|
setFontFamily: (font: string) => void;
|
|
fontSize: number;
|
|
setFontSize: (size: number) => void;
|
|
terminalWidth: number;
|
|
setTerminalWidth: (width: number) => void;
|
|
logLevel: string;
|
|
setLogLevel: (level: string) => void;
|
|
maxLogBuffer: number;
|
|
setMaxLogBuffer: (buffer: number) => void;
|
|
maxOutputLines: number;
|
|
setMaxOutputLines: (lines: number) => void;
|
|
defaultShell: string;
|
|
setDefaultShell: (shell: string) => void;
|
|
customShellPath: string;
|
|
setCustomShellPath: (path: string) => void;
|
|
shellArgs: string;
|
|
setShellArgs: (args: string) => void;
|
|
shellEnvVars: Record<string, string>;
|
|
setShellEnvVars: (vars: Record<string, string>) => void;
|
|
ghPath: string;
|
|
setGhPath: (path: string) => void;
|
|
enterToSendAI: boolean;
|
|
setEnterToSendAI: (value: boolean) => void;
|
|
enterToSendTerminal: boolean;
|
|
setEnterToSendTerminal: (value: boolean) => void;
|
|
defaultSaveToHistory: boolean;
|
|
setDefaultSaveToHistory: (value: boolean) => void;
|
|
defaultShowThinking: boolean;
|
|
setDefaultShowThinking: (value: boolean) => void;
|
|
osNotificationsEnabled: boolean;
|
|
setOsNotificationsEnabled: (value: boolean) => void;
|
|
audioFeedbackEnabled: boolean;
|
|
setAudioFeedbackEnabled: (value: boolean) => void;
|
|
audioFeedbackCommand: string;
|
|
setAudioFeedbackCommand: (value: string) => void;
|
|
toastDuration: number;
|
|
setToastDuration: (value: number) => void;
|
|
checkForUpdatesOnStartup: boolean;
|
|
setCheckForUpdatesOnStartup: (value: boolean) => void;
|
|
enableBetaUpdates: boolean;
|
|
setEnableBetaUpdates: (value: boolean) => void;
|
|
crashReportingEnabled: boolean;
|
|
setCrashReportingEnabled: (value: boolean) => void;
|
|
customAICommands: CustomAICommand[];
|
|
setCustomAICommands: (commands: CustomAICommand[]) => void;
|
|
initialTab?: 'general' | 'llm' | 'shortcuts' | 'theme' | 'notifications' | 'aicommands' | 'ssh';
|
|
hasNoAgents?: boolean;
|
|
onThemeImportError?: (message: string) => void;
|
|
onThemeImportSuccess?: (message: string) => void;
|
|
}
|
|
|
|
export const SettingsModal = memo(function SettingsModal(props: SettingsModalProps) {
|
|
const { isOpen, onClose, theme, themes, initialTab } = props;
|
|
|
|
// Context management settings from useSettings hook
|
|
const {
|
|
contextManagementSettings,
|
|
updateContextManagementSettings,
|
|
// Document Graph settings
|
|
documentGraphShowExternalLinks,
|
|
setDocumentGraphShowExternalLinks,
|
|
documentGraphMaxNodes,
|
|
setDocumentGraphMaxNodes,
|
|
documentGraphLayoutMode,
|
|
setDocumentGraphLayoutMode,
|
|
// Stats settings
|
|
statsCollectionEnabled,
|
|
setStatsCollectionEnabled,
|
|
defaultStatsTimeRange,
|
|
setDefaultStatsTimeRange,
|
|
} = useSettings();
|
|
|
|
const [activeTab, setActiveTab] = useState<'general' | 'llm' | 'shortcuts' | 'theme' | 'notifications' | 'aicommands' | 'ssh'>('general');
|
|
const [systemFonts, setSystemFonts] = useState<string[]>([]);
|
|
const [customFonts, setCustomFonts] = useState<string[]>([]);
|
|
const [fontLoading, setFontLoading] = useState(false);
|
|
const [fontsLoaded, setFontsLoaded] = useState(false);
|
|
const [recordingId, setRecordingId] = useState<string | null>(null);
|
|
const [shortcutsFilter, setShortcutsFilter] = useState('');
|
|
const [testingLLM, setTestingLLM] = useState(false);
|
|
const [testResult, setTestResult] = useState<{ status: 'success' | 'error' | null; message: string }>({ status: null, message: '' });
|
|
const [shells, setShells] = useState<ShellInfo[]>([]);
|
|
const [shellsLoading, setShellsLoading] = useState(false);
|
|
const [shellsLoaded, setShellsLoaded] = useState(false);
|
|
const [shellConfigExpanded, setShellConfigExpanded] = useState(false);
|
|
|
|
// Sync/storage location state
|
|
const [defaultStoragePath, setDefaultStoragePath] = useState<string>('');
|
|
const [_currentStoragePath, setCurrentStoragePath] = useState<string>('');
|
|
const [customSyncPath, setCustomSyncPath] = useState<string | undefined>(undefined);
|
|
const [syncRestartRequired, setSyncRestartRequired] = useState(false);
|
|
const [syncMigrating, setSyncMigrating] = useState(false);
|
|
const [syncError, setSyncError] = useState<string | null>(null);
|
|
const [syncMigratedCount, setSyncMigratedCount] = useState<number | null>(null);
|
|
|
|
// Stats data management state
|
|
const [statsDbSize, setStatsDbSize] = useState<number | null>(null);
|
|
const [statsClearing, setStatsClearing] = useState(false);
|
|
const [statsClearResult, setStatsClearResult] = useState<{
|
|
success: boolean;
|
|
deletedQueryEvents: number;
|
|
deletedAutoRunSessions: number;
|
|
deletedAutoRunTasks: number;
|
|
error?: string;
|
|
} | null>(null);
|
|
|
|
// Layer stack integration
|
|
const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack();
|
|
const layerIdRef = useRef<string>();
|
|
const shortcutsFilterRef = useRef<HTMLInputElement>(null);
|
|
const themePickerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
// Don't load fonts immediately - only when user interacts with font selector
|
|
// Set initial tab if provided, otherwise default to 'general'
|
|
setActiveTab(initialTab || 'general');
|
|
|
|
// Load sync settings
|
|
Promise.all([
|
|
window.maestro.sync.getDefaultPath(),
|
|
window.maestro.sync.getSettings(),
|
|
window.maestro.sync.getCurrentStoragePath(),
|
|
]).then(([defaultPath, settings, currentPath]) => {
|
|
setDefaultStoragePath(defaultPath);
|
|
setCustomSyncPath(settings.customSyncPath);
|
|
setCurrentStoragePath(currentPath);
|
|
setSyncRestartRequired(false);
|
|
setSyncError(null);
|
|
setSyncMigratedCount(null);
|
|
}).catch((err) => {
|
|
console.error('Failed to load sync settings:', err);
|
|
setSyncError('Failed to load storage settings');
|
|
});
|
|
|
|
// Load stats database size
|
|
window.maestro.stats.getDatabaseSize().then((size) => {
|
|
setStatsDbSize(size);
|
|
}).catch((err) => {
|
|
console.error('Failed to load stats database size:', err);
|
|
});
|
|
|
|
// Reset stats clear state
|
|
setStatsClearResult(null);
|
|
}
|
|
}, [isOpen, initialTab]);
|
|
|
|
// Store onClose in a ref to avoid re-registering layer when onClose changes
|
|
const onCloseRef = useRef(onClose);
|
|
onCloseRef.current = onClose;
|
|
|
|
// Register layer when modal opens
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const id = registerLayer({
|
|
type: 'modal',
|
|
priority: MODAL_PRIORITIES.SETTINGS,
|
|
blocksLowerLayers: true,
|
|
capturesFocus: true,
|
|
focusTrap: 'strict',
|
|
ariaLabel: 'Settings',
|
|
onEscape: () => {
|
|
// If recording a shortcut, cancel recording instead of closing modal
|
|
if (recordingId) {
|
|
setRecordingId(null);
|
|
} else {
|
|
onCloseRef.current();
|
|
}
|
|
}
|
|
});
|
|
|
|
layerIdRef.current = id;
|
|
|
|
return () => {
|
|
if (layerIdRef.current) {
|
|
unregisterLayer(layerIdRef.current);
|
|
}
|
|
};
|
|
}, [isOpen, registerLayer, unregisterLayer]); // Removed onClose from deps
|
|
|
|
// Update handler when dependencies change
|
|
useEffect(() => {
|
|
if (!isOpen || !layerIdRef.current) return;
|
|
|
|
updateLayerHandler(layerIdRef.current, () => {
|
|
// If recording a shortcut, cancel recording instead of closing modal
|
|
if (recordingId) {
|
|
setRecordingId(null);
|
|
} else {
|
|
onCloseRef.current();
|
|
}
|
|
});
|
|
}, [isOpen, recordingId, updateLayerHandler]); // Use ref for onClose
|
|
|
|
// Tab navigation with Cmd+Shift+[ and ]
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const handleTabNavigation = (e: KeyboardEvent) => {
|
|
const tabs: Array<'general' | 'llm' | 'shortcuts' | 'theme' | 'notifications' | 'aicommands' | 'ssh'> = FEATURE_FLAGS.LLM_SETTINGS
|
|
? ['general', 'llm', 'shortcuts', 'theme', 'notifications', 'aicommands', 'ssh']
|
|
: ['general', 'shortcuts', 'theme', 'notifications', 'aicommands', 'ssh'];
|
|
const currentIndex = tabs.indexOf(activeTab);
|
|
|
|
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === '[') {
|
|
e.preventDefault();
|
|
const prevIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1;
|
|
setActiveTab(tabs[prevIndex]);
|
|
} else if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === ']') {
|
|
e.preventDefault();
|
|
const nextIndex = (currentIndex + 1) % tabs.length;
|
|
setActiveTab(tabs[nextIndex]);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleTabNavigation);
|
|
return () => window.removeEventListener('keydown', handleTabNavigation);
|
|
}, [isOpen, activeTab]);
|
|
|
|
// Focus theme picker when theme tab becomes active
|
|
useEffect(() => {
|
|
if (isOpen && activeTab === 'theme') {
|
|
const timer = setTimeout(() => themePickerRef.current?.focus(), 50);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [isOpen, activeTab]);
|
|
|
|
// Auto-focus shortcuts filter when switching to shortcuts tab
|
|
useEffect(() => {
|
|
if (isOpen && activeTab === 'shortcuts') {
|
|
// Small delay to ensure DOM is ready
|
|
setTimeout(() => shortcutsFilterRef.current?.focus(), 50);
|
|
}
|
|
}, [isOpen, activeTab]);
|
|
|
|
const loadFonts = async () => {
|
|
if (fontsLoaded) return; // Don't reload if already loaded
|
|
|
|
setFontLoading(true);
|
|
try {
|
|
const detected = await window.maestro.fonts.detect();
|
|
setSystemFonts(detected);
|
|
|
|
const savedCustomFonts = await window.maestro.settings.get('customFonts') as string[] | undefined;
|
|
if (savedCustomFonts && Array.isArray(savedCustomFonts)) {
|
|
setCustomFonts(savedCustomFonts);
|
|
}
|
|
setFontsLoaded(true);
|
|
} catch (error) {
|
|
console.error('Failed to load fonts:', error);
|
|
} finally {
|
|
setFontLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleFontInteraction = () => {
|
|
if (!fontsLoaded && !fontLoading) {
|
|
loadFonts();
|
|
}
|
|
};
|
|
|
|
const loadShells = async () => {
|
|
if (shellsLoaded) return; // Don't reload if already loaded
|
|
|
|
setShellsLoading(true);
|
|
try {
|
|
const detected = await window.maestro.shells.detect();
|
|
setShells(detected);
|
|
setShellsLoaded(true);
|
|
} catch (error) {
|
|
console.error('Failed to load shells:', error);
|
|
} finally {
|
|
setShellsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleShellInteraction = () => {
|
|
if (!shellsLoaded && !shellsLoading) {
|
|
loadShells();
|
|
}
|
|
};
|
|
|
|
const addCustomFont = (font: string) => {
|
|
if (font && !customFonts.includes(font)) {
|
|
const newCustomFonts = [...customFonts, font];
|
|
setCustomFonts(newCustomFonts);
|
|
window.maestro.settings.set('customFonts', newCustomFonts);
|
|
}
|
|
};
|
|
|
|
const removeCustomFont = (font: string) => {
|
|
const newCustomFonts = customFonts.filter(f => f !== font);
|
|
setCustomFonts(newCustomFonts);
|
|
window.maestro.settings.set('customFonts', newCustomFonts);
|
|
};
|
|
|
|
const testLLMConnection = async () => {
|
|
setTestingLLM(true);
|
|
setTestResult({ status: null, message: '' });
|
|
|
|
try {
|
|
let response;
|
|
const testPrompt = 'Respond with exactly: "Connection successful"';
|
|
|
|
if (props.llmProvider === 'openrouter') {
|
|
if (!props.apiKey) {
|
|
throw new Error('API key is required for OpenRouter');
|
|
}
|
|
|
|
response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${props.apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
'HTTP-Referer': 'https://maestro.local',
|
|
},
|
|
body: JSON.stringify({
|
|
model: props.modelSlug || 'anthropic/claude-3.5-sonnet',
|
|
messages: [{ role: 'user', content: testPrompt }],
|
|
max_tokens: 50,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error?.message || `OpenRouter API error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (!data.choices?.[0]?.message?.content) {
|
|
throw new Error('Invalid response from OpenRouter');
|
|
}
|
|
|
|
setTestResult({
|
|
status: 'success',
|
|
message: 'Successfully connected to OpenRouter!',
|
|
});
|
|
} else if (props.llmProvider === 'anthropic') {
|
|
if (!props.apiKey) {
|
|
throw new Error('API key is required for Anthropic');
|
|
}
|
|
|
|
response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
method: 'POST',
|
|
headers: {
|
|
'x-api-key': props.apiKey,
|
|
'anthropic-version': '2023-06-01',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
model: props.modelSlug || 'claude-3-5-sonnet-20241022',
|
|
max_tokens: 50,
|
|
messages: [{ role: 'user', content: testPrompt }],
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error?.message || `Anthropic API error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (!data.content?.[0]?.text) {
|
|
throw new Error('Invalid response from Anthropic');
|
|
}
|
|
|
|
setTestResult({
|
|
status: 'success',
|
|
message: 'Successfully connected to Anthropic!',
|
|
});
|
|
} else if (props.llmProvider === 'ollama') {
|
|
response = await fetch('http://localhost:11434/api/generate', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
model: props.modelSlug || 'llama3:latest',
|
|
prompt: testPrompt,
|
|
stream: false,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Ollama API error: ${response.status}. Make sure Ollama is running locally.`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (!data.response) {
|
|
throw new Error('Invalid response from Ollama');
|
|
}
|
|
|
|
setTestResult({
|
|
status: 'success',
|
|
message: 'Successfully connected to Ollama!',
|
|
});
|
|
}
|
|
} catch (error: any) {
|
|
setTestResult({
|
|
status: 'error',
|
|
message: error.message || 'Connection failed',
|
|
});
|
|
} finally {
|
|
setTestingLLM(false);
|
|
}
|
|
};
|
|
|
|
const handleRecord = (e: React.KeyboardEvent, actionId: string, isTabShortcut: boolean = false) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
// Escape cancels recording without saving
|
|
if (e.key === 'Escape') {
|
|
setRecordingId(null);
|
|
return;
|
|
}
|
|
|
|
const keys = [];
|
|
if (e.metaKey) keys.push('Meta');
|
|
if (e.ctrlKey) keys.push('Ctrl');
|
|
if (e.altKey) keys.push('Alt');
|
|
if (e.shiftKey) keys.push('Shift');
|
|
if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) return;
|
|
|
|
// On macOS, Alt+letter produces special characters (e.g., Alt+L = ¬, Alt+P = π)
|
|
// Use e.code to get the physical key name when Alt is pressed
|
|
let mainKey = e.key;
|
|
if (e.altKey && e.code) {
|
|
// e.code is like 'KeyL', 'KeyP', 'Digit1', etc.
|
|
if (e.code.startsWith('Key')) {
|
|
mainKey = e.code.replace('Key', '').toLowerCase();
|
|
} else if (e.code.startsWith('Digit')) {
|
|
mainKey = e.code.replace('Digit', '');
|
|
} else {
|
|
// For other keys like Arrow keys, use as-is
|
|
mainKey = e.key;
|
|
}
|
|
}
|
|
keys.push(mainKey);
|
|
|
|
if (isTabShortcut) {
|
|
props.setTabShortcuts({
|
|
...props.tabShortcuts,
|
|
[actionId]: { ...props.tabShortcuts[actionId], keys }
|
|
});
|
|
} else {
|
|
props.setShortcuts({
|
|
...props.shortcuts,
|
|
[actionId]: { ...props.shortcuts[actionId], keys }
|
|
});
|
|
}
|
|
setRecordingId(null);
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
// 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;
|
|
}, {} as Record<string, Theme[]>);
|
|
|
|
const handleThemePickerKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
// Create ordered array: dark themes first, then light, then vibe, then custom (cycling back to dark)
|
|
const allThemes = [...(groupedThemes['dark'] || []), ...(groupedThemes['light'] || []), ...(groupedThemes['vibe'] || [])];
|
|
// 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 ? allThemeIds.length - 1 : currentIndex - 1;
|
|
newThemeId = allThemeIds[prevIndex];
|
|
} else {
|
|
// Tab: go forward
|
|
const nextIndex = (currentIndex + 1) % allThemeIds.length;
|
|
newThemeId = allThemeIds[nextIndex];
|
|
}
|
|
props.setActiveThemeId(newThemeId as ThemeId);
|
|
|
|
// Scroll the newly selected theme button into view
|
|
setTimeout(() => {
|
|
const themeButton = themePickerRef.current?.querySelector(`[data-theme-id="${newThemeId}"]`);
|
|
themeButton?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}, 0);
|
|
}
|
|
};
|
|
|
|
// Theme picker JSX (not a separate component to avoid remount issues)
|
|
const themePickerContent = (
|
|
<div
|
|
ref={themePickerRef}
|
|
className="space-y-6 outline-none"
|
|
tabIndex={0}
|
|
onKeyDown={handleThemePickerKeyDown}
|
|
>
|
|
{['dark', 'light', 'vibe'].map(mode => (
|
|
<div key={mode}>
|
|
<div className="text-xs font-bold uppercase mb-3 flex items-center gap-2" style={{ color: theme.colors.textDim }}>
|
|
{mode === 'dark' ? <Moon className="w-3 h-3" /> : mode === 'light' ? <Sun className="w-3 h-3" /> : <Sparkles className="w-3 h-3" />}
|
|
{mode} Mode
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{groupedThemes[mode]?.map((t: Theme) => (
|
|
<button
|
|
key={t.id}
|
|
data-theme-id={t.id}
|
|
onClick={() => props.setActiveThemeId(t.id)}
|
|
className={`p-3 rounded-lg border text-left transition-all ${props.activeThemeId === t.id ? 'ring-2' : ''}`}
|
|
style={{
|
|
borderColor: theme.colors.border,
|
|
backgroundColor: t.colors.bgSidebar,
|
|
'--tw-ring-color': t.colors.accent
|
|
} as React.CSSProperties}
|
|
tabIndex={-1}
|
|
>
|
|
<div className="flex justify-between items-center mb-2">
|
|
<span className="text-sm font-bold" style={{ color: t.colors.textMain }}>{t.name}</span>
|
|
{props.activeThemeId === t.id && <Check className="w-4 h-4" style={{ color: t.colors.accent }} />}
|
|
</div>
|
|
<div className="flex h-3 rounded overflow-hidden">
|
|
<div className="flex-1" style={{ backgroundColor: t.colors.bgMain }} />
|
|
<div className="flex-1" style={{ backgroundColor: t.colors.bgActivity }} />
|
|
<div className="flex-1" style={{ backgroundColor: t.colors.accent }} />
|
|
</div>
|
|
</button>
|
|
))}
|
|
</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')}
|
|
onImportError={props.onThemeImportError}
|
|
onImportSuccess={props.onThemeImportSuccess}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 modal-overlay flex items-center justify-center z-[9999]"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Settings"
|
|
>
|
|
<div className="w-[650px] h-[600px] rounded-xl border shadow-2xl overflow-hidden flex flex-col"
|
|
style={{ backgroundColor: theme.colors.bgSidebar, borderColor: theme.colors.border }}>
|
|
|
|
<div className="flex border-b" style={{ borderColor: theme.colors.border }}>
|
|
<button onClick={() => setActiveTab('general')} className={`px-4 py-4 text-sm font-bold border-b-2 ${activeTab === 'general' ? 'border-indigo-500' : 'border-transparent'} flex items-center gap-2`} tabIndex={-1} title="General">
|
|
<Settings className="w-4 h-4" />
|
|
{activeTab === 'general' && <span>General</span>}
|
|
</button>
|
|
{FEATURE_FLAGS.LLM_SETTINGS && (
|
|
<button onClick={() => setActiveTab('llm')} className={`px-4 py-4 text-sm font-bold border-b-2 ${activeTab === 'llm' ? 'border-indigo-500' : 'border-transparent'}`} tabIndex={-1} title="LLM">LLM</button>
|
|
)}
|
|
<button onClick={() => setActiveTab('shortcuts')} className={`px-4 py-4 text-sm font-bold border-b-2 ${activeTab === 'shortcuts' ? 'border-indigo-500' : 'border-transparent'} flex items-center gap-2`} tabIndex={-1} title="Shortcuts">
|
|
<Keyboard className="w-4 h-4" />
|
|
{activeTab === 'shortcuts' && <span>Shortcuts</span>}
|
|
</button>
|
|
<button onClick={() => setActiveTab('theme')} className={`px-4 py-4 text-sm font-bold border-b-2 ${activeTab === 'theme' ? 'border-indigo-500' : 'border-transparent'} flex items-center gap-2`} tabIndex={-1} title="Themes">
|
|
<Palette className="w-4 h-4" />
|
|
{activeTab === 'theme' && <span>Themes</span>}
|
|
</button>
|
|
<button onClick={() => setActiveTab('notifications')} className={`px-4 py-4 text-sm font-bold border-b-2 ${activeTab === 'notifications' ? 'border-indigo-500' : 'border-transparent'} flex items-center gap-2`} tabIndex={-1} title="Notifications">
|
|
<Bell className="w-4 h-4" />
|
|
{activeTab === 'notifications' && <span>Notify</span>}
|
|
</button>
|
|
<button onClick={() => setActiveTab('aicommands')} className={`px-4 py-4 text-sm font-bold border-b-2 ${activeTab === 'aicommands' ? 'border-indigo-500' : 'border-transparent'} flex items-center gap-2`} tabIndex={-1} title="AI Commands">
|
|
<Cpu className="w-4 h-4" />
|
|
{activeTab === 'aicommands' && <span>AI Commands</span>}
|
|
</button>
|
|
<button onClick={() => setActiveTab('ssh')} className={`px-4 py-4 text-sm font-bold border-b-2 ${activeTab === 'ssh' ? 'border-indigo-500' : 'border-transparent'} flex items-center gap-2`} tabIndex={-1} title="SSH">
|
|
<Server className="w-4 h-4" />
|
|
{activeTab === 'ssh' && <span>SSH</span>}
|
|
</button>
|
|
<div className="flex-1 flex justify-end items-center pr-4">
|
|
<button onClick={onClose} tabIndex={-1}><X className="w-5 h-5 opacity-50 hover:opacity-100" /></button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 p-6 overflow-y-auto scrollbar-thin">
|
|
{activeTab === 'general' && (
|
|
<div className="space-y-5">
|
|
{/* Font Family */}
|
|
<FontConfigurationPanel
|
|
fontFamily={props.fontFamily}
|
|
setFontFamily={props.setFontFamily}
|
|
systemFonts={systemFonts}
|
|
fontsLoaded={fontsLoaded}
|
|
fontLoading={fontLoading}
|
|
customFonts={customFonts}
|
|
onAddCustomFont={addCustomFont}
|
|
onRemoveCustomFont={removeCustomFont}
|
|
onFontInteraction={handleFontInteraction}
|
|
theme={theme}
|
|
/>
|
|
|
|
{/* Font Size */}
|
|
<div>
|
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2">Font Size</label>
|
|
<ToggleButtonGroup
|
|
options={[
|
|
{ value: 12, label: 'Small' },
|
|
{ value: 14, label: 'Medium' },
|
|
{ value: 16, label: 'Large' },
|
|
{ value: 18, label: 'X-Large' },
|
|
]}
|
|
value={props.fontSize}
|
|
onChange={props.setFontSize}
|
|
theme={theme}
|
|
/>
|
|
</div>
|
|
|
|
{/* Terminal Width */}
|
|
<div>
|
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2">Terminal Width (Columns)</label>
|
|
<ToggleButtonGroup
|
|
options={[80, 100, 120, 160]}
|
|
value={props.terminalWidth}
|
|
onChange={props.setTerminalWidth}
|
|
theme={theme}
|
|
/>
|
|
</div>
|
|
|
|
{/* Log Level */}
|
|
<div>
|
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2">System Log Level</label>
|
|
<ToggleButtonGroup
|
|
options={[
|
|
{ value: 'debug', label: 'Debug', activeColor: '#6366f1' },
|
|
{ value: 'info', label: 'Info', activeColor: '#3b82f6' },
|
|
{ value: 'warn', label: 'Warn', activeColor: '#f59e0b' },
|
|
{ value: 'error', label: 'Error', activeColor: '#ef4444' },
|
|
]}
|
|
value={props.logLevel}
|
|
onChange={props.setLogLevel}
|
|
theme={theme}
|
|
/>
|
|
<p className="text-xs opacity-50 mt-2">
|
|
Higher levels show fewer logs. Debug shows all logs, Error shows only errors.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Max Log Buffer */}
|
|
<div>
|
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2">Maximum Log Buffer</label>
|
|
<ToggleButtonGroup
|
|
options={[1000, 5000, 10000, 25000]}
|
|
value={props.maxLogBuffer}
|
|
onChange={props.setMaxLogBuffer}
|
|
theme={theme}
|
|
/>
|
|
<p className="text-xs opacity-50 mt-2">
|
|
Maximum number of log messages to keep in memory. Older logs are automatically removed.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Max Output Lines */}
|
|
<div>
|
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2">Max Output Lines per Response</label>
|
|
<ToggleButtonGroup
|
|
options={[
|
|
{ value: 15 },
|
|
{ value: 25 },
|
|
{ value: 50 },
|
|
{ value: 100 },
|
|
{ value: Infinity, label: 'All' },
|
|
]}
|
|
value={props.maxOutputLines}
|
|
onChange={props.setMaxOutputLines}
|
|
theme={theme}
|
|
/>
|
|
<p className="text-xs opacity-50 mt-2">
|
|
Long outputs will be collapsed into a scrollable window. Set to "All" to always show full output.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Default Shell */}
|
|
<div>
|
|
<label className="block text-xs font-bold opacity-70 uppercase mb-1 flex items-center gap-2">
|
|
<Terminal className="w-3 h-3" />
|
|
Default Terminal Shell
|
|
</label>
|
|
<p className="text-xs opacity-50 mb-2">
|
|
Choose which shell to use for terminal sessions. Select any shell and configure a custom path if needed.
|
|
</p>
|
|
{shellsLoading ? (
|
|
<div className="text-sm opacity-50 p-2">Loading shells...</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{shellsLoaded && shells.length > 0 ? (
|
|
shells.map((shell) => (
|
|
<button
|
|
key={shell.id}
|
|
onClick={() => {
|
|
props.setDefaultShell(shell.id);
|
|
// Auto-expand shell config when selecting an unavailable shell
|
|
if (!shell.available) {
|
|
setShellConfigExpanded(true);
|
|
}
|
|
}}
|
|
onMouseEnter={handleShellInteraction}
|
|
onFocus={handleShellInteraction}
|
|
className={`w-full text-left p-3 rounded border transition-all ${
|
|
props.defaultShell === shell.id ? 'ring-2' : ''
|
|
} hover:bg-opacity-10`}
|
|
style={{
|
|
borderColor: theme.colors.border,
|
|
backgroundColor: props.defaultShell === shell.id ? theme.colors.accentDim : theme.colors.bgMain,
|
|
'--tw-ring-color': theme.colors.accent,
|
|
color: theme.colors.textMain,
|
|
} as React.CSSProperties}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="font-medium">{shell.name}</div>
|
|
{shell.path && (
|
|
<div className="text-xs opacity-50 font-mono mt-1">{shell.path}</div>
|
|
)}
|
|
</div>
|
|
{shell.available ? (
|
|
props.defaultShell === shell.id ? (
|
|
<Check className="w-4 h-4" style={{ color: theme.colors.accent }} />
|
|
) : (
|
|
<span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: theme.colors.success + '20', color: theme.colors.success }}>
|
|
Available
|
|
</span>
|
|
)
|
|
) : (
|
|
props.defaultShell === shell.id ? (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: theme.colors.warning + '20', color: theme.colors.warning }}>
|
|
Custom Path Required
|
|
</span>
|
|
<Check className="w-4 h-4" style={{ color: theme.colors.accent }} />
|
|
</div>
|
|
) : (
|
|
<span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: theme.colors.warning + '20', color: theme.colors.warning }}>
|
|
Not Found
|
|
</span>
|
|
)
|
|
)}
|
|
</div>
|
|
</button>
|
|
))
|
|
) : (
|
|
/* Show current default shell before detection runs */
|
|
<div className="space-y-2">
|
|
<button
|
|
className="w-full text-left p-3 rounded border ring-2"
|
|
style={{
|
|
borderColor: theme.colors.border,
|
|
backgroundColor: theme.colors.accentDim,
|
|
'--tw-ring-color': theme.colors.accent,
|
|
color: theme.colors.textMain,
|
|
} as React.CSSProperties}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="font-medium">{props.defaultShell.charAt(0).toUpperCase() + props.defaultShell.slice(1)}</div>
|
|
<div className="text-xs opacity-50 font-mono mt-1">Current default</div>
|
|
</div>
|
|
<Check className="w-4 h-4" style={{ color: theme.colors.accent }} />
|
|
</div>
|
|
</button>
|
|
<button
|
|
onClick={handleShellInteraction}
|
|
className="w-full text-left p-3 rounded border hover:bg-white/5 transition-colors"
|
|
style={{
|
|
borderColor: theme.colors.border,
|
|
backgroundColor: theme.colors.bgMain,
|
|
color: theme.colors.textDim,
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Terminal className="w-4 h-4" />
|
|
<span>Detect other available shells...</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Shell Configuration Expandable Section */}
|
|
<button
|
|
onClick={() => setShellConfigExpanded(!shellConfigExpanded)}
|
|
className="w-full flex items-center justify-between p-3 rounded border mt-3 hover:bg-white/5 transition-colors"
|
|
style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}
|
|
>
|
|
<span className="text-sm font-medium" style={{ color: theme.colors.textMain }}>
|
|
Shell Configuration
|
|
</span>
|
|
<ChevronDown
|
|
className={`w-4 h-4 transition-transform ${shellConfigExpanded ? 'rotate-180' : ''}`}
|
|
style={{ color: theme.colors.textDim }}
|
|
/>
|
|
</button>
|
|
|
|
{shellConfigExpanded && (
|
|
<div className="mt-2 space-y-3 p-3 rounded border" style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgActivity }}>
|
|
{/* Custom Shell Path */}
|
|
<div>
|
|
<label className="block text-xs opacity-60 mb-1">Custom Path (optional)</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={props.customShellPath}
|
|
onChange={(e) => props.setCustomShellPath(e.target.value)}
|
|
placeholder="/path/to/shell"
|
|
className="flex-1 p-2 rounded border bg-transparent outline-none text-sm font-mono"
|
|
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
|
|
/>
|
|
{props.customShellPath && (
|
|
<button
|
|
onClick={() => props.setCustomShellPath('')}
|
|
className="px-2 py-1.5 rounded text-xs"
|
|
style={{ backgroundColor: theme.colors.bgMain, color: theme.colors.textDim }}
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
<p className="text-xs opacity-50 mt-1">
|
|
Override the auto-detected shell path. Leave empty to use the detected path.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Shell Arguments */}
|
|
<div>
|
|
<label className="block text-xs opacity-60 mb-1">Additional Arguments (optional)</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={props.shellArgs}
|
|
onChange={(e) => props.setShellArgs(e.target.value)}
|
|
placeholder="--flag value"
|
|
className="flex-1 p-2 rounded border bg-transparent outline-none text-sm font-mono"
|
|
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
|
|
/>
|
|
{props.shellArgs && (
|
|
<button
|
|
onClick={() => props.setShellArgs('')}
|
|
className="px-2 py-1.5 rounded text-xs"
|
|
style={{ backgroundColor: theme.colors.bgMain, color: theme.colors.textDim }}
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
<p className="text-xs opacity-50 mt-1">
|
|
Additional CLI arguments passed to every shell session (e.g., --login, -c).
|
|
</p>
|
|
</div>
|
|
|
|
{/* Shell Environment Variables */}
|
|
<EnvVarsEditor
|
|
envVars={props.shellEnvVars}
|
|
setEnvVars={props.setShellEnvVars}
|
|
theme={theme}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
|
|
{/* GitHub CLI Path */}
|
|
<div>
|
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2 flex items-center gap-2">
|
|
<Terminal className="w-3 h-3" />
|
|
GitHub CLI (gh) Path
|
|
</label>
|
|
<div className="p-3 rounded border" style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}>
|
|
<label className="block text-xs opacity-60 mb-1">Custom Path (optional)</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={props.ghPath}
|
|
onChange={(e) => props.setGhPath(e.target.value)}
|
|
placeholder="/opt/homebrew/bin/gh"
|
|
className="flex-1 p-1.5 rounded border bg-transparent outline-none text-xs font-mono"
|
|
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
|
|
/>
|
|
{props.ghPath && (
|
|
<button
|
|
onClick={() => props.setGhPath('')}
|
|
className="px-2 py-1 rounded text-xs"
|
|
style={{ backgroundColor: theme.colors.bgActivity, color: theme.colors.textDim }}
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
<p className="text-xs opacity-40 mt-2">
|
|
Specify the full path to the <code className="px-1 py-0.5 rounded" style={{ backgroundColor: theme.colors.bgActivity }}>gh</code> binary if it's not in your PATH. Used for Auto Run worktree features.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Input Behavior Settings */}
|
|
<div>
|
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2 flex items-center gap-2">
|
|
<Keyboard className="w-3 h-3" />
|
|
Input Send Behavior
|
|
</label>
|
|
<p className="text-xs opacity-50 mb-3">
|
|
Configure how to send messages in each mode. Choose between Enter or Command+Enter for each input type.
|
|
</p>
|
|
|
|
{/* AI Mode Setting */}
|
|
<div className="mb-4 p-3 rounded border" style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="text-sm font-medium">AI Interaction Mode</label>
|
|
<button
|
|
onClick={() => props.setEnterToSendAI(!props.enterToSendAI)}
|
|
className="px-3 py-1.5 rounded text-xs font-mono transition-all"
|
|
style={{
|
|
backgroundColor: props.enterToSendAI ? theme.colors.accentDim : theme.colors.bgActivity,
|
|
color: theme.colors.textMain,
|
|
border: `1px solid ${theme.colors.border}`
|
|
}}
|
|
>
|
|
{props.enterToSendAI ? 'Enter' : '⌘ + Enter'}
|
|
</button>
|
|
</div>
|
|
<p className="text-xs opacity-50">
|
|
{props.enterToSendAI
|
|
? 'Press Enter to send. Use Shift+Enter for new line.'
|
|
: 'Press Command+Enter to send. Enter creates new line.'}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Terminal Mode Setting */}
|
|
<div className="p-3 rounded border" style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="text-sm font-medium">Terminal Mode</label>
|
|
<button
|
|
onClick={() => props.setEnterToSendTerminal(!props.enterToSendTerminal)}
|
|
className="px-3 py-1.5 rounded text-xs font-mono transition-all"
|
|
style={{
|
|
backgroundColor: props.enterToSendTerminal ? theme.colors.accentDim : theme.colors.bgActivity,
|
|
color: theme.colors.textMain,
|
|
border: `1px solid ${theme.colors.border}`
|
|
}}
|
|
>
|
|
{props.enterToSendTerminal ? 'Enter' : '⌘ + Enter'}
|
|
</button>
|
|
</div>
|
|
<p className="text-xs opacity-50">
|
|
{props.enterToSendTerminal
|
|
? 'Press Enter to send. Use Shift+Enter for new line.'
|
|
: 'Press Command+Enter to send. Enter creates new line.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Default History Toggle */}
|
|
<SettingCheckbox
|
|
icon={History}
|
|
sectionLabel="Default History Toggle"
|
|
title="Enable "History" by default for new tabs"
|
|
description="When enabled, new AI tabs will have the "History" toggle on by default, saving a synopsis after each completion"
|
|
checked={props.defaultSaveToHistory}
|
|
onChange={props.setDefaultSaveToHistory}
|
|
theme={theme}
|
|
/>
|
|
|
|
{/* Default Thinking Toggle */}
|
|
<SettingCheckbox
|
|
icon={Brain}
|
|
sectionLabel="Default Thinking Toggle"
|
|
title="Enable "Thinking" by default for new tabs"
|
|
description="When enabled, new AI tabs will show streaming thinking/reasoning content as the AI works, instead of waiting for the final result"
|
|
checked={props.defaultShowThinking}
|
|
onChange={props.setDefaultShowThinking}
|
|
theme={theme}
|
|
/>
|
|
|
|
{/* Check for Updates on Startup */}
|
|
<SettingCheckbox
|
|
icon={Download}
|
|
sectionLabel="Updates"
|
|
title="Check for updates on startup"
|
|
description="Automatically check for new Maestro versions when the app starts"
|
|
checked={props.checkForUpdatesOnStartup}
|
|
onChange={props.setCheckForUpdatesOnStartup}
|
|
theme={theme}
|
|
/>
|
|
|
|
{/* Beta Updates */}
|
|
<SettingCheckbox
|
|
icon={FlaskConical}
|
|
sectionLabel="Pre-release Channel"
|
|
title="Include beta and release candidate updates"
|
|
description="Opt-in to receive pre-release versions (e.g., v0.11.1-rc, v0.12.0-beta). These may contain experimental features and bugs."
|
|
checked={props.enableBetaUpdates}
|
|
onChange={props.setEnableBetaUpdates}
|
|
theme={theme}
|
|
/>
|
|
|
|
{/* Crash Reporting */}
|
|
<SettingCheckbox
|
|
icon={Bug}
|
|
sectionLabel="Privacy"
|
|
title="Send anonymous crash reports"
|
|
description="Help improve Maestro by automatically sending crash reports. No personal data is collected. Changes take effect after restart."
|
|
checked={props.crashReportingEnabled}
|
|
onChange={props.setCrashReportingEnabled}
|
|
theme={theme}
|
|
/>
|
|
|
|
{/* Context Window Warnings */}
|
|
<div>
|
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2 flex items-center gap-2">
|
|
<AlertTriangle className="w-3 h-3" />
|
|
Context Window Warnings
|
|
</label>
|
|
<label
|
|
className="flex items-center gap-3 p-3 rounded border cursor-pointer hover:bg-opacity-10"
|
|
style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={contextManagementSettings.contextWarningsEnabled}
|
|
onChange={(e) => updateContextManagementSettings({
|
|
contextWarningsEnabled: e.target.checked
|
|
})}
|
|
className="w-4 h-4"
|
|
style={{ accentColor: theme.colors.accent }}
|
|
/>
|
|
<div className="flex-1">
|
|
<div className="font-medium" style={{ color: theme.colors.textMain }}>
|
|
Show context consumption warnings
|
|
</div>
|
|
<div className="text-xs opacity-50 mt-0.5" style={{ color: theme.colors.textDim }}>
|
|
Display warning banners when context window usage reaches configurable thresholds
|
|
</div>
|
|
</div>
|
|
</label>
|
|
|
|
{/* Threshold Sliders (ghosted when disabled) */}
|
|
<div
|
|
className="mt-3 p-3 rounded border space-y-4"
|
|
style={{
|
|
borderColor: theme.colors.border,
|
|
backgroundColor: theme.colors.bgMain,
|
|
opacity: contextManagementSettings.contextWarningsEnabled ? 1 : 0.4,
|
|
pointerEvents: contextManagementSettings.contextWarningsEnabled ? 'auto' : 'none',
|
|
}}
|
|
>
|
|
{/* Yellow Warning Threshold */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="text-xs font-medium flex items-center gap-2" style={{ color: theme.colors.textMain }}>
|
|
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: '#eab308' }} />
|
|
Yellow warning threshold
|
|
</label>
|
|
<span
|
|
className="text-xs font-mono px-2 py-0.5 rounded"
|
|
style={{ backgroundColor: 'rgba(234, 179, 8, 0.2)', color: '#fde047' }}
|
|
>
|
|
{contextManagementSettings.contextWarningYellowThreshold}%
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min={30}
|
|
max={90}
|
|
step={5}
|
|
value={contextManagementSettings.contextWarningYellowThreshold}
|
|
onChange={(e) => {
|
|
const newYellow = Number(e.target.value);
|
|
// Validation: ensure yellow < red by at least 10%
|
|
if (newYellow >= contextManagementSettings.contextWarningRedThreshold) {
|
|
// Bump red threshold up
|
|
updateContextManagementSettings({
|
|
contextWarningYellowThreshold: newYellow,
|
|
contextWarningRedThreshold: Math.min(95, newYellow + 10),
|
|
});
|
|
} else {
|
|
updateContextManagementSettings({ contextWarningYellowThreshold: newYellow });
|
|
}
|
|
}}
|
|
className="w-full h-2 rounded-lg appearance-none cursor-pointer"
|
|
style={{
|
|
background: `linear-gradient(to right, #eab308 0%, #eab308 ${((contextManagementSettings.contextWarningYellowThreshold - 30) / 60) * 100}%, ${theme.colors.bgActivity} ${((contextManagementSettings.contextWarningYellowThreshold - 30) / 60) * 100}%, ${theme.colors.bgActivity} 100%)`,
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Red Warning Threshold */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="text-xs font-medium flex items-center gap-2" style={{ color: theme.colors.textMain }}>
|
|
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: '#ef4444' }} />
|
|
Red warning threshold
|
|
</label>
|
|
<span
|
|
className="text-xs font-mono px-2 py-0.5 rounded"
|
|
style={{ backgroundColor: 'rgba(239, 68, 68, 0.2)', color: '#fca5a5' }}
|
|
>
|
|
{contextManagementSettings.contextWarningRedThreshold}%
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min={50}
|
|
max={95}
|
|
step={5}
|
|
value={contextManagementSettings.contextWarningRedThreshold}
|
|
onChange={(e) => {
|
|
const newRed = Number(e.target.value);
|
|
// Validation: ensure red > yellow by at least 10%
|
|
if (newRed <= contextManagementSettings.contextWarningYellowThreshold) {
|
|
// Bump yellow threshold down
|
|
updateContextManagementSettings({
|
|
contextWarningRedThreshold: newRed,
|
|
contextWarningYellowThreshold: Math.max(30, newRed - 10),
|
|
});
|
|
} else {
|
|
updateContextManagementSettings({ contextWarningRedThreshold: newRed });
|
|
}
|
|
}}
|
|
className="w-full h-2 rounded-lg appearance-none cursor-pointer"
|
|
style={{
|
|
background: `linear-gradient(to right, #ef4444 0%, #ef4444 ${((contextManagementSettings.contextWarningRedThreshold - 50) / 45) * 100}%, ${theme.colors.bgActivity} ${((contextManagementSettings.contextWarningRedThreshold - 50) / 45) * 100}%, ${theme.colors.bgActivity} 100%)`,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Data Management */}
|
|
<div>
|
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2 flex items-center gap-2">
|
|
<Database className="w-3 h-3" />
|
|
Usage & Stats
|
|
</label>
|
|
<div
|
|
className="p-3 rounded border space-y-3"
|
|
style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}
|
|
>
|
|
{/* Enable/Disable Stats Collection */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm" style={{ color: theme.colors.textMain }}>Enable stats collection</p>
|
|
<p className="text-xs opacity-50 mt-0.5">Track queries and Auto Run sessions for the dashboard.</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setStatsCollectionEnabled(!statsCollectionEnabled)}
|
|
className={`relative w-10 h-5 rounded-full transition-colors ${
|
|
statsCollectionEnabled ? '' : ''
|
|
}`}
|
|
style={{
|
|
backgroundColor: statsCollectionEnabled ? theme.colors.accent : theme.colors.bgActivity,
|
|
}}
|
|
role="switch"
|
|
aria-checked={statsCollectionEnabled}
|
|
>
|
|
<span
|
|
className={`absolute left-0 top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
|
|
statsCollectionEnabled ? 'translate-x-5' : 'translate-x-0.5'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Default Time Range */}
|
|
<div>
|
|
<label className="block text-xs opacity-60 mb-2">Default dashboard time range</label>
|
|
<select
|
|
value={defaultStatsTimeRange}
|
|
onChange={(e) => setDefaultStatsTimeRange(e.target.value as 'day' | 'week' | 'month' | 'year' | 'all')}
|
|
className="w-full p-2 rounded border bg-transparent outline-none text-sm"
|
|
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
|
|
>
|
|
<option value="day">Last 24 hours</option>
|
|
<option value="week">Last 7 days</option>
|
|
<option value="month">Last 30 days</option>
|
|
<option value="year">Last 365 days</option>
|
|
<option value="all">All time</option>
|
|
</select>
|
|
<p className="text-xs opacity-50 mt-1">
|
|
Time range shown when opening the Usage Dashboard.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Divider */}
|
|
<div className="border-t" style={{ borderColor: theme.colors.border }} />
|
|
|
|
{/* Database Size Display */}
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm" style={{ color: theme.colors.textDim }}>Database size</span>
|
|
<span className="text-sm font-mono" style={{ color: theme.colors.textMain }}>
|
|
{statsDbSize !== null
|
|
? (statsDbSize / 1024 / 1024).toFixed(2) + ' MB'
|
|
: 'Loading...'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Clear Old Data Dropdown */}
|
|
<div>
|
|
<label className="block text-xs opacity-60 mb-2">Clear stats older than...</label>
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
id="clear-stats-period"
|
|
className="flex-1 p-2 rounded border bg-transparent outline-none text-sm"
|
|
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
|
|
defaultValue=""
|
|
disabled={statsClearing}
|
|
>
|
|
<option value="" disabled>Select a time period</option>
|
|
<option value="7">7 days</option>
|
|
<option value="30">30 days</option>
|
|
<option value="90">90 days</option>
|
|
<option value="180">6 months</option>
|
|
<option value="365">1 year</option>
|
|
</select>
|
|
<button
|
|
onClick={async () => {
|
|
const select = document.getElementById('clear-stats-period') as HTMLSelectElement;
|
|
const days = parseInt(select.value, 10);
|
|
if (!days || isNaN(days)) {
|
|
return; // No selection
|
|
}
|
|
setStatsClearing(true);
|
|
setStatsClearResult(null);
|
|
try {
|
|
const result = await window.maestro.stats.clearOldData(days);
|
|
setStatsClearResult(result);
|
|
if (result.success) {
|
|
// Refresh database size
|
|
const newSize = await window.maestro.stats.getDatabaseSize();
|
|
setStatsDbSize(newSize);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to clear old stats:', err);
|
|
setStatsClearResult({
|
|
success: false,
|
|
deletedQueryEvents: 0,
|
|
deletedAutoRunSessions: 0,
|
|
deletedAutoRunTasks: 0,
|
|
error: err instanceof Error ? err.message : 'Unknown error',
|
|
});
|
|
} finally {
|
|
setStatsClearing(false);
|
|
}
|
|
}}
|
|
disabled={statsClearing}
|
|
className="px-3 py-2 rounded text-xs font-medium transition-colors flex items-center gap-2 disabled:opacity-50"
|
|
style={{
|
|
backgroundColor: theme.colors.error + '20',
|
|
color: theme.colors.error,
|
|
border: `1px solid ${theme.colors.error}40`,
|
|
}}
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
{statsClearing ? 'Clearing...' : 'Clear'}
|
|
</button>
|
|
</div>
|
|
<p className="text-xs opacity-50 mt-2">
|
|
Remove old query events, Auto Run sessions, and tasks from the stats database.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Clear Result Feedback */}
|
|
{statsClearResult && (
|
|
<div
|
|
className="p-2 rounded text-xs flex items-start gap-2"
|
|
style={{
|
|
backgroundColor: statsClearResult.success
|
|
? theme.colors.success + '20'
|
|
: theme.colors.error + '20',
|
|
color: statsClearResult.success ? theme.colors.success : theme.colors.error,
|
|
}}
|
|
>
|
|
{statsClearResult.success ? (
|
|
<>
|
|
<Check className="w-3 h-3 flex-shrink-0 mt-0.5" />
|
|
<span>
|
|
Cleared {statsClearResult.deletedQueryEvents + statsClearResult.deletedAutoRunSessions + statsClearResult.deletedAutoRunTasks} records
|
|
({statsClearResult.deletedQueryEvents} queries, {statsClearResult.deletedAutoRunSessions} sessions, {statsClearResult.deletedAutoRunTasks} tasks)
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<X className="w-3 h-3 flex-shrink-0 mt-0.5" />
|
|
<span>{statsClearResult.error || 'Failed to clear stats data'}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Document Graph Settings */}
|
|
<div>
|
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2 flex items-center gap-2">
|
|
<Sparkles className="w-3 h-3" />
|
|
Document Graph
|
|
</label>
|
|
<div
|
|
className="p-3 rounded border space-y-3"
|
|
style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}
|
|
>
|
|
{/* Default Layout Mode */}
|
|
<div>
|
|
<label className="block text-xs opacity-60 mb-2">Default layout mode</label>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setDocumentGraphLayoutMode('force')}
|
|
className={`flex-1 px-3 py-2 rounded text-xs font-medium transition-colors ${
|
|
documentGraphLayoutMode === 'force' ? 'ring-2' : ''
|
|
}`}
|
|
style={{
|
|
backgroundColor: documentGraphLayoutMode === 'force'
|
|
? theme.colors.accent + '20'
|
|
: theme.colors.bgActivity,
|
|
color: documentGraphLayoutMode === 'force'
|
|
? theme.colors.accent
|
|
: theme.colors.textDim,
|
|
borderColor: documentGraphLayoutMode === 'force'
|
|
? theme.colors.accent
|
|
: 'transparent',
|
|
}}
|
|
>
|
|
Force-Directed
|
|
</button>
|
|
<button
|
|
onClick={() => setDocumentGraphLayoutMode('hierarchical')}
|
|
className={`flex-1 px-3 py-2 rounded text-xs font-medium transition-colors ${
|
|
documentGraphLayoutMode === 'hierarchical' ? 'ring-2' : ''
|
|
}`}
|
|
style={{
|
|
backgroundColor: documentGraphLayoutMode === 'hierarchical'
|
|
? theme.colors.accent + '20'
|
|
: theme.colors.bgActivity,
|
|
color: documentGraphLayoutMode === 'hierarchical'
|
|
? theme.colors.accent
|
|
: theme.colors.textDim,
|
|
borderColor: documentGraphLayoutMode === 'hierarchical'
|
|
? theme.colors.accent
|
|
: 'transparent',
|
|
}}
|
|
>
|
|
Hierarchical
|
|
</button>
|
|
</div>
|
|
<p className="text-xs opacity-50 mt-1">
|
|
Layout algorithm used when opening the Document Graph.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Show External Links */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm" style={{ color: theme.colors.textMain }}>Show external links by default</p>
|
|
<p className="text-xs opacity-50 mt-0.5">Display external website links as nodes. Can be toggled in the graph view.</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setDocumentGraphShowExternalLinks(!documentGraphShowExternalLinks)}
|
|
className="relative w-10 h-5 rounded-full transition-colors"
|
|
style={{
|
|
backgroundColor: documentGraphShowExternalLinks ? theme.colors.accent : theme.colors.bgActivity,
|
|
}}
|
|
role="switch"
|
|
aria-checked={documentGraphShowExternalLinks}
|
|
>
|
|
<span
|
|
className={`absolute left-0 top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
|
|
documentGraphShowExternalLinks ? 'translate-x-5' : 'translate-x-0.5'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Max Nodes */}
|
|
<div>
|
|
<label className="block text-xs opacity-60 mb-2">Maximum nodes to display</label>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="range"
|
|
min={50}
|
|
max={1000}
|
|
step={50}
|
|
value={documentGraphMaxNodes}
|
|
onChange={(e) => setDocumentGraphMaxNodes(Number(e.target.value))}
|
|
className="flex-1 h-2 rounded-lg appearance-none cursor-pointer"
|
|
style={{
|
|
background: `linear-gradient(to right, ${theme.colors.accent} 0%, ${theme.colors.accent} ${((documentGraphMaxNodes - 50) / 950) * 100}%, ${theme.colors.bgActivity} ${((documentGraphMaxNodes - 50) / 950) * 100}%, ${theme.colors.bgActivity} 100%)`,
|
|
}}
|
|
/>
|
|
<span className="text-sm font-mono w-12 text-right" style={{ color: theme.colors.textMain }}>
|
|
{documentGraphMaxNodes}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs opacity-50 mt-1">
|
|
Limits initial graph size for performance. Use "Load more" to show additional nodes.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Settings Storage Location */}
|
|
<div
|
|
className="flex items-start gap-3 p-4 rounded-xl border relative"
|
|
style={{ backgroundColor: theme.colors.bgMain, borderColor: theme.colors.border }}
|
|
>
|
|
{/* BETA Badge */}
|
|
<div
|
|
className="absolute top-2 right-2 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase"
|
|
style={{ backgroundColor: theme.colors.warning + '30', color: theme.colors.warning }}
|
|
>
|
|
Beta
|
|
</div>
|
|
<div
|
|
className="p-2 rounded-lg flex-shrink-0"
|
|
style={{ backgroundColor: theme.colors.accent + '20' }}
|
|
>
|
|
<FolderSync className="w-5 h-5" style={{ color: theme.colors.accent }} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-[10px] uppercase font-bold opacity-50 mb-1">Storage Location</p>
|
|
<p className="font-semibold mb-1">Settings folder</p>
|
|
<p className="text-xs opacity-60 mb-2">
|
|
Choose where Maestro stores settings, sessions, and groups. Use a synced folder (iCloud Drive, Dropbox, OneDrive) to share across devices.
|
|
</p>
|
|
<p className="text-xs opacity-50 mb-4 italic">
|
|
Note: Only run Maestro on one device at a time to avoid sync conflicts.
|
|
</p>
|
|
|
|
{/* Default Location */}
|
|
<div className="mb-3">
|
|
<p className="text-[10px] uppercase font-bold opacity-40 mb-1">Default Location</p>
|
|
<div
|
|
className="text-xs p-2 rounded font-mono truncate"
|
|
style={{ backgroundColor: theme.colors.bgActivity }}
|
|
title={defaultStoragePath}
|
|
>
|
|
{defaultStoragePath || 'Loading...'}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Current Location (if different) */}
|
|
{customSyncPath && (
|
|
<div className="mb-3">
|
|
<p className="text-[10px] uppercase font-bold opacity-40 mb-1">Current Location (Custom)</p>
|
|
<div
|
|
className="text-xs p-2 rounded font-mono truncate flex items-center gap-2"
|
|
style={{ backgroundColor: theme.colors.accent + '15', border: `1px solid ${theme.colors.accent}40` }}
|
|
title={customSyncPath}
|
|
>
|
|
<Cloud className="w-3 h-3 flex-shrink-0" style={{ color: theme.colors.accent }} />
|
|
<span className="truncate">{customSyncPath}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<button
|
|
onClick={async () => {
|
|
const folder = await window.maestro.sync.selectSyncFolder();
|
|
if (folder) {
|
|
setSyncMigrating(true);
|
|
setSyncError(null);
|
|
setSyncMigratedCount(null);
|
|
try {
|
|
const result = await window.maestro.sync.setCustomPath(folder);
|
|
if (result.success) {
|
|
setCustomSyncPath(folder);
|
|
setCurrentStoragePath(folder);
|
|
setSyncRestartRequired(true);
|
|
if (result.migrated !== undefined) {
|
|
setSyncMigratedCount(result.migrated);
|
|
}
|
|
} else {
|
|
setSyncError(result.error || 'Failed to change storage location');
|
|
}
|
|
if (result.errors && result.errors.length > 0) {
|
|
setSyncError(result.errors.join(', '));
|
|
}
|
|
} finally {
|
|
setSyncMigrating(false);
|
|
}
|
|
}
|
|
}}
|
|
disabled={syncMigrating}
|
|
className="flex items-center gap-2 px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50"
|
|
style={{
|
|
backgroundColor: theme.colors.accent,
|
|
color: theme.colors.bgMain,
|
|
}}
|
|
>
|
|
<Folder className="w-3 h-3" />
|
|
{syncMigrating ? 'Migrating...' : (customSyncPath ? 'Change Folder...' : 'Choose Folder...')}
|
|
</button>
|
|
|
|
{customSyncPath && (
|
|
<button
|
|
onClick={async () => {
|
|
setSyncMigrating(true);
|
|
setSyncError(null);
|
|
setSyncMigratedCount(null);
|
|
try {
|
|
const result = await window.maestro.sync.setCustomPath(null);
|
|
if (result.success) {
|
|
setCustomSyncPath(undefined);
|
|
setCurrentStoragePath(defaultStoragePath);
|
|
setSyncRestartRequired(true);
|
|
if (result.migrated !== undefined) {
|
|
setSyncMigratedCount(result.migrated);
|
|
}
|
|
} else {
|
|
setSyncError(result.error || 'Failed to reset storage location');
|
|
}
|
|
} finally {
|
|
setSyncMigrating(false);
|
|
}
|
|
}}
|
|
disabled={syncMigrating}
|
|
className="flex items-center gap-2 px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50"
|
|
style={{
|
|
backgroundColor: theme.colors.border,
|
|
color: theme.colors.textMain,
|
|
}}
|
|
title="Reset to default location"
|
|
>
|
|
<RotateCcw className="w-3 h-3" />
|
|
Use Default
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Success Message */}
|
|
{syncMigratedCount !== null && syncMigratedCount > 0 && !syncError && (
|
|
<div
|
|
className="mt-3 p-2 rounded text-xs flex items-center gap-2"
|
|
style={{
|
|
backgroundColor: theme.colors.success + '20',
|
|
color: theme.colors.success,
|
|
}}
|
|
>
|
|
<Check className="w-3 h-3" />
|
|
Migrated {syncMigratedCount} settings file{syncMigratedCount !== 1 ? 's' : ''}
|
|
</div>
|
|
)}
|
|
|
|
{/* Error Message */}
|
|
{syncError && (
|
|
<div
|
|
className="mt-3 p-2 rounded text-xs flex items-start gap-2"
|
|
style={{
|
|
backgroundColor: theme.colors.error + '20',
|
|
color: theme.colors.error,
|
|
}}
|
|
>
|
|
<X className="w-3 h-3 flex-shrink-0 mt-0.5" />
|
|
<span>{syncError}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Restart Required Warning */}
|
|
{syncRestartRequired && !syncError && (
|
|
<div
|
|
className="mt-3 p-2 rounded text-xs flex items-center gap-2"
|
|
style={{
|
|
backgroundColor: theme.colors.warning + '20',
|
|
color: theme.colors.warning,
|
|
}}
|
|
>
|
|
<RotateCcw className="w-3 h-3" />
|
|
Restart Maestro for changes to take effect
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'llm' && FEATURE_FLAGS.LLM_SETTINGS && (
|
|
<div className="space-y-5">
|
|
<div>
|
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2">LLM Provider</label>
|
|
<select
|
|
value={props.llmProvider}
|
|
onChange={(e) => props.setLlmProvider(e.target.value as LLMProvider)}
|
|
className="w-full p-2 rounded border bg-transparent outline-none"
|
|
style={{ borderColor: theme.colors.border }}
|
|
>
|
|
<option value="openrouter">OpenRouter</option>
|
|
<option value="anthropic">Anthropic</option>
|
|
<option value="ollama">Ollama (Local)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2">Model Slug</label>
|
|
<input
|
|
value={props.modelSlug}
|
|
onChange={(e) => props.setModelSlug(e.target.value)}
|
|
className="w-full p-2 rounded border bg-transparent outline-none"
|
|
style={{ borderColor: theme.colors.border }}
|
|
placeholder={props.llmProvider === 'ollama' ? 'llama3:latest' : 'anthropic/claude-3.5-sonnet'}
|
|
/>
|
|
</div>
|
|
|
|
{props.llmProvider !== 'ollama' && (
|
|
<div>
|
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2">API Key</label>
|
|
<div className="flex items-center border rounded px-3 py-2" style={{ backgroundColor: theme.colors.bgMain, borderColor: theme.colors.border }}>
|
|
<Key className="w-4 h-4 mr-2 opacity-50" />
|
|
<input
|
|
type="password"
|
|
value={props.apiKey}
|
|
onChange={(e) => props.setApiKey(e.target.value)}
|
|
className="bg-transparent flex-1 text-sm outline-none"
|
|
placeholder="sk-..."
|
|
/>
|
|
</div>
|
|
<p className="text-[10px] mt-2 opacity-50">Keys are stored locally in ~/.maestro/settings.json</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Test Connection */}
|
|
<div className="pt-4 border-t" style={{ borderColor: theme.colors.border }}>
|
|
<button
|
|
onClick={testLLMConnection}
|
|
disabled={testingLLM || (props.llmProvider !== 'ollama' && !props.apiKey)}
|
|
className="w-full py-3 rounded-lg font-bold text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
|
style={{
|
|
backgroundColor: theme.colors.accent,
|
|
color: theme.colors.accentForeground,
|
|
}}
|
|
>
|
|
{testingLLM ? 'Testing Connection...' : 'Test Connection'}
|
|
</button>
|
|
|
|
{testResult.status && (
|
|
<div
|
|
className="mt-3 p-3 rounded-lg text-sm"
|
|
style={{
|
|
backgroundColor: testResult.status === 'success' ? theme.colors.success + '20' : theme.colors.error + '20',
|
|
color: testResult.status === 'success' ? theme.colors.success : theme.colors.error,
|
|
border: `1px solid ${testResult.status === 'success' ? theme.colors.success : theme.colors.error}`,
|
|
}}
|
|
>
|
|
{testResult.message}
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-[10px] mt-3 opacity-50 text-center">
|
|
Test sends a simple prompt to verify connectivity and configuration
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'shortcuts' && (() => {
|
|
const allShortcuts = [
|
|
...Object.values(props.shortcuts).map(sc => ({ ...sc, isTabShortcut: false })),
|
|
...Object.values(props.tabShortcuts).map(sc => ({ ...sc, isTabShortcut: true })),
|
|
];
|
|
const totalShortcuts = allShortcuts.length;
|
|
const filteredShortcuts = allShortcuts
|
|
.filter((sc) => sc.label.toLowerCase().includes(shortcutsFilter.toLowerCase()));
|
|
const filteredCount = filteredShortcuts.length;
|
|
|
|
// Group shortcuts by category
|
|
const generalShortcuts = filteredShortcuts.filter(sc => !sc.isTabShortcut);
|
|
const tabShortcutsFiltered = filteredShortcuts.filter(sc => sc.isTabShortcut);
|
|
|
|
const renderShortcutItem = (sc: Shortcut & { isTabShortcut: boolean }) => (
|
|
<div key={sc.id} className="flex items-center justify-between p-3 rounded border" style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}>
|
|
<span className="text-sm font-medium" style={{ color: theme.colors.textMain }}>{sc.label}</span>
|
|
<button
|
|
onClick={(e) => {
|
|
setRecordingId(sc.id);
|
|
e.currentTarget.focus();
|
|
}}
|
|
onKeyDownCapture={(e) => {
|
|
if (recordingId === sc.id) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleRecord(e, sc.id, sc.isTabShortcut);
|
|
}
|
|
}}
|
|
className={`px-3 py-1.5 rounded border text-xs font-mono min-w-[80px] text-center transition-colors ${recordingId === sc.id ? 'ring-2' : ''}`}
|
|
style={{
|
|
borderColor: recordingId === sc.id ? theme.colors.accent : theme.colors.border,
|
|
backgroundColor: recordingId === sc.id ? theme.colors.accentDim : theme.colors.bgActivity,
|
|
color: recordingId === sc.id ? theme.colors.accent : theme.colors.textDim,
|
|
'--tw-ring-color': theme.colors.accent
|
|
} as React.CSSProperties}
|
|
>
|
|
{recordingId === sc.id ? 'Press keys...' : formatShortcutKeys(sc.keys)}
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="flex flex-col" style={{ minHeight: '450px' }}>
|
|
{props.hasNoAgents && (
|
|
<p className="text-xs mb-3 px-2 py-1.5 rounded" style={{ backgroundColor: theme.colors.accent + '20', color: theme.colors.accent }}>
|
|
Note: Most functionality is unavailable until you've created your first agent.
|
|
</p>
|
|
)}
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<input
|
|
ref={shortcutsFilterRef}
|
|
type="text"
|
|
value={shortcutsFilter}
|
|
onChange={(e) => setShortcutsFilter(e.target.value)}
|
|
placeholder="Filter shortcuts..."
|
|
className="flex-1 px-3 py-2 rounded border bg-transparent outline-none text-sm"
|
|
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
|
|
/>
|
|
<span className="text-xs px-2 py-1.5 rounded font-medium" style={{ backgroundColor: theme.colors.bgActivity, color: theme.colors.textDim }}>
|
|
{shortcutsFilter ? `${filteredCount} / ${totalShortcuts}` : totalShortcuts}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs opacity-50 mb-3" style={{ color: theme.colors.textDim }}>
|
|
Not all shortcuts can be modified. Press <kbd className="px-1.5 py-0.5 rounded font-mono" style={{ backgroundColor: theme.colors.bgActivity }}>⌘/</kbd> from the main interface to view the full list of keyboard shortcuts.
|
|
</p>
|
|
<div className="space-y-4 flex-1 overflow-y-auto pr-2 scrollbar-thin">
|
|
{/* General Shortcuts Section */}
|
|
{generalShortcuts.length > 0 && (
|
|
<div>
|
|
<h3 className="text-xs font-bold uppercase mb-2 px-1" style={{ color: theme.colors.textDim }}>
|
|
General
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{generalShortcuts.map(renderShortcutItem)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* AI Tab Shortcuts Section */}
|
|
{tabShortcutsFiltered.length > 0 && (
|
|
<div>
|
|
<h3 className="text-xs font-bold uppercase mb-2 px-1" style={{ color: theme.colors.textDim }}>
|
|
AI Tab
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{tabShortcutsFiltered.map(renderShortcutItem)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{activeTab === 'theme' && themePickerContent}
|
|
|
|
{activeTab === 'notifications' && (
|
|
<NotificationsPanel
|
|
osNotificationsEnabled={props.osNotificationsEnabled}
|
|
setOsNotificationsEnabled={props.setOsNotificationsEnabled}
|
|
audioFeedbackEnabled={props.audioFeedbackEnabled}
|
|
setAudioFeedbackEnabled={props.setAudioFeedbackEnabled}
|
|
audioFeedbackCommand={props.audioFeedbackCommand}
|
|
setAudioFeedbackCommand={props.setAudioFeedbackCommand}
|
|
toastDuration={props.toastDuration}
|
|
setToastDuration={props.setToastDuration}
|
|
theme={theme}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'aicommands' && (
|
|
<div className="space-y-8">
|
|
<AICommandsPanel
|
|
theme={theme}
|
|
customAICommands={props.customAICommands}
|
|
setCustomAICommands={props.setCustomAICommands}
|
|
/>
|
|
|
|
{/* Divider */}
|
|
<div
|
|
className="border-t"
|
|
style={{ borderColor: theme.colors.border }}
|
|
/>
|
|
|
|
{/* Spec Kit Commands Section */}
|
|
<SpecKitCommandsPanel theme={theme} />
|
|
|
|
{/* Divider */}
|
|
<div
|
|
className="border-t"
|
|
style={{ borderColor: theme.colors.border }}
|
|
/>
|
|
|
|
{/* OpenSpec Commands Section */}
|
|
<OpenSpecCommandsPanel theme={theme} />
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'ssh' && (
|
|
<div className="space-y-5">
|
|
<SshRemotesSection theme={theme} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|