/ src / components / Config.tsx
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  }