Config.tsx
1 import { Box, Text, useInput } from 'ink' 2 import * as React from 'react' 3 import { useState } from 'react' 4 import figures from 'figures' 5 import { getTheme } from '../utils/theme.js' 6 import { 7 GlobalConfig, 8 saveGlobalConfig, 9 normalizeApiKeyForConfig, 10 } from '../utils/config.js' 11 import { getGlobalConfig } from '../utils/config.js' 12 import chalk from 'chalk' 13 import { PRODUCT_NAME } from '../constants/product.js' 14 import { useExitOnCtrlCD } from '../hooks/useExitOnCtrlCD.js' 15 16 type Props = { 17 onClose: () => void 18 } 19 20 type Setting = 21 | { 22 id: string 23 label: string 24 value: boolean 25 onChange(value: boolean): void 26 type: 'boolean' 27 } 28 | { 29 id: string 30 label: string 31 value: string 32 options: string[] 33 onChange(value: string): void 34 type: 'enum' 35 } 36 37 export function Config({ onClose }: Props): React.ReactNode { 38 const [globalConfig, setGlobalConfig] = useState(getGlobalConfig()) 39 const initialConfig = React.useRef(getGlobalConfig()) 40 const [selectedIndex, setSelectedIndex] = useState(0) 41 const exitState = useExitOnCtrlCD(() => process.exit(0)) 42 43 // TODO: Add MCP servers 44 const settings: Setting[] = [ 45 // Global settings 46 ...(process.env.ANTHROPIC_API_KEY 47 ? [ 48 { 49 id: 'apiKey', 50 label: `Use custom API key: ${chalk.bold(normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY))}`, 51 value: Boolean( 52 process.env.ANTHROPIC_API_KEY && 53 globalConfig.customApiKeyResponses?.approved?.includes( 54 normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY), 55 ), 56 ), 57 type: 'boolean' as const, 58 onChange(useCustomKey: boolean) { 59 const config = { ...getGlobalConfig() } 60 if (!config.customApiKeyResponses) { 61 config.customApiKeyResponses = { 62 approved: [], 63 rejected: [], 64 } 65 } 66 if (!config.customApiKeyResponses.approved) { 67 config.customApiKeyResponses.approved = [] 68 } 69 if (!config.customApiKeyResponses.rejected) { 70 config.customApiKeyResponses.rejected = [] 71 } 72 if (process.env.ANTHROPIC_API_KEY) { 73 const truncatedKey = normalizeApiKeyForConfig( 74 process.env.ANTHROPIC_API_KEY, 75 ) 76 if (useCustomKey) { 77 config.customApiKeyResponses.approved = [ 78 ...config.customApiKeyResponses.approved.filter( 79 k => k !== truncatedKey, 80 ), 81 truncatedKey, 82 ] 83 config.customApiKeyResponses.rejected = 84 config.customApiKeyResponses.rejected.filter( 85 k => k !== truncatedKey, 86 ) 87 } else { 88 config.customApiKeyResponses.approved = 89 config.customApiKeyResponses.approved.filter( 90 k => k !== truncatedKey, 91 ) 92 config.customApiKeyResponses.rejected = [ 93 ...config.customApiKeyResponses.rejected.filter( 94 k => k !== truncatedKey, 95 ), 96 truncatedKey, 97 ] 98 } 99 } 100 saveGlobalConfig(config) 101 setGlobalConfig(config) 102 }, 103 }, 104 ] 105 : []), 106 { 107 id: 'verbose', 108 label: 'Verbose output', 109 value: globalConfig.verbose, 110 type: 'boolean', 111 onChange(verbose: boolean) { 112 const config = { ...getGlobalConfig(), verbose } 113 saveGlobalConfig(config) 114 setGlobalConfig(config) 115 }, 116 }, 117 { 118 id: 'theme', 119 label: 'Theme', 120 value: globalConfig.theme, 121 options: ['light', 'dark', 'light-daltonized', 'dark-daltonized'], 122 type: 'enum', 123 onChange(theme: GlobalConfig['theme']) { 124 const config = { ...getGlobalConfig(), theme } 125 saveGlobalConfig(config) 126 setGlobalConfig(config) 127 }, 128 }, 129 { 130 id: 'notifChannel', 131 label: 'Notifications', 132 value: globalConfig.preferredNotifChannel, 133 options: [ 134 'iterm2', 135 'terminal_bell', 136 'iterm2_with_bell', 137 'notifications_disabled', 138 ], 139 type: 'enum', 140 onChange(notifChannel: GlobalConfig['preferredNotifChannel']) { 141 const config = { 142 ...getGlobalConfig(), 143 preferredNotifChannel: notifChannel, 144 } 145 saveGlobalConfig(config) 146 setGlobalConfig(config) 147 }, 148 }, 149 ] 150 151 useInput((input, key) => { 152 if (key.escape) { 153 // Log any changes that were made 154 // TODO: Make these proper messages 155 const changes: string[] = [] 156 // Check for API key changes 157 const initialUsingCustomKey = Boolean( 158 process.env.ANTHROPIC_API_KEY && 159 initialConfig.current.customApiKeyResponses?.approved?.includes( 160 normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY), 161 ), 162 ) 163 const currentUsingCustomKey = Boolean( 164 process.env.ANTHROPIC_API_KEY && 165 globalConfig.customApiKeyResponses?.approved?.includes( 166 normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY), 167 ), 168 ) 169 if (initialUsingCustomKey !== currentUsingCustomKey) { 170 changes.push( 171 ` ⎿ ${currentUsingCustomKey ? 'Enabled' : 'Disabled'} custom API key`, 172 ) 173 } 174 175 if (globalConfig.verbose !== initialConfig.current.verbose) { 176 changes.push(` ⎿ Set verbose to ${chalk.bold(globalConfig.verbose)}`) 177 } 178 if (globalConfig.theme !== initialConfig.current.theme) { 179 changes.push(` ⎿ Set theme to ${chalk.bold(globalConfig.theme)}`) 180 } 181 if ( 182 globalConfig.preferredNotifChannel !== 183 initialConfig.current.preferredNotifChannel 184 ) { 185 changes.push( 186 ` ⎿ Set notifications to ${chalk.bold(globalConfig.preferredNotifChannel)}`, 187 ) 188 } 189 if (changes.length > 0) { 190 console.log(chalk.gray(changes.join('\n'))) 191 } 192 onClose() 193 return 194 } 195 196 function toggleSetting() { 197 const setting = settings[selectedIndex] 198 if (!setting || !setting.onChange) { 199 return 200 } 201 202 if (setting.type === 'boolean') { 203 setting.onChange(!setting.value) 204 return 205 } 206 207 if (setting.type === 'enum') { 208 const currentIndex = setting.options.indexOf(setting.value) 209 const nextIndex = (currentIndex + 1) % setting.options.length 210 setting.onChange(setting.options[nextIndex]!) 211 return 212 } 213 } 214 215 if (key.return || input === ' ') { 216 toggleSetting() 217 return 218 } 219 220 if (key.upArrow) { 221 setSelectedIndex(prev => Math.max(0, prev - 1)) 222 } 223 224 if (key.downArrow) { 225 setSelectedIndex(prev => Math.min(settings.length - 1, prev + 1)) 226 } 227 }) 228 229 return ( 230 <> 231 <Box 232 flexDirection="column" 233 borderStyle="round" 234 borderColor={getTheme().secondaryBorder} 235 paddingX={1} 236 marginTop={1} 237 > 238 <Box flexDirection="column" minHeight={2} marginBottom={1}> 239 <Text bold>Settings</Text> 240 <Text dimColor>Configure {PRODUCT_NAME} preferences</Text> 241 </Box> 242 243 {settings.map((setting, i) => { 244 const isSelected = i === selectedIndex 245 246 return ( 247 <Box key={setting.id} height={2} minHeight={2}> 248 <Box width={44}> 249 <Text color={isSelected ? 'blue' : undefined}> 250 {isSelected ? figures.pointer : ' '} {setting.label} 251 </Text> 252 </Box> 253 <Box> 254 {setting.type === 'boolean' ? ( 255 <Text color={isSelected ? 'blue' : undefined}> 256 {setting.value.toString()} 257 </Text> 258 ) : ( 259 <Text color={isSelected ? 'blue' : undefined}> 260 {setting.value.toString()} 261 </Text> 262 )} 263 </Box> 264 </Box> 265 ) 266 })} 267 </Box> 268 <Box marginLeft={3}> 269 <Text dimColor> 270 {exitState.pending ? ( 271 <>Press {exitState.keyName} again to exit</> 272 ) : ( 273 <>↑/↓ to select · Enter/Space to change · Esc to close</> 274 )} 275 </Text> 276 </Box> 277 </> 278 ) 279 }