diff --git a/src/config.ts b/src/config.ts index a870a122c..10d34aa41 100644 --- a/src/config.ts +++ b/src/config.ts @@ -38,6 +38,17 @@ export type CommitConfig = { export type ProviderConfig = Partial>; +export type ReasoningLevel = 'disabled' | 'low' | 'medium' | 'high'; +export type CompletionSound = + | 'off' + | 'terminal-bell' + | 'fx-ok01' + | 'fx-ack01' + | 'custom'; +export type PlaySoundsTiming = 'always' | 'when-focused' | 'when-unfocused'; +export type DiffDisplayMode = 'github' | 'unified'; +export type Theme = 'dark' | 'light'; + export type Config = { model: string; planModel: string; @@ -63,6 +74,14 @@ export type Config = { autoUpdate?: boolean; browser?: boolean; temperature?: number; + reasoningLevel?: ReasoningLevel; + completionSound?: CompletionSound; + playSounds?: PlaySoundsTiming; + showTips?: boolean; + diffDisplayMode?: DiffDisplayMode; + respectGitignore?: boolean; + theme?: Theme; + autoConnectIDE?: boolean; }; const DEFAULT_CONFIG: Partial = { @@ -77,6 +96,14 @@ const DEFAULT_CONFIG: Partial = { outputFormat: 'text', autoUpdate: true, browser: false, + reasoningLevel: 'disabled', + completionSound: 'off', + playSounds: 'always', + showTips: true, + diffDisplayMode: 'unified', + respectGitignore: true, + theme: 'dark', + autoConnectIDE: false, }; const VALID_CONFIG_KEYS = [ ...Object.keys(DEFAULT_CONFIG), @@ -92,6 +119,14 @@ const VALID_CONFIG_KEYS = [ 'provider', 'browser', 'temperature', + 'reasoningLevel', + 'completionSound', + 'playSounds', + 'showTips', + 'diffDisplayMode', + 'respectGitignore', + 'theme', + 'autoConnectIDE', ]; const ARRAY_CONFIG_KEYS = ['plugins']; const OBJECT_CONFIG_KEYS = ['mcpServers', 'commit', 'provider']; @@ -101,7 +136,19 @@ const BOOLEAN_CONFIG_KEYS = [ 'autoCompact', 'autoUpdate', 'browser', + 'showTips', + 'respectGitignore', + 'autoConnectIDE', ]; +const ENUM_CONFIG_KEYS = { + approvalMode: ['default', 'autoEdit', 'yolo'], + reasoningLevel: ['disabled', 'low', 'medium', 'high'], + completionSound: ['off', 'terminal-bell', 'fx-ok01', 'fx-ack01', 'custom'], + playSounds: ['always', 'when-focused', 'when-unfocused'], + diffDisplayMode: ['github', 'unified'], + theme: ['dark', 'light'], + outputFormat: ['text', 'stream-json', 'json'], +} as const; export class ConfigManager { globalConfig: Partial; @@ -317,6 +364,16 @@ export class ConfigManager { if (OBJECT_CONFIG_KEYS.includes(key)) { newValue = JSON.parse(value); } + // Validate enum values + if (key in ENUM_CONFIG_KEYS) { + const validValues = + ENUM_CONFIG_KEYS[key as keyof typeof ENUM_CONFIG_KEYS]; + if (!validValues.includes(newValue as never)) { + throw new Error( + `Invalid value "${newValue}" for config key "${key}". Valid values are: ${validValues.join(', ')}`, + ); + } + } (config[key as keyof Config] as any) = newValue; } diff --git a/src/slash-commands/builtin/index.ts b/src/slash-commands/builtin/index.ts index 45ab50c54..1a7fd02e8 100644 --- a/src/slash-commands/builtin/index.ts +++ b/src/slash-commands/builtin/index.ts @@ -12,6 +12,7 @@ import { createModelCommand } from './model'; import { createOutputStyleCommand } from './output-style'; import { createResumeCommand } from './resume'; import { createReviewCommand } from './review'; +import { createSettingCommand } from './setting'; import { brainstormCommand } from './spec/brainstorm'; import { executePlanCommand } from './spec/execute-plan'; import { saveDesignCommand } from './spec/save-design'; @@ -36,6 +37,7 @@ export function createBuiltinCommands(opts: { createOutputStyleCommand(), createResumeCommand(), createReviewCommand(opts.language), + createSettingCommand(), createTerminalSetupCommand(), createBugCommand(), compactCommand, diff --git a/src/slash-commands/builtin/setting.tsx b/src/slash-commands/builtin/setting.tsx new file mode 100644 index 000000000..8b055c3ea --- /dev/null +++ b/src/slash-commands/builtin/setting.tsx @@ -0,0 +1,635 @@ +import { Box, Text, useInput } from 'ink'; +import pc from 'picocolors'; +import type React from 'react'; +import { useEffect, useState } from 'react'; +import type { Config } from '../../config'; +import PaginatedGroupSelectInput from '../../ui/PaginatedGroupSelectInput'; +import PaginatedSelectInput from '../../ui/PaginatedSelectInput'; +import { useAppStore } from '../../ui/store'; +import type { LocalJSXCommand } from '../types'; +import { ModelSelect } from './model'; + +type SettingCategory = + | 'main' + | 'model' + | 'reasoning' + | 'completion-sound' + | 'play-sounds' + | 'show-tips' + | 'diff-mode' + | 'gitignore' + | 'theme' + | 'auto-compact' + | 'vscode-extension' + | 'auto-connect-ide'; + +interface SettingManagerProps { + onExit: (result: string) => void; +} + +const SettingManagerComponent: React.FC = ({ onExit }) => { + const { bridge, cwd } = useAppStore(); + const [category, setCategory] = useState('main'); + const [lastCategory, setLastCategory] = useState('model'); + const [currentConfig, setCurrentConfig] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [extensionInfo, setExtensionInfo] = useState<{ + installed: boolean; + version: string | null; + }>({ installed: false, version: null }); + + // Load current configuration + useEffect(() => { + const loadConfig = async () => { + try { + const result = await bridge.request('config.list', { cwd }); + setCurrentConfig(result.data.config); + + // Check VSCode extension status + // TODO: Implement extension status check + // For now, just set default values + setExtensionInfo({ + installed: false, + version: null, + }); + + setLoading(false); + } catch (error) { + console.error('Failed to load config:', error); + setLoading(false); + } + }; + + loadConfig(); + }, [cwd, bridge]); + + // Handle ESC key to go back + useInput((_input, key) => { + if (key.escape) { + if (category === 'main') { + onExit('Settings closed'); + } else { + setLastCategory(category); + setCategory('main'); + } + } + }); + + const saveConfig = async ( + key: string, + value: string | boolean, + isGlobal = true, + ) => { + setSaving(true); + try { + await bridge.request('config.set', { + cwd, + isGlobal, + key, + value: typeof value === 'string' ? value : String(value), + }); + + // Reload config to reflect changes + const result = await bridge.request('config.list', { cwd }); + setCurrentConfig(result.data.config); + } catch (error) { + console.error('Failed to save config:', error); + } finally { + setSaving(false); + } + }; + + const renderMainMenu = () => { + const getDisplayValue = (key: keyof Config) => { + const value = currentConfig[key]; + if (value === undefined || value === null) return ''; + if (typeof value === 'boolean') return value ? 'On' : 'Off'; + return String(value); + }; + + const groups = [ + { + provider: 'Model & Reasoning', + providerId: 'model-reasoning', + models: [ + { name: 'Model', modelId: currentConfig.model || '', value: 'model' }, + { + name: 'Reasoning Level', + modelId: getDisplayValue('reasoningLevel'), + value: 'reasoning', + }, + ], + }, + { + provider: 'Preferences', + providerId: 'preferences', + models: [ + { + name: 'Completion Sound', + modelId: getDisplayValue('completionSound'), + value: 'completion-sound', + }, + { + name: 'Play Sounds', + modelId: getDisplayValue('playSounds'), + value: 'play-sounds', + }, + { + name: 'Show Tips', + modelId: getDisplayValue('showTips'), + value: 'show-tips', + }, + { + name: 'Diff Display Mode', + modelId: getDisplayValue('diffDisplayMode'), + value: 'diff-mode', + }, + { + name: 'Respect .gitignore in File Picker', + modelId: getDisplayValue('respectGitignore'), + value: 'gitignore', + }, + { name: 'Theme', modelId: getDisplayValue('theme'), value: 'theme' }, + { + name: 'Auto Compact', + modelId: getDisplayValue('autoCompact'), + value: 'auto-compact', + }, + ], + }, + { + provider: 'Integrations', + providerId: 'integrations', + models: [ + { + name: 'Install VSCode Extension', + modelId: extensionInfo.installed + ? `v${extensionInfo.version || '0.0.0'}` + : 'Not Installed', + value: 'vscode-extension', + }, + { + name: 'Auto-connect to IDE', + modelId: getDisplayValue('autoConnectIDE'), + value: 'auto-connect-ide', + }, + ], + }, + ]; + + return ( + + Settings + + onExit('Settings closed')} + onSelect={(item) => { + setLastCategory(item.value as SettingCategory); + setCategory(item.value as SettingCategory); + }} + /> + + ); + }; + + const renderModelSelection = () => { + // Wrapper component to handle model selection within settings + const ModelSelectionWrapper = () => { + return ( + { + setLastCategory('model'); + setCategory('main'); + }} + onSelect={(model) => { + // Model is already saved by ModelSelect component + onExit(`Model changed to ${model}`); + }} + /> + ); + }; + + return ; + }; + + const renderReasoningLevel = () => { + const items = [ + { + label: `Disabled ${currentConfig.reasoningLevel === 'disabled' ? pc.cyan('(current)') : ''}`, + value: 'disabled', + }, + { + label: `Low ${currentConfig.reasoningLevel === 'low' ? pc.cyan('(current)') : ''}`, + value: 'low', + }, + { + label: `Medium ${currentConfig.reasoningLevel === 'medium' ? pc.cyan('(current)') : ''}`, + value: 'medium', + }, + { + label: `High ${currentConfig.reasoningLevel === 'high' ? pc.cyan('(current)') : ''}`, + value: 'high', + }, + ]; + + return ( + + Reasoning Level + + Current:{' '} + {currentConfig.reasoningLevel || 'disabled'} + + + { + await saveConfig('reasoningLevel', item.value); + onExit(`Reasoning level changed to ${item.value}`); + }} + /> + + ); + }; + + const renderCompletionSound = () => { + const items = [ + { + label: `Off ${currentConfig.completionSound === 'off' ? pc.cyan('(current)') : ''}`, + value: 'off', + }, + { + label: `Terminal Bell ${currentConfig.completionSound === 'terminal-bell' ? pc.cyan('(current)') : ''}`, + value: 'terminal-bell', + }, + { + label: `FX-OK01 ${currentConfig.completionSound === 'fx-ok01' ? pc.cyan('(current)') : ''}`, + value: 'fx-ok01', + }, + { + label: `FX-ACK01 ${currentConfig.completionSound === 'fx-ack01' ? pc.cyan('(current)') : ''}`, + value: 'fx-ack01', + }, + { + label: `Custom Sound ${currentConfig.completionSound === 'custom' ? pc.cyan('(current)') : ''}`, + value: 'custom', + }, + ]; + + return ( + + Completion Sound + + Current:{' '} + {currentConfig.completionSound || 'off'} + + + { + await saveConfig('completionSound', item.value); + onExit(`Completion sound changed to ${item.value}`); + }} + /> + + ); + }; + + const renderPlaySounds = () => { + const items = [ + { + label: `Always ${currentConfig.playSounds === 'always' ? pc.cyan('(current)') : ''}`, + value: 'always', + }, + { + label: `When Focused ${currentConfig.playSounds === 'when-focused' ? pc.cyan('(current)') : ''}`, + value: 'when-focused', + }, + { + label: `When Unfocused ${currentConfig.playSounds === 'when-unfocused' ? pc.cyan('(current)') : ''}`, + value: 'when-unfocused', + }, + ]; + + return ( + + Play Sounds + + Current:{' '} + {currentConfig.playSounds || 'always'} + + + { + await saveConfig('playSounds', item.value); + onExit(`Play sounds changed to ${item.value}`); + }} + /> + + ); + }; + + const renderShowTips = () => { + const items = [ + { + label: `On ${currentConfig.showTips === true ? pc.cyan('(current)') : ''}`, + value: 'true', + }, + { + label: `Off ${currentConfig.showTips === false ? pc.cyan('(current)') : ''}`, + value: 'false', + }, + ]; + + return ( + + Show Tips + + Current:{' '} + + {currentConfig.showTips === true ? 'On' : 'Off'} + + + + { + await saveConfig('showTips', item.value); + onExit( + `Show tips changed to ${item.value === 'true' ? 'On' : 'Off'}`, + ); + }} + /> + + ); + }; + + const renderDiffMode = () => { + const items = [ + { + label: `GitHub (side-by-side) ${currentConfig.diffDisplayMode === 'github' ? pc.cyan('(current)') : ''}`, + value: 'github', + }, + { + label: `Unified (inline) ${currentConfig.diffDisplayMode === 'unified' || !currentConfig.diffDisplayMode ? pc.cyan('(current)') : ''}`, + value: 'unified', + }, + ]; + + return ( + + Diff Display Mode + + Current:{' '} + {currentConfig.diffDisplayMode || 'unified'} + + + { + await saveConfig('diffDisplayMode', item.value); + onExit(`Diff display mode changed to ${item.value}`); + }} + /> + + ); + }; + + const renderRespectGitignore = () => { + const items = [ + { + label: `On ${currentConfig.respectGitignore === true ? pc.cyan('(current)') : ''}`, + value: 'true', + }, + { + label: `Off ${currentConfig.respectGitignore === false ? pc.cyan('(current)') : ''}`, + value: 'false', + }, + ]; + + return ( + + Respect .gitignore in File Picker + + Current:{' '} + + {currentConfig.respectGitignore === true ? 'On' : 'Off'} + + + + { + await saveConfig('respectGitignore', item.value); + onExit( + `Respect .gitignore changed to ${item.value === 'true' ? 'On' : 'Off'}`, + ); + }} + /> + + ); + }; + + const renderTheme = () => { + const items = [ + { + label: `Dark Mode ${currentConfig.theme === 'dark' || !currentConfig.theme ? pc.cyan('(current)') : ''}`, + value: 'dark', + }, + { + label: `Light Mode ${currentConfig.theme === 'light' ? pc.cyan('(current)') : ''}`, + value: 'light', + }, + ]; + + return ( + + Theme + + Current: {currentConfig.theme || 'dark'} + + Note: Theme changes may require restart + + { + await saveConfig('theme', item.value); + onExit(`Theme changed to ${item.value}`); + }} + /> + + ); + }; + + const renderAutoCompact = () => { + const items = [ + { + label: `On ${currentConfig.autoCompact === true ? pc.cyan('(current)') : ''}`, + value: 'true', + }, + { + label: `Off ${currentConfig.autoCompact === false ? pc.cyan('(current)') : ''}`, + value: 'false', + }, + ]; + + return ( + + Auto Compact + + Current:{' '} + + {currentConfig.autoCompact === true ? 'On' : 'Off'} + + + + Automatically compress conversation history when context limit is + reached + + + { + await saveConfig('autoCompact', item.value); + onExit( + `Auto compact changed to ${item.value === 'true' ? 'On' : 'Off'}`, + ); + }} + /> + + ); + }; + + const renderVSCodeExtension = () => { + return ( + + VSCode Extension + + + Status:{' '} + {extensionInfo.installed ? ( + + Installed{' '} + {extensionInfo.version ? `v${extensionInfo.version}` : ''} + + ) : ( + Not Installed + )} + + + {!extensionInfo.installed && ( + + The VSCode extension will be automatically installed when you use a + compatible IDE. + + )} + + Press ESC to go back + + ); + }; + + const renderAutoConnectIDE = () => { + const items = [ + { + label: `On ${currentConfig.autoConnectIDE === true ? pc.cyan('(current)') : ''}`, + value: 'true', + }, + { + label: `Off ${currentConfig.autoConnectIDE === false ? pc.cyan('(current)') : ''}`, + value: 'false', + }, + ]; + + return ( + + Auto-connect to IDE + + Current:{' '} + + {currentConfig.autoConnectIDE === true ? 'On' : 'Off'} + + + Automatically connect to IDE when available + + { + await saveConfig('autoConnectIDE', item.value); + onExit( + `Auto-connect to IDE changed to ${item.value === 'true' ? 'On' : 'Off'}`, + ); + }} + /> + + ); + }; + + if (loading) { + return ( + + Loading settings... + + ); + } + + return ( + + {saving && ( + + Saving... + + )} + + {category === 'main' && renderMainMenu()} + {category === 'model' && renderModelSelection()} + {category === 'reasoning' && renderReasoningLevel()} + {category === 'completion-sound' && renderCompletionSound()} + {category === 'play-sounds' && renderPlaySounds()} + {category === 'show-tips' && renderShowTips()} + {category === 'diff-mode' && renderDiffMode()} + {category === 'gitignore' && renderRespectGitignore()} + {category === 'theme' && renderTheme()} + {category === 'auto-compact' && renderAutoCompact()} + {category === 'vscode-extension' && renderVSCodeExtension()} + {category === 'auto-connect-ide' && renderAutoConnectIDE()} + + + + {category === 'main' ? 'ESC: Exit' : 'ESC: Back to main menu'} + + + + ); +}; + +export function createSettingCommand() { + return { + type: 'local-jsx', + name: 'setting', + description: 'Configure settings with GUI', + async call(onDone: (result: string | null) => void) { + return ; + }, + } as LocalJSXCommand; +}