Download src/components/Settings/Config.tsx from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 83.8 kB
-
https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/components/Settings/Config.tsx
- Command line
-
hf download hf://chenbhao/codev@afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/components/Settings/Config.tsx
-
curl -L -o Config.tsx https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/components/Settings/Config.tsx
83.8 kB
| // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered | |
| import { feature } from 'bun:bundle' | |
| import { | |
| Box, | |
| Text, | |
| useTheme, | |
| useThemeSetting, | |
| useTerminalFocus, | |
| } from '../../ink.js' | |
| import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' | |
| import * as React from 'react' | |
| import { useState, useCallback } from 'react' | |
| import { | |
| useKeybinding, | |
| useKeybindings, | |
| } from '../../keybindings/useKeybinding.js' | |
| import figures from 'figures' | |
| import { | |
| type GlobalConfig, | |
| saveGlobalConfig, | |
| getCurrentProjectConfig, | |
| type OutputStyle, | |
| } from '../../utils/config.js' | |
| import { normalizeApiKeyForConfig } from '../../utils/authPortable.js' | |
| import { | |
| getGlobalConfig, | |
| getAutoUpdaterDisabledReason, | |
| formatAutoUpdaterDisabledReason, | |
| getRemoteControlAtStartup, | |
| } from '../../utils/config.js' | |
| import chalk from 'chalk' | |
| import { | |
| permissionModeTitle, | |
| permissionModeFromString, | |
| toExternalPermissionMode, | |
| isExternalPermissionMode, | |
| EXTERNAL_PERMISSION_MODES, | |
| PERMISSION_MODES, | |
| type ExternalPermissionMode, | |
| type PermissionMode, | |
| } from '../../utils/permissions/PermissionMode.js' | |
| import { | |
| getAutoModeEnabledState, | |
| hasAutoModeOptInAnySource, | |
| transitionPlanAutoMode, | |
| } from '../../utils/permissions/permissionSetup.js' | |
| import { logError } from '../../utils/log.js' | |
| import { | |
| logEvent, | |
| type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| } from 'src/services/analytics/index.js' | |
| import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js' | |
| import { ThemePicker } from '../ThemePicker.js' | |
| import { | |
| useAppState, | |
| useSetAppState, | |
| useAppStateStore, | |
| } from '../../state/AppState.js' | |
| import { ModelPicker } from '../ModelPicker.js' | |
| import { | |
| modelDisplayString, | |
| isOpus1mMergeEnabled, | |
| } from '../../utils/model/model.js' | |
| import { isBilledAsExtraUsage } from '../../utils/extraUsage.js' | |
| import { ClaudeMdExternalIncludesDialog } from '../ClaudeMdExternalIncludesDialog.js' | |
| import { | |
| ChannelDowngradeDialog, | |
| type ChannelDowngradeChoice, | |
| } from '../ChannelDowngradeDialog.js' | |
| import { Dialog } from '../design-system/Dialog.js' | |
| import { Select } from '../CustomSelect/index.js' | |
| import { OutputStylePicker } from '../OutputStylePicker.js' | |
| import { LanguagePicker } from '../LanguagePicker.js' | |
| import { | |
| getExternalClaudeMdIncludes, | |
| getMemoryFiles, | |
| hasExternalClaudeMdIncludes, | |
| } from 'src/utils/claudemd.js' | |
| import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' | |
| import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' | |
| import { Byline } from '../design-system/Byline.js' | |
| import { useTabHeaderFocus } from '../design-system/Tabs.js' | |
| import { useIsInsideModal } from '../../context/modalContext.js' | |
| import { SearchBox } from '../SearchBox.js' | |
| import { | |
| isSupportedTerminal, | |
| hasAccessToIDEExtensionDiffFeature, | |
| } from '../../utils/ide.js' | |
| import { | |
| getInitialSettings, | |
| getSettingsForSource, | |
| updateSettingsForSource, | |
| } from '../../utils/settings/settings.js' | |
| import { getUserMsgOptIn, setUserMsgOptIn } from '../../bootstrap/state.js' | |
| import { DEFAULT_OUTPUT_STYLE_NAME } from 'src/constants/outputStyles.js' | |
| import { isEnvTruthy, isRunningOnHomespace } from 'src/utils/envUtils.js' | |
| import type { | |
| LocalJSXCommandContext, | |
| CommandResultDisplay, | |
| } from '../../commands.js' | |
| import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' | |
| import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' | |
| import { | |
| getCliTeammateModeOverride, | |
| clearCliTeammateModeOverride, | |
| } from '../../utils/swarm/backends/teammateModeSnapshot.js' | |
| import { getHardcodedTeammateModelFallback } from '../../utils/swarm/teammateModel.js' | |
| import { useSearchInput } from '../../hooks/useSearchInput.js' | |
| import { useTerminalSize } from '../../hooks/useTerminalSize.js' | |
| import { | |
| clearFastModeCooldown, | |
| FAST_MODE_MODEL_DISPLAY, | |
| isFastModeAvailable, | |
| isFastModeEnabled, | |
| getFastModeModel, | |
| isFastModeSupportedByModel, | |
| } from '../../utils/fastMode.js' | |
| import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js' | |
| type Props = { | |
| onClose: ( | |
| result?: string, | |
| options?: { display?: CommandResultDisplay }, | |
| ) => void | |
| context: LocalJSXCommandContext | |
| setTabsHidden: (hidden: boolean) => void | |
| onIsSearchModeChange?: (inSearchMode: boolean) => void | |
| contentHeight?: number | |
| } | |
| type SettingBase = | |
| | { | |
| id: string | |
| label: string | |
| } | |
| | { | |
| id: string | |
| label: React.ReactNode | |
| searchText: string | |
| } | |
| type Setting = | |
| | (SettingBase & { | |
| value: boolean | |
| onChange(value: boolean): void | |
| type: 'boolean' | |
| }) | |
| | (SettingBase & { | |
| value: string | |
| options: string[] | |
| onChange(value: string): void | |
| type: 'enum' | |
| }) | |
| | (SettingBase & { | |
| // For enums that are set by a custom component, we don't need to pass options, | |
| // but we still need a value to display in the top-level config menu | |
| value: string | |
| onChange(value: string): void | |
| type: 'managedEnum' | |
| }) | |
| type SubMenu = | |
| | 'Theme' | |
| | 'Model' | |
| | 'TeammateModel' | |
| | 'ExternalIncludes' | |
| | 'OutputStyle' | |
| | 'ChannelDowngrade' | |
| | 'Language' | |
| | 'EnableAutoUpdates' | |
| export function Config({ | |
| onClose, | |
| context, | |
| setTabsHidden, | |
| onIsSearchModeChange, | |
| contentHeight, | |
| }: Props): React.ReactNode { | |
| const { headerFocused, focusHeader } = useTabHeaderFocus() | |
| const insideModal = useIsInsideModal() | |
| const [, setTheme] = useTheme() | |
| const themeSetting = useThemeSetting() | |
| const [globalConfig, setGlobalConfig] = useState(getGlobalConfig()) | |
| const initialConfig = React.useRef(getGlobalConfig()) | |
| const [settingsData, setSettingsData] = useState(getInitialSettings()) | |
| const initialSettingsData = React.useRef(getInitialSettings()) | |
| const [currentOutputStyle, setCurrentOutputStyle] = useState<OutputStyle>( | |
| settingsData?.outputStyle || DEFAULT_OUTPUT_STYLE_NAME, | |
| ) | |
| const initialOutputStyle = React.useRef(currentOutputStyle) | |
| const [currentLanguage, setCurrentLanguage] = useState<string | undefined>( | |
| settingsData?.language, | |
| ) | |
| const initialLanguage = React.useRef(currentLanguage) | |
| const [selectedIndex, setSelectedIndex] = useState(0) | |
| const [scrollOffset, setScrollOffset] = useState(0) | |
| const [isSearchMode, setIsSearchMode] = useState(true) | |
| const isTerminalFocused = useTerminalFocus() | |
| const { rows } = useTerminalSize() | |
| // contentHeight is set by Settings.tsx (same value passed to Tabs to fix | |
| // pane height across all tabs — prevents layout jank when switching). | |
| // Reserve ~10 rows for chrome (search box, gaps, footer, scroll hints). | |
| // Fallback calc for standalone rendering (tests). | |
| const paneCap = contentHeight ?? Math.min(Math.floor(rows * 0.8), 30) | |
| const maxVisible = Math.max(5, paneCap - 10) | |
| const mainLoopModel = useAppState(s => s.mainLoopModel) | |
| const verbose = useAppState(s => s.verbose) | |
| const thinkingEnabled = useAppState(s => s.thinkingEnabled) | |
| const isFastMode = useAppState(s => | |
| isFastModeEnabled() ? s.fastMode : false, | |
| ) | |
| const promptSuggestionEnabled = useAppState(s => s.promptSuggestionEnabled) | |
| // Show auto in the default-mode dropdown when the user has opted in OR the | |
| // config is fully 'enabled' — even if currently circuit-broken ('disabled'), | |
| // an opted-in user should still see it in settings (it's a temporary state). | |
| const showAutoInDefaultModePicker = feature('TRANSCRIPT_CLASSIFIER') | |
| ? hasAutoModeOptInAnySource() || getAutoModeEnabledState() === 'enabled' | |
| : false | |
| // Chat/Transcript view picker is visible to entitled users (pass the GB | |
| // gate) even if they haven't opted in this session — it IS the persistent | |
| // opt-in. 'chat' written here is read at next startup by main.tsx which | |
| // sets userMsgOptIn if still entitled. | |
| /* eslint-disable @typescript-eslint/no-require-imports */ | |
| const showDefaultViewPicker = | |
| feature('KAIROS') || feature('KAIROS_BRIEF') | |
| ? ( | |
| require('../../tools/BriefTool/BriefTool.js') as typeof import('../../tools/BriefTool/BriefTool.js') | |
| ).isBriefEntitled() | |
| : false | |
| /* eslint-enable @typescript-eslint/no-require-imports */ | |
| const setAppState = useSetAppState() | |
| const [changes, setChanges] = useState<{ [key: string]: unknown }>({}) | |
| const initialThinkingEnabled = React.useRef(thinkingEnabled) | |
| // Per-source settings snapshots for revert-on-escape. getInitialSettings() | |
| // returns merged-across-sources which can't tell us what to delete vs | |
| // restore; per-source snapshots + updateSettingsForSource's | |
| // undefined-deletes-key semantics can. Lazy-init via useState (no setter) to | |
| // avoid reading settings files on every render — useRef evaluates its arg | |
| // eagerly even though only the first result is kept. | |
| const [initialLocalSettings] = useState(() => | |
| getSettingsForSource('localSettings'), | |
| ) | |
| const [initialUserSettings] = useState(() => | |
| getSettingsForSource('userSettings'), | |
| ) | |
| const initialThemeSetting = React.useRef(themeSetting) | |
| // AppState fields Config may modify — snapshot once at mount. | |
| const store = useAppStateStore() | |
| const [initialAppState] = useState(() => { | |
| const s = store.getState() | |
| return { | |
| mainLoopModel: s.mainLoopModel, | |
| mainLoopModelForSession: s.mainLoopModelForSession, | |
| verbose: s.verbose, | |
| thinkingEnabled: s.thinkingEnabled, | |
| fastMode: s.fastMode, | |
| promptSuggestionEnabled: s.promptSuggestionEnabled, | |
| isBriefOnly: s.isBriefOnly, | |
| replBridgeEnabled: s.replBridgeEnabled, | |
| replBridgeOutboundOnly: s.replBridgeOutboundOnly, | |
| settings: s.settings, | |
| } | |
| }) | |
| // Bootstrap state snapshot — userMsgOptIn is outside AppState, so | |
| // revertChanges needs to restore it separately. Without this, cycling | |
| // defaultView to 'chat' then Escape leaves the tool active while the | |
| // display filter reverts — the exact ambient-activation behavior this | |
| // PR's entitlement/opt-in split is meant to prevent. | |
| const [initialUserMsgOptIn] = useState(() => getUserMsgOptIn()) | |
| // Set on first user-visible change; gates revertChanges() on Escape so | |
| // opening-then-closing doesn't trigger redundant disk writes. | |
| const isDirty = React.useRef(false) | |
| const [showThinkingWarning, setShowThinkingWarning] = useState(false) | |
| const [showSubmenu, setShowSubmenu] = useState<SubMenu | null>(null) | |
| const { | |
| query: searchQuery, | |
| setQuery: setSearchQuery, | |
| cursorOffset: searchCursorOffset, | |
| } = useSearchInput({ | |
| isActive: isSearchMode && showSubmenu === null && !headerFocused, | |
| onExit: () => setIsSearchMode(false), | |
| onExitUp: focusHeader, | |
| // Ctrl+C/D must reach Settings' useExitOnCtrlCD; 'd' also avoids | |
| // double-action (delete-char + exit-pending). | |
| passthroughCtrlKeys: ['c', 'd'], | |
| }) | |
| // Tell the parent when Config's own Esc handler is active so Settings cedes | |
| // confirm:no. Only true when search mode owns the keyboard — not when the | |
| // tab header is focused (then Settings must handle Esc-to-close). | |
| const ownsEsc = isSearchMode && !headerFocused | |
| React.useEffect(() => { | |
| onIsSearchModeChange?.(ownsEsc) | |
| }, [ownsEsc, onIsSearchModeChange]) | |
| const isConnectedToIde = hasAccessToIDEExtensionDiffFeature( | |
| context.options.mcpClients, | |
| ) | |
| const isFileCheckpointingAvailable = !isEnvTruthy( | |
| process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING, | |
| ) | |
| const memoryFiles = React.use(getMemoryFiles(true)) | |
| const shouldShowExternalIncludesToggle = | |
| hasExternalClaudeMdIncludes(memoryFiles) | |
| const autoUpdaterDisabledReason = getAutoUpdaterDisabledReason() | |
| function onChangeMainModelConfig(value: string | null): void { | |
| const previousModel = mainLoopModel | |
| logEvent('tengu_config_model_changed', { | |
| from_model: | |
| previousModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| to_model: | |
| value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| setAppState(prev => ({ | |
| ...prev, | |
| mainLoopModel: value, | |
| mainLoopModelForSession: null, | |
| })) | |
| setChanges(prev => { | |
| const valStr = | |
| modelDisplayString(value) + | |
| (isBilledAsExtraUsage(value, false, isOpus1mMergeEnabled()) | |
| ? ' · Billed as extra usage' | |
| : '') | |
| if ('model' in prev) { | |
| const { model, ...rest } = prev | |
| return { ...rest, model: valStr } | |
| } | |
| return { ...prev, model: valStr } | |
| }) | |
| } | |
| function onChangeVerbose(value: boolean): void { | |
| // Update the global config to persist the setting | |
| saveGlobalConfig(current => ({ ...current, verbose: value })) | |
| setGlobalConfig({ ...getGlobalConfig(), verbose: value }) | |
| // Update the app state for immediate UI feedback | |
| setAppState(prev => ({ | |
| ...prev, | |
| verbose: value, | |
| })) | |
| setChanges(prev => { | |
| if ('verbose' in prev) { | |
| const { verbose, ...rest } = prev | |
| return rest | |
| } | |
| return { ...prev, verbose: value } | |
| }) | |
| } | |
| // TODO: Add MCP servers | |
| const settingsItems: Setting[] = [ | |
| // Global settings | |
| { | |
| id: 'autoCompactEnabled', | |
| label: 'Auto-compact', | |
| value: globalConfig.autoCompactEnabled, | |
| type: 'boolean' as const, | |
| onChange(autoCompactEnabled: boolean) { | |
| saveGlobalConfig(current => ({ ...current, autoCompactEnabled })) | |
| setGlobalConfig({ ...getGlobalConfig(), autoCompactEnabled }) | |
| logEvent('tengu_auto_compact_setting_changed', { | |
| enabled: autoCompactEnabled, | |
| }) | |
| }, | |
| }, | |
| { | |
| id: 'spinnerTipsEnabled', | |
| label: 'Show tips', | |
| value: settingsData?.spinnerTipsEnabled ?? true, | |
| type: 'boolean' as const, | |
| onChange(spinnerTipsEnabled: boolean) { | |
| updateSettingsForSource('localSettings', { | |
| spinnerTipsEnabled, | |
| }) | |
| // Update local state to reflect the change immediately | |
| setSettingsData(prev => ({ | |
| ...prev, | |
| spinnerTipsEnabled, | |
| })) | |
| logEvent('tengu_tips_setting_changed', { | |
| enabled: spinnerTipsEnabled, | |
| }) | |
| }, | |
| }, | |
| { | |
| id: 'prefersReducedMotion', | |
| label: 'Reduce motion', | |
| value: settingsData?.prefersReducedMotion ?? false, | |
| type: 'boolean' as const, | |
| onChange(prefersReducedMotion: boolean) { | |
| updateSettingsForSource('localSettings', { | |
| prefersReducedMotion, | |
| }) | |
| setSettingsData(prev => ({ | |
| ...prev, | |
| prefersReducedMotion, | |
| })) | |
| // Sync to AppState so components react immediately | |
| setAppState(prev => ({ | |
| ...prev, | |
| settings: { ...prev.settings, prefersReducedMotion }, | |
| })) | |
| logEvent('tengu_reduce_motion_setting_changed', { | |
| enabled: prefersReducedMotion, | |
| }) | |
| }, | |
| }, | |
| { | |
| id: 'thinkingEnabled', | |
| label: 'Thinking mode', | |
| value: thinkingEnabled ?? true, | |
| type: 'boolean' as const, | |
| onChange(enabled: boolean) { | |
| setAppState(prev => ({ ...prev, thinkingEnabled: enabled })) | |
| updateSettingsForSource('userSettings', { | |
| alwaysThinkingEnabled: enabled ? undefined : false, | |
| }) | |
| logEvent('tengu_thinking_toggled', { enabled }) | |
| }, | |
| }, | |
| // Fast mode toggle (ant-only, eliminated from external builds) | |
| ...(isFastModeEnabled() && isFastModeAvailable() | |
| ? [ | |
| { | |
| id: 'fastMode', | |
| label: `Fast mode (${FAST_MODE_MODEL_DISPLAY} only)`, | |
| value: !!isFastMode, | |
| type: 'boolean' as const, | |
| onChange(enabled: boolean) { | |
| clearFastModeCooldown() | |
| updateSettingsForSource('userSettings', { | |
| fastMode: enabled ? true : undefined, | |
| }) | |
| if (enabled) { | |
| setAppState(prev => ({ | |
| ...prev, | |
| mainLoopModel: getFastModeModel(), | |
| mainLoopModelForSession: null, | |
| fastMode: true, | |
| })) | |
| setChanges(prev => ({ | |
| ...prev, | |
| model: getFastModeModel(), | |
| 'Fast mode': 'ON', | |
| })) | |
| } else { | |
| setAppState(prev => ({ | |
| ...prev, | |
| fastMode: false, | |
| })) | |
| setChanges(prev => ({ ...prev, 'Fast mode': 'OFF' })) | |
| } | |
| }, | |
| }, | |
| ] | |
| : []), | |
| ...(getFeatureValue_CACHED_MAY_BE_STALE('tengu_chomp_inflection', false) | |
| ? [ | |
| { | |
| id: 'promptSuggestionEnabled', | |
| label: 'Prompt suggestions', | |
| value: promptSuggestionEnabled, | |
| type: 'boolean' as const, | |
| onChange(enabled: boolean) { | |
| setAppState(prev => ({ | |
| ...prev, | |
| promptSuggestionEnabled: enabled, | |
| })) | |
| updateSettingsForSource('userSettings', { | |
| promptSuggestionEnabled: enabled ? undefined : false, | |
| }) | |
| }, | |
| }, | |
| ] | |
| : []), | |
| // Speculation toggle (ant-only) | |
| ...("external" === 'ant' | |
| ? [ | |
| { | |
| id: 'speculationEnabled', | |
| label: 'Speculative execution', | |
| value: globalConfig.speculationEnabled ?? true, | |
| type: 'boolean' as const, | |
| onChange(enabled: boolean) { | |
| saveGlobalConfig(current => { | |
| if (current.speculationEnabled === enabled) return current | |
| return { | |
| ...current, | |
| speculationEnabled: enabled, | |
| } | |
| }) | |
| setGlobalConfig({ | |
| ...getGlobalConfig(), | |
| speculationEnabled: enabled, | |
| }) | |
| logEvent('tengu_speculation_setting_changed', { | |
| enabled, | |
| }) | |
| }, | |
| }, | |
| ] | |
| : []), | |
| ...(isFileCheckpointingAvailable | |
| ? [ | |
| { | |
| id: 'fileCheckpointingEnabled', | |
| label: 'Rewind code (checkpoints)', | |
| value: globalConfig.fileCheckpointingEnabled, | |
| type: 'boolean' as const, | |
| onChange(enabled: boolean) { | |
| saveGlobalConfig(current => ({ | |
| ...current, | |
| fileCheckpointingEnabled: enabled, | |
| })) | |
| setGlobalConfig({ | |
| ...getGlobalConfig(), | |
| fileCheckpointingEnabled: enabled, | |
| }) | |
| logEvent('tengu_file_history_snapshots_setting_changed', { | |
| enabled: enabled, | |
| }) | |
| }, | |
| }, | |
| ] | |
| : []), | |
| { | |
| id: 'verbose', | |
| label: 'Verbose output', | |
| value: verbose, | |
| type: 'boolean', | |
| onChange: onChangeVerbose, | |
| }, | |
| { | |
| id: 'terminalProgressBarEnabled', | |
| label: 'Terminal progress bar', | |
| value: globalConfig.terminalProgressBarEnabled, | |
| type: 'boolean' as const, | |
| onChange(terminalProgressBarEnabled: boolean) { | |
| saveGlobalConfig(current => ({ | |
| ...current, | |
| terminalProgressBarEnabled, | |
| })) | |
| setGlobalConfig({ ...getGlobalConfig(), terminalProgressBarEnabled }) | |
| logEvent('tengu_terminal_progress_bar_setting_changed', { | |
| enabled: terminalProgressBarEnabled, | |
| }) | |
| }, | |
| }, | |
| ...(getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_sidebar', false) | |
| ? [ | |
| { | |
| id: 'showStatusInTerminalTab', | |
| label: 'Show status in terminal tab', | |
| value: globalConfig.showStatusInTerminalTab ?? false, | |
| type: 'boolean' as const, | |
| onChange(showStatusInTerminalTab: boolean) { | |
| saveGlobalConfig(current => ({ | |
| ...current, | |
| showStatusInTerminalTab, | |
| })) | |
| setGlobalConfig({ | |
| ...getGlobalConfig(), | |
| showStatusInTerminalTab, | |
| }) | |
| logEvent('tengu_terminal_tab_status_setting_changed', { | |
| enabled: showStatusInTerminalTab, | |
| }) | |
| }, | |
| }, | |
| ] | |
| : []), | |
| { | |
| id: 'showTurnDuration', | |
| label: 'Show turn duration', | |
| value: globalConfig.showTurnDuration, | |
| type: 'boolean' as const, | |
| onChange(showTurnDuration: boolean) { | |
| saveGlobalConfig(current => ({ ...current, showTurnDuration })) | |
| setGlobalConfig({ ...getGlobalConfig(), showTurnDuration }) | |
| logEvent('tengu_show_turn_duration_setting_changed', { | |
| enabled: showTurnDuration, | |
| }) | |
| }, | |
| }, | |
| { | |
| id: 'defaultPermissionMode', | |
| label: 'Default permission mode', | |
| value: settingsData?.permissions?.defaultMode || 'default', | |
| options: (() => { | |
| const priorityOrder: PermissionMode[] = ['default', 'plan'] | |
| const allModes: readonly PermissionMode[] = feature( | |
| 'TRANSCRIPT_CLASSIFIER', | |
| ) | |
| ? PERMISSION_MODES | |
| : EXTERNAL_PERMISSION_MODES | |
| const excluded: PermissionMode[] = ['bypassPermissions'] | |
| if (feature('TRANSCRIPT_CLASSIFIER') && !showAutoInDefaultModePicker) { | |
| excluded.push('auto') | |
| } | |
| return [ | |
| ...priorityOrder, | |
| ...allModes.filter( | |
| m => !priorityOrder.includes(m) && !excluded.includes(m), | |
| ), | |
| ] | |
| })(), | |
| type: 'enum' as const, | |
| onChange(mode: string) { | |
| const parsedMode = permissionModeFromString(mode) | |
| // Internal modes (e.g. auto) are stored directly | |
| const validatedMode = isExternalPermissionMode(parsedMode) | |
| ? toExternalPermissionMode(parsedMode) | |
| : parsedMode | |
| const result = updateSettingsForSource('userSettings', { | |
| permissions: { | |
| ...settingsData?.permissions, | |
| defaultMode: validatedMode as ExternalPermissionMode, | |
| }, | |
| }) | |
| if (result.error) { | |
| logError(result.error) | |
| return | |
| } | |
| // Update local state to reflect the change immediately. | |
| // validatedMode is typed as the wide PermissionMode union but at | |
| // runtime is always a PERMISSION_MODES member (the options dropdown | |
| // is built from that array above), so this narrowing is sound. | |
| setSettingsData(prev => ({ | |
| ...prev, | |
| permissions: { | |
| ...prev?.permissions, | |
| defaultMode: validatedMode as (typeof PERMISSION_MODES)[number], | |
| }, | |
| })) | |
| // Track changes | |
| setChanges(prev => ({ ...prev, defaultPermissionMode: mode })) | |
| logEvent('tengu_config_changed', { | |
| setting: | |
| 'defaultPermissionMode' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| value: | |
| mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| }, | |
| }, | |
| ...(feature('TRANSCRIPT_CLASSIFIER') && showAutoInDefaultModePicker | |
| ? [ | |
| { | |
| id: 'useAutoModeDuringPlan', | |
| label: 'Use auto mode during plan', | |
| value: | |
| (settingsData as { useAutoModeDuringPlan?: boolean } | undefined) | |
| ?.useAutoModeDuringPlan ?? true, | |
| type: 'boolean' as const, | |
| onChange(useAutoModeDuringPlan: boolean) { | |
| updateSettingsForSource('userSettings', { | |
| useAutoModeDuringPlan, | |
| }) | |
| setSettingsData(prev => ({ | |
| ...prev, | |
| useAutoModeDuringPlan, | |
| })) | |
| // Internal writes suppress the file watcher, so | |
| // applySettingsChange won't fire. Reconcile directly so | |
| // mid-plan toggles take effect immediately. | |
| setAppState(prev => { | |
| const next = transitionPlanAutoMode(prev.toolPermissionContext) | |
| if (next === prev.toolPermissionContext) return prev | |
| return { ...prev, toolPermissionContext: next } | |
| }) | |
| setChanges(prev => ({ | |
| ...prev, | |
| 'Use auto mode during plan': useAutoModeDuringPlan, | |
| })) | |
| }, | |
| }, | |
| ] | |
| : []), | |
| { | |
| id: 'respectGitignore', | |
| label: 'Respect .gitignore in file picker', | |
| value: globalConfig.respectGitignore, | |
| type: 'boolean' as const, | |
| onChange(respectGitignore: boolean) { | |
| saveGlobalConfig(current => ({ ...current, respectGitignore })) | |
| setGlobalConfig({ ...getGlobalConfig(), respectGitignore }) | |
| logEvent('tengu_respect_gitignore_setting_changed', { | |
| enabled: respectGitignore, | |
| }) | |
| }, | |
| }, | |
| { | |
| id: 'copyFullResponse', | |
| label: 'Always copy full response (skip /copy picker)', | |
| value: globalConfig.copyFullResponse, | |
| type: 'boolean' as const, | |
| onChange(copyFullResponse: boolean) { | |
| saveGlobalConfig(current => ({ ...current, copyFullResponse })) | |
| setGlobalConfig({ ...getGlobalConfig(), copyFullResponse }) | |
| logEvent('tengu_config_changed', { | |
| setting: | |
| 'copyFullResponse' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| value: String( | |
| copyFullResponse, | |
| ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| }, | |
| }, | |
| // Copy-on-select is only meaningful with in-app selection (fullscreen | |
| // alt-screen mode). In inline mode the terminal emulator owns selection. | |
| ...(isFullscreenEnvEnabled() | |
| ? [ | |
| { | |
| id: 'copyOnSelect', | |
| label: 'Copy on select', | |
| value: globalConfig.copyOnSelect ?? true, | |
| type: 'boolean' as const, | |
| onChange(copyOnSelect: boolean) { | |
| saveGlobalConfig(current => ({ ...current, copyOnSelect })) | |
| setGlobalConfig({ ...getGlobalConfig(), copyOnSelect }) | |
| logEvent('tengu_config_changed', { | |
| setting: | |
| 'copyOnSelect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| value: String( | |
| copyOnSelect, | |
| ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| }, | |
| }, | |
| ] | |
| : []), | |
| // autoUpdates setting is hidden - use DISABLE_AUTOUPDATER env var to control | |
| autoUpdaterDisabledReason | |
| ? { | |
| id: 'autoUpdatesChannel', | |
| label: 'Auto-update channel', | |
| value: 'disabled', | |
| type: 'managedEnum' as const, | |
| onChange() {}, | |
| } | |
| : { | |
| id: 'autoUpdatesChannel', | |
| label: 'Auto-update channel', | |
| value: settingsData?.autoUpdatesChannel ?? 'latest', | |
| type: 'managedEnum' as const, | |
| onChange() { | |
| // Handled via toggleSetting -> 'ChannelDowngrade' | |
| }, | |
| }, | |
| { | |
| id: 'theme', | |
| label: 'Theme', | |
| value: themeSetting, | |
| type: 'managedEnum', | |
| onChange: setTheme, | |
| }, | |
| { | |
| id: 'notifChannel', | |
| label: | |
| feature('KAIROS') || feature('KAIROS_PUSH_NOTIFICATION') | |
| ? 'Local notifications' | |
| : 'Notifications', | |
| value: globalConfig.preferredNotifChannel, | |
| options: [ | |
| 'auto', | |
| 'iterm2', | |
| 'terminal_bell', | |
| 'iterm2_with_bell', | |
| 'kitty', | |
| 'ghostty', | |
| 'notifications_disabled', | |
| ], | |
| type: 'enum', | |
| onChange(notifChannel: GlobalConfig['preferredNotifChannel']) { | |
| saveGlobalConfig(current => ({ | |
| ...current, | |
| preferredNotifChannel: notifChannel, | |
| })) | |
| setGlobalConfig({ | |
| ...getGlobalConfig(), | |
| preferredNotifChannel: notifChannel, | |
| }) | |
| }, | |
| }, | |
| ...(feature('KAIROS') || feature('KAIROS_PUSH_NOTIFICATION') | |
| ? [ | |
| { | |
| id: 'taskCompleteNotifEnabled', | |
| label: 'Push when idle', | |
| value: globalConfig.taskCompleteNotifEnabled ?? false, | |
| type: 'boolean' as const, | |
| onChange(taskCompleteNotifEnabled: boolean) { | |
| saveGlobalConfig(current => ({ | |
| ...current, | |
| taskCompleteNotifEnabled, | |
| })) | |
| setGlobalConfig({ | |
| ...getGlobalConfig(), | |
| taskCompleteNotifEnabled, | |
| }) | |
| }, | |
| }, | |
| { | |
| id: 'inputNeededNotifEnabled', | |
| label: 'Push when input needed', | |
| value: globalConfig.inputNeededNotifEnabled ?? false, | |
| type: 'boolean' as const, | |
| onChange(inputNeededNotifEnabled: boolean) { | |
| saveGlobalConfig(current => ({ | |
| ...current, | |
| inputNeededNotifEnabled, | |
| })) | |
| setGlobalConfig({ | |
| ...getGlobalConfig(), | |
| inputNeededNotifEnabled, | |
| }) | |
| }, | |
| }, | |
| { | |
| id: 'agentPushNotifEnabled', | |
| label: 'Push when Claude decides', | |
| value: globalConfig.agentPushNotifEnabled ?? false, | |
| type: 'boolean' as const, | |
| onChange(agentPushNotifEnabled: boolean) { | |
| saveGlobalConfig(current => ({ | |
| ...current, | |
| agentPushNotifEnabled, | |
| })) | |
| setGlobalConfig({ | |
| ...getGlobalConfig(), | |
| agentPushNotifEnabled, | |
| }) | |
| }, | |
| }, | |
| ] | |
| : []), | |
| { | |
| id: 'outputStyle', | |
| label: 'Output style', | |
| value: currentOutputStyle, | |
| type: 'managedEnum' as const, | |
| onChange: () => {}, // handled by OutputStylePicker submenu | |
| }, | |
| ...(showDefaultViewPicker | |
| ? [ | |
| { | |
| id: 'defaultView', | |
| label: 'What you see by default', | |
| // 'default' means the setting is unset — currently resolves to | |
| // transcript (main.tsx falls through when defaultView !== 'chat'). | |
| // String() narrows the conditional-schema-spread union to string. | |
| value: | |
| settingsData?.defaultView === undefined | |
| ? 'default' | |
| : String(settingsData.defaultView), | |
| options: ['transcript', 'chat', 'default'], | |
| type: 'enum' as const, | |
| onChange(selected: string) { | |
| const defaultView = | |
| selected === 'default' | |
| ? undefined | |
| : (selected as 'chat' | 'transcript') | |
| updateSettingsForSource('localSettings', { defaultView }) | |
| setSettingsData(prev => ({ ...prev, defaultView })) | |
| const nextBrief = defaultView === 'chat' | |
| setAppState(prev => { | |
| if (prev.isBriefOnly === nextBrief) return prev | |
| return { ...prev, isBriefOnly: nextBrief } | |
| }) | |
| // Keep userMsgOptIn in sync so the tool list follows the view. | |
| // Two-way now (same as /brief) — accepting a cache invalidation | |
| // is better than leaving the tool on after switching away. | |
| // Reverted on Escape via initialUserMsgOptIn snapshot. | |
| setUserMsgOptIn(nextBrief) | |
| setChanges(prev => ({ ...prev, 'Default view': selected })) | |
| logEvent('tengu_default_view_setting_changed', { | |
| value: (defaultView ?? | |
| 'unset') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| }, | |
| }, | |
| ] | |
| : []), | |
| { | |
| id: 'language', | |
| label: 'Language', | |
| value: currentLanguage ?? 'Default (English)', | |
| type: 'managedEnum' as const, | |
| onChange: () => {}, // handled by LanguagePicker submenu | |
| }, | |
| { | |
| id: 'editorMode', | |
| label: 'Editor mode', | |
| // Convert 'emacs' to 'normal' for backward compatibility | |
| value: | |
| globalConfig.editorMode === 'emacs' | |
| ? 'normal' | |
| : globalConfig.editorMode || 'normal', | |
| options: ['normal', 'vim'], | |
| type: 'enum', | |
| onChange(value: string) { | |
| saveGlobalConfig(current => ({ | |
| ...current, | |
| editorMode: value as GlobalConfig['editorMode'], | |
| })) | |
| setGlobalConfig({ | |
| ...getGlobalConfig(), | |
| editorMode: value as GlobalConfig['editorMode'], | |
| }) | |
| logEvent('tengu_editor_mode_changed', { | |
| mode: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| source: | |
| 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| }, | |
| }, | |
| { | |
| id: 'prStatusFooterEnabled', | |
| label: 'Show PR status footer', | |
| value: globalConfig.prStatusFooterEnabled ?? true, | |
| type: 'boolean' as const, | |
| onChange(enabled: boolean) { | |
| saveGlobalConfig(current => { | |
| if (current.prStatusFooterEnabled === enabled) return current | |
| return { | |
| ...current, | |
| prStatusFooterEnabled: enabled, | |
| } | |
| }) | |
| setGlobalConfig({ | |
| ...getGlobalConfig(), | |
| prStatusFooterEnabled: enabled, | |
| }) | |
| logEvent('tengu_pr_status_footer_setting_changed', { | |
| enabled, | |
| }) | |
| }, | |
| }, | |
| { | |
| id: 'model', | |
| label: 'Model', | |
| value: mainLoopModel === null ? 'Default (recommended)' : mainLoopModel, | |
| type: 'managedEnum' as const, | |
| onChange: onChangeMainModelConfig, | |
| }, | |
| ...(isConnectedToIde | |
| ? [ | |
| { | |
| id: 'diffTool', | |
| label: 'Diff tool', | |
| value: globalConfig.diffTool ?? 'auto', | |
| options: ['terminal', 'auto'], | |
| type: 'enum' as const, | |
| onChange(diffTool: string) { | |
| saveGlobalConfig(current => ({ | |
| ...current, | |
| diffTool: diffTool as GlobalConfig['diffTool'], | |
| })) | |
| setGlobalConfig({ | |
| ...getGlobalConfig(), | |
| diffTool: diffTool as GlobalConfig['diffTool'], | |
| }) | |
| logEvent('tengu_diff_tool_changed', { | |
| tool: diffTool as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| source: | |
| 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| }, | |
| }, | |
| ] | |
| : []), | |
| ...(!isSupportedTerminal() | |
| ? [ | |
| { | |
| id: 'autoConnectIde', | |
| label: 'Auto-connect to IDE (external terminal)', | |
| value: globalConfig.autoConnectIde ?? false, | |
| type: 'boolean' as const, | |
| onChange(autoConnectIde: boolean) { | |
| saveGlobalConfig(current => ({ ...current, autoConnectIde })) | |
| setGlobalConfig({ ...getGlobalConfig(), autoConnectIde }) | |
| logEvent('tengu_auto_connect_ide_changed', { | |
| enabled: autoConnectIde, | |
| source: | |
| 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| }, | |
| }, | |
| ] | |
| : []), | |
| ...(isSupportedTerminal() | |
| ? [ | |
| { | |
| id: 'autoInstallIdeExtension', | |
| label: 'Auto-install IDE extension', | |
| value: globalConfig.autoInstallIdeExtension ?? true, | |
| type: 'boolean' as const, | |
| onChange(autoInstallIdeExtension: boolean) { | |
| saveGlobalConfig(current => ({ | |
| ...current, | |
| autoInstallIdeExtension, | |
| })) | |
| setGlobalConfig({ ...getGlobalConfig(), autoInstallIdeExtension }) | |
| logEvent('tengu_auto_install_ide_extension_changed', { | |
| enabled: autoInstallIdeExtension, | |
| source: | |
| 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| }, | |
| }, | |
| ] | |
| : []), | |
| { | |
| id: 'claudeInChromeDefaultEnabled', | |
| label: 'Claude in Chrome enabled by default', | |
| value: globalConfig.claudeInChromeDefaultEnabled ?? true, | |
| type: 'boolean' as const, | |
| onChange(enabled: boolean) { | |
| saveGlobalConfig(current => ({ | |
| ...current, | |
| claudeInChromeDefaultEnabled: enabled, | |
| })) | |
| setGlobalConfig({ | |
| ...getGlobalConfig(), | |
| claudeInChromeDefaultEnabled: enabled, | |
| }) | |
| logEvent('tengu_claude_in_chrome_setting_changed', { | |
| enabled, | |
| }) | |
| }, | |
| }, | |
| // Teammate mode (only shown when agent swarms are enabled) | |
| ...(isAgentSwarmsEnabled() | |
| ? (() => { | |
| const cliOverride = getCliTeammateModeOverride() | |
| const label = cliOverride | |
| ? `Teammate mode [overridden: ${cliOverride}]` | |
| : 'Teammate mode' | |
| return [ | |
| { | |
| id: 'teammateMode', | |
| label, | |
| value: globalConfig.teammateMode ?? 'auto', | |
| options: ['auto', 'tmux', 'in-process'], | |
| type: 'enum' as const, | |
| onChange(mode: string) { | |
| if ( | |
| mode !== 'auto' && | |
| mode !== 'tmux' && | |
| mode !== 'in-process' | |
| ) { | |
| return | |
| } | |
| // Clear CLI override and set new mode (pass mode to avoid race condition) | |
| clearCliTeammateModeOverride(mode) | |
| saveGlobalConfig(current => ({ | |
| ...current, | |
| teammateMode: mode, | |
| })) | |
| setGlobalConfig({ | |
| ...getGlobalConfig(), | |
| teammateMode: mode, | |
| }) | |
| logEvent('tengu_teammate_mode_changed', { | |
| mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| }, | |
| }, | |
| { | |
| id: 'teammateDefaultModel', | |
| label: 'Default teammate model', | |
| value: teammateModelDisplayString( | |
| globalConfig.teammateDefaultModel, | |
| ), | |
| type: 'managedEnum' as const, | |
| onChange() {}, | |
| }, | |
| ] | |
| })() | |
| : []), | |
| // Remote at startup toggle — gated on build flag + GrowthBook + policy | |
| ...(feature('BRIDGE_MODE') && isBridgeEnabled() | |
| ? [ | |
| { | |
| id: 'remoteControlAtStartup', | |
| label: 'Enable Remote Control for all sessions', | |
| value: | |
| globalConfig.remoteControlAtStartup === undefined | |
| ? 'default' | |
| : String(globalConfig.remoteControlAtStartup), | |
| options: ['true', 'false', 'default'], | |
| type: 'enum' as const, | |
| onChange(selected: string) { | |
| if (selected === 'default') { | |
| // Unset the config key so it falls back to the platform default | |
| saveGlobalConfig(current => { | |
| if (current.remoteControlAtStartup === undefined) | |
| return current | |
| const next = { ...current } | |
| delete next.remoteControlAtStartup | |
| return next | |
| }) | |
| setGlobalConfig({ | |
| ...getGlobalConfig(), | |
| remoteControlAtStartup: undefined, | |
| }) | |
| } else { | |
| const enabled = selected === 'true' | |
| saveGlobalConfig(current => { | |
| if (current.remoteControlAtStartup === enabled) return current | |
| return { ...current, remoteControlAtStartup: enabled } | |
| }) | |
| setGlobalConfig({ | |
| ...getGlobalConfig(), | |
| remoteControlAtStartup: enabled, | |
| }) | |
| } | |
| // Sync to AppState so useReplBridge reacts immediately | |
| const resolved = getRemoteControlAtStartup() | |
| setAppState(prev => { | |
| if ( | |
| prev.replBridgeEnabled === resolved && | |
| !prev.replBridgeOutboundOnly | |
| ) | |
| return prev | |
| return { | |
| ...prev, | |
| replBridgeEnabled: resolved, | |
| replBridgeOutboundOnly: false, | |
| } | |
| }) | |
| }, | |
| }, | |
| ] | |
| : []), | |
| ...(shouldShowExternalIncludesToggle | |
| ? [ | |
| { | |
| id: 'showExternalIncludesDialog', | |
| label: 'External CLAUDE.md includes', | |
| value: (() => { | |
| const projectConfig = getCurrentProjectConfig() | |
| if (projectConfig.hasClaudeMdExternalIncludesApproved) { | |
| return 'true' | |
| } else { | |
| return 'false' | |
| } | |
| })(), | |
| type: 'managedEnum' as const, | |
| onChange() { | |
| // Will be handled by toggleSetting function | |
| }, | |
| }, | |
| ] | |
| : []), | |
| ...(process.env.ANTHROPIC_API_KEY && !isRunningOnHomespace() | |
| ? [ | |
| { | |
| id: 'apiKey', | |
| label: ( | |
| <Text> | |
| Use custom API key:{' '} | |
| <Text bold> | |
| {normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY)} | |
| </Text> | |
| </Text> | |
| ), | |
| searchText: 'Use custom API key', | |
| value: Boolean( | |
| process.env.ANTHROPIC_API_KEY && | |
| globalConfig.customApiKeyResponses?.approved?.includes( | |
| normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY), | |
| ), | |
| ), | |
| type: 'boolean' as const, | |
| onChange(useCustomKey: boolean) { | |
| saveGlobalConfig(current => { | |
| const updated = { ...current } | |
| if (!updated.customApiKeyResponses) { | |
| updated.customApiKeyResponses = { | |
| approved: [], | |
| rejected: [], | |
| } | |
| } | |
| if (!updated.customApiKeyResponses.approved) { | |
| updated.customApiKeyResponses = { | |
| ...updated.customApiKeyResponses, | |
| approved: [], | |
| } | |
| } | |
| if (!updated.customApiKeyResponses.rejected) { | |
| updated.customApiKeyResponses = { | |
| ...updated.customApiKeyResponses, | |
| rejected: [], | |
| } | |
| } | |
| if (process.env.ANTHROPIC_API_KEY) { | |
| const truncatedKey = normalizeApiKeyForConfig( | |
| process.env.ANTHROPIC_API_KEY, | |
| ) | |
| if (useCustomKey) { | |
| updated.customApiKeyResponses = { | |
| ...updated.customApiKeyResponses, | |
| approved: [ | |
| ...( | |
| updated.customApiKeyResponses.approved ?? [] | |
| ).filter(k => k !== truncatedKey), | |
| truncatedKey, | |
| ], | |
| rejected: ( | |
| updated.customApiKeyResponses.rejected ?? [] | |
| ).filter(k => k !== truncatedKey), | |
| } | |
| } else { | |
| updated.customApiKeyResponses = { | |
| ...updated.customApiKeyResponses, | |
| approved: ( | |
| updated.customApiKeyResponses.approved ?? [] | |
| ).filter(k => k !== truncatedKey), | |
| rejected: [ | |
| ...( | |
| updated.customApiKeyResponses.rejected ?? [] | |
| ).filter(k => k !== truncatedKey), | |
| truncatedKey, | |
| ], | |
| } | |
| } | |
| } | |
| return updated | |
| }) | |
| setGlobalConfig(getGlobalConfig()) | |
| }, | |
| }, | |
| ] | |
| : []), | |
| ] | |
| // Filter settings based on search query | |
| const filteredSettingsItems = React.useMemo(() => { | |
| if (!searchQuery) return settingsItems | |
| const lowerQuery = searchQuery.toLowerCase() | |
| return settingsItems.filter(setting => { | |
| if (setting.id.toLowerCase().includes(lowerQuery)) return true | |
| const searchableText = | |
| 'searchText' in setting ? setting.searchText : setting.label | |
| return searchableText.toLowerCase().includes(lowerQuery) | |
| }) | |
| }, [settingsItems, searchQuery]) | |
| // Adjust selected index when filtered list shrinks, and keep the selected | |
| // item visible when maxVisible changes (e.g., terminal resize). | |
| React.useEffect(() => { | |
| if (selectedIndex >= filteredSettingsItems.length) { | |
| const newIndex = Math.max(0, filteredSettingsItems.length - 1) | |
| setSelectedIndex(newIndex) | |
| setScrollOffset(Math.max(0, newIndex - maxVisible + 1)) | |
| return | |
| } | |
| setScrollOffset(prev => { | |
| if (selectedIndex < prev) return selectedIndex | |
| if (selectedIndex >= prev + maxVisible) | |
| return selectedIndex - maxVisible + 1 | |
| return prev | |
| }) | |
| }, [filteredSettingsItems.length, selectedIndex, maxVisible]) | |
| // Keep the selected item visible within the scroll window. | |
| // Called synchronously from navigation handlers to avoid a render frame | |
| // where the selected item falls outside the visible window. | |
| const adjustScrollOffset = useCallback( | |
| (newIndex: number) => { | |
| setScrollOffset(prev => { | |
| if (newIndex < prev) return newIndex | |
| if (newIndex >= prev + maxVisible) return newIndex - maxVisible + 1 | |
| return prev | |
| }) | |
| }, | |
| [maxVisible], | |
| ) | |
| // Enter: keep all changes (already persisted by onChange handlers), close | |
| // with a summary of what changed. | |
| const handleSaveAndClose = useCallback(() => { | |
| // Submenu handling: each submenu has its own Enter/Esc — don't close | |
| // the whole panel while one is open. | |
| if (showSubmenu !== null) { | |
| return | |
| } | |
| // Log any changes that were made | |
| // TODO: Make these proper messages | |
| const formattedChanges: string[] = Object.entries(changes).map( | |
| ([key, value]) => { | |
| logEvent('tengu_config_changed', { | |
| key: key as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| value: | |
| value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| return `Set ${key} to ${chalk.bold(value)}` | |
| }, | |
| ) | |
| // Check for API key changes | |
| // On homespace, ANTHROPIC_API_KEY is preserved in process.env for child | |
| // processes but ignored by Claude Code itself (see auth.ts). | |
| const effectiveApiKey = isRunningOnHomespace() | |
| ? undefined | |
| : process.env.ANTHROPIC_API_KEY | |
| const initialUsingCustomKey = Boolean( | |
| effectiveApiKey && | |
| initialConfig.current.customApiKeyResponses?.approved?.includes( | |
| normalizeApiKeyForConfig(effectiveApiKey), | |
| ), | |
| ) | |
| const currentUsingCustomKey = Boolean( | |
| effectiveApiKey && | |
| globalConfig.customApiKeyResponses?.approved?.includes( | |
| normalizeApiKeyForConfig(effectiveApiKey), | |
| ), | |
| ) | |
| if (initialUsingCustomKey !== currentUsingCustomKey) { | |
| formattedChanges.push( | |
| `${currentUsingCustomKey ? 'Enabled' : 'Disabled'} custom API key`, | |
| ) | |
| logEvent('tengu_config_changed', { | |
| key: 'env.ANTHROPIC_API_KEY' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| value: | |
| currentUsingCustomKey as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| } | |
| if (globalConfig.theme !== initialConfig.current.theme) { | |
| formattedChanges.push(`Set theme to ${chalk.bold(globalConfig.theme)}`) | |
| } | |
| if ( | |
| globalConfig.preferredNotifChannel !== | |
| initialConfig.current.preferredNotifChannel | |
| ) { | |
| formattedChanges.push( | |
| `Set notifications to ${chalk.bold(globalConfig.preferredNotifChannel)}`, | |
| ) | |
| } | |
| if (currentOutputStyle !== initialOutputStyle.current) { | |
| formattedChanges.push( | |
| `Set output style to ${chalk.bold(currentOutputStyle)}`, | |
| ) | |
| } | |
| if (currentLanguage !== initialLanguage.current) { | |
| formattedChanges.push( | |
| `Set response language to ${chalk.bold(currentLanguage ?? 'Default (English)')}`, | |
| ) | |
| } | |
| if (globalConfig.editorMode !== initialConfig.current.editorMode) { | |
| formattedChanges.push( | |
| `Set editor mode to ${chalk.bold(globalConfig.editorMode || 'emacs')}`, | |
| ) | |
| } | |
| if (globalConfig.diffTool !== initialConfig.current.diffTool) { | |
| formattedChanges.push( | |
| `Set diff tool to ${chalk.bold(globalConfig.diffTool)}`, | |
| ) | |
| } | |
| if (globalConfig.autoConnectIde !== initialConfig.current.autoConnectIde) { | |
| formattedChanges.push( | |
| `${globalConfig.autoConnectIde ? 'Enabled' : 'Disabled'} auto-connect to IDE`, | |
| ) | |
| } | |
| if ( | |
| globalConfig.autoInstallIdeExtension !== | |
| initialConfig.current.autoInstallIdeExtension | |
| ) { | |
| formattedChanges.push( | |
| `${globalConfig.autoInstallIdeExtension ? 'Enabled' : 'Disabled'} auto-install IDE extension`, | |
| ) | |
| } | |
| if ( | |
| globalConfig.autoCompactEnabled !== | |
| initialConfig.current.autoCompactEnabled | |
| ) { | |
| formattedChanges.push( | |
| `${globalConfig.autoCompactEnabled ? 'Enabled' : 'Disabled'} auto-compact`, | |
| ) | |
| } | |
| if ( | |
| globalConfig.respectGitignore !== initialConfig.current.respectGitignore | |
| ) { | |
| formattedChanges.push( | |
| `${globalConfig.respectGitignore ? 'Enabled' : 'Disabled'} respect .gitignore in file picker`, | |
| ) | |
| } | |
| if ( | |
| globalConfig.copyFullResponse !== initialConfig.current.copyFullResponse | |
| ) { | |
| formattedChanges.push( | |
| `${globalConfig.copyFullResponse ? 'Enabled' : 'Disabled'} always copy full response`, | |
| ) | |
| } | |
| if (globalConfig.copyOnSelect !== initialConfig.current.copyOnSelect) { | |
| formattedChanges.push( | |
| `${globalConfig.copyOnSelect ? 'Enabled' : 'Disabled'} copy on select`, | |
| ) | |
| } | |
| if ( | |
| globalConfig.terminalProgressBarEnabled !== | |
| initialConfig.current.terminalProgressBarEnabled | |
| ) { | |
| formattedChanges.push( | |
| `${globalConfig.terminalProgressBarEnabled ? 'Enabled' : 'Disabled'} terminal progress bar`, | |
| ) | |
| } | |
| if ( | |
| globalConfig.showStatusInTerminalTab !== | |
| initialConfig.current.showStatusInTerminalTab | |
| ) { | |
| formattedChanges.push( | |
| `${globalConfig.showStatusInTerminalTab ? 'Enabled' : 'Disabled'} terminal tab status`, | |
| ) | |
| } | |
| if ( | |
| globalConfig.showTurnDuration !== initialConfig.current.showTurnDuration | |
| ) { | |
| formattedChanges.push( | |
| `${globalConfig.showTurnDuration ? 'Enabled' : 'Disabled'} turn duration`, | |
| ) | |
| } | |
| if ( | |
| globalConfig.remoteControlAtStartup !== | |
| initialConfig.current.remoteControlAtStartup | |
| ) { | |
| const remoteLabel = | |
| globalConfig.remoteControlAtStartup === undefined | |
| ? 'Reset Remote Control to default' | |
| : `${globalConfig.remoteControlAtStartup ? 'Enabled' : 'Disabled'} Remote Control for all sessions` | |
| formattedChanges.push(remoteLabel) | |
| } | |
| if ( | |
| settingsData?.autoUpdatesChannel !== | |
| initialSettingsData.current?.autoUpdatesChannel | |
| ) { | |
| formattedChanges.push( | |
| `Set auto-update channel to ${chalk.bold(settingsData?.autoUpdatesChannel ?? 'latest')}`, | |
| ) | |
| } | |
| if (formattedChanges.length > 0) { | |
| onClose(formattedChanges.join('\n')) | |
| } else { | |
| onClose('Config dialog dismissed', { display: 'system' }) | |
| } | |
| }, [ | |
| showSubmenu, | |
| changes, | |
| globalConfig, | |
| mainLoopModel, | |
| currentOutputStyle, | |
| currentLanguage, | |
| settingsData?.autoUpdatesChannel, | |
| isFastModeEnabled() | |
| ? (settingsData as Record<string, unknown> | undefined)?.fastMode | |
| : undefined, | |
| onClose, | |
| ]) | |
| // Restore all state stores to their mount-time snapshots. Changes are | |
| // applied to disk/AppState immediately on toggle, so "cancel" means | |
| // actively writing the old values back. | |
| const revertChanges = useCallback(() => { | |
| // Theme: restores ThemeProvider React state. Must run before the global | |
| // config overwrite since setTheme internally calls saveGlobalConfig with | |
| // a partial update — we want the full snapshot to be the last write. | |
| if (themeSetting !== initialThemeSetting.current) { | |
| setTheme(initialThemeSetting.current) | |
| } | |
| // Global config: full overwrite from snapshot. saveGlobalConfig skips if | |
| // the returned ref equals current (test mode checks ref; prod writes to | |
| // disk but content is identical). | |
| saveGlobalConfig(() => initialConfig.current) | |
| // Settings files: restore each key Config may have touched. undefined | |
| // deletes the key (updateSettingsForSource customizer at settings.ts:368). | |
| const il = initialLocalSettings | |
| updateSettingsForSource('localSettings', { | |
| spinnerTipsEnabled: il?.spinnerTipsEnabled, | |
| prefersReducedMotion: il?.prefersReducedMotion, | |
| defaultView: il?.defaultView, | |
| outputStyle: il?.outputStyle, | |
| }) | |
| const iu = initialUserSettings | |
| updateSettingsForSource('userSettings', { | |
| alwaysThinkingEnabled: iu?.alwaysThinkingEnabled, | |
| fastMode: iu?.fastMode, | |
| promptSuggestionEnabled: iu?.promptSuggestionEnabled, | |
| autoUpdatesChannel: iu?.autoUpdatesChannel, | |
| minimumVersion: iu?.minimumVersion, | |
| language: iu?.language, | |
| ...(feature('TRANSCRIPT_CLASSIFIER') | |
| ? { | |
| useAutoModeDuringPlan: ( | |
| iu as { useAutoModeDuringPlan?: boolean } | undefined | |
| )?.useAutoModeDuringPlan, | |
| } | |
| : {}), | |
| // ThemePicker's Ctrl+T writes this key directly — include it so the | |
| // disk state reverts along with the in-memory AppState.settings restore. | |
| syntaxHighlightingDisabled: iu?.syntaxHighlightingDisabled, | |
| // permissions: the defaultMode onChange (above) spreads the MERGED | |
| // settingsData.permissions into userSettings — project/policy allow/deny | |
| // arrays can leak to disk. Spread the full initial snapshot so the | |
| // mergeWith array-customizer (settings.ts:375) replaces leaked arrays. | |
| // Explicitly include defaultMode so undefined triggers the customizer's | |
| // delete path even when iu.permissions lacks that key. | |
| permissions: | |
| iu?.permissions === undefined | |
| ? undefined | |
| : { ...iu.permissions, defaultMode: iu.permissions.defaultMode }, | |
| }) | |
| // AppState: batch-restore all possibly-touched fields. | |
| const ia = initialAppState | |
| setAppState(prev => ({ | |
| ...prev, | |
| mainLoopModel: ia.mainLoopModel, | |
| mainLoopModelForSession: ia.mainLoopModelForSession, | |
| verbose: ia.verbose, | |
| thinkingEnabled: ia.thinkingEnabled, | |
| fastMode: ia.fastMode, | |
| promptSuggestionEnabled: ia.promptSuggestionEnabled, | |
| isBriefOnly: ia.isBriefOnly, | |
| replBridgeEnabled: ia.replBridgeEnabled, | |
| replBridgeOutboundOnly: ia.replBridgeOutboundOnly, | |
| settings: ia.settings, | |
| // Reconcile auto-mode state after useAutoModeDuringPlan revert above — | |
| // the onChange handler may have activated/deactivated auto mid-plan. | |
| toolPermissionContext: transitionPlanAutoMode(prev.toolPermissionContext), | |
| })) | |
| // Bootstrap state: restore userMsgOptIn. Only touched by the defaultView | |
| // onChange above, so no feature() guard needed here (that path only | |
| // exists when showDefaultViewPicker is true). | |
| if (getUserMsgOptIn() !== initialUserMsgOptIn) { | |
| setUserMsgOptIn(initialUserMsgOptIn) | |
| } | |
| }, [ | |
| themeSetting, | |
| setTheme, | |
| initialLocalSettings, | |
| initialUserSettings, | |
| initialAppState, | |
| initialUserMsgOptIn, | |
| setAppState, | |
| ]) | |
| // Escape: revert all changes (if any) and close. | |
| const handleEscape = useCallback(() => { | |
| if (showSubmenu !== null) { | |
| return | |
| } | |
| if (isDirty.current) { | |
| revertChanges() | |
| } | |
| onClose('Config dialog dismissed', { display: 'system' }) | |
| }, [showSubmenu, revertChanges, onClose]) | |
| // Disable when submenu is open so the submenu's Dialog handles ESC, and in | |
| // search mode so the onKeyDown handler (which clears-then-exits search) | |
| // wins — otherwise Escape in search would jump straight to revert+close. | |
| useKeybinding('confirm:no', handleEscape, { | |
| context: 'Settings', | |
| isActive: showSubmenu === null && !isSearchMode && !headerFocused, | |
| }) | |
| // Save-and-close fires on Enter only when not in search mode (Enter there | |
| // exits search to the list — see the isSearchMode branch in handleKeyDown). | |
| useKeybinding('settings:close', handleSaveAndClose, { | |
| context: 'Settings', | |
| isActive: showSubmenu === null && !isSearchMode && !headerFocused, | |
| }) | |
| // Settings navigation and toggle actions via configurable keybindings. | |
| // Only active when not in search mode and no submenu is open. | |
| const toggleSetting = useCallback(() => { | |
| const setting = filteredSettingsItems[selectedIndex] | |
| if (!setting || !setting.onChange) { | |
| return | |
| } | |
| if (setting.type === 'boolean') { | |
| isDirty.current = true | |
| setting.onChange(!setting.value) | |
| if (setting.id === 'thinkingEnabled') { | |
| const newValue = !setting.value | |
| const backToInitial = newValue === initialThinkingEnabled.current | |
| if (backToInitial) { | |
| setShowThinkingWarning(false) | |
| } else if (context.messages.some(m => m.type === 'assistant')) { | |
| setShowThinkingWarning(true) | |
| } | |
| } | |
| return | |
| } | |
| if ( | |
| setting.id === 'theme' || | |
| setting.id === 'model' || | |
| setting.id === 'teammateDefaultModel' || | |
| setting.id === 'showExternalIncludesDialog' || | |
| setting.id === 'outputStyle' || | |
| setting.id === 'language' | |
| ) { | |
| // managedEnum items open a submenu — isDirty is set by the submenu's | |
| // completion callback, not here (submenu may be cancelled). | |
| switch (setting.id) { | |
| case 'theme': | |
| setShowSubmenu('Theme') | |
| setTabsHidden(true) | |
| return | |
| case 'model': | |
| setShowSubmenu('Model') | |
| setTabsHidden(true) | |
| return | |
| case 'teammateDefaultModel': | |
| setShowSubmenu('TeammateModel') | |
| setTabsHidden(true) | |
| return | |
| case 'showExternalIncludesDialog': | |
| setShowSubmenu('ExternalIncludes') | |
| setTabsHidden(true) | |
| return | |
| case 'outputStyle': | |
| setShowSubmenu('OutputStyle') | |
| setTabsHidden(true) | |
| return | |
| case 'language': | |
| setShowSubmenu('Language') | |
| setTabsHidden(true) | |
| return | |
| } | |
| } | |
| if (setting.id === 'autoUpdatesChannel') { | |
| if (autoUpdaterDisabledReason) { | |
| // Auto-updates are disabled - show enable dialog instead | |
| setShowSubmenu('EnableAutoUpdates') | |
| setTabsHidden(true) | |
| return | |
| } | |
| const currentChannel = settingsData?.autoUpdatesChannel ?? 'latest' | |
| if (currentChannel === 'latest') { | |
| // Switching to stable - show downgrade dialog | |
| setShowSubmenu('ChannelDowngrade') | |
| setTabsHidden(true) | |
| } else { | |
| // Switching to latest - just do it and clear minimumVersion | |
| isDirty.current = true | |
| updateSettingsForSource('userSettings', { | |
| autoUpdatesChannel: 'latest', | |
| minimumVersion: undefined, | |
| }) | |
| setSettingsData(prev => ({ | |
| ...prev, | |
| autoUpdatesChannel: 'latest', | |
| minimumVersion: undefined, | |
| })) | |
| logEvent('tengu_autoupdate_channel_changed', { | |
| channel: | |
| 'latest' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| } | |
| return | |
| } | |
| if (setting.type === 'enum') { | |
| isDirty.current = true | |
| const currentIndex = setting.options.indexOf(setting.value) | |
| const nextIndex = (currentIndex + 1) % setting.options.length | |
| setting.onChange(setting.options[nextIndex]!) | |
| return | |
| } | |
| }, [ | |
| autoUpdaterDisabledReason, | |
| filteredSettingsItems, | |
| selectedIndex, | |
| settingsData?.autoUpdatesChannel, | |
| setTabsHidden, | |
| ]) | |
| const moveSelection = (delta: -1 | 1): void => { | |
| setShowThinkingWarning(false) | |
| const newIndex = Math.max( | |
| 0, | |
| Math.min(filteredSettingsItems.length - 1, selectedIndex + delta), | |
| ) | |
| setSelectedIndex(newIndex) | |
| adjustScrollOffset(newIndex) | |
| } | |
| useKeybindings( | |
| { | |
| 'select:previous': () => { | |
| if (selectedIndex === 0) { | |
| // ↑ at top enters search mode so users can type-to-filter after | |
| // reaching the list boundary. Wheel-up (scroll:lineUp) clamps | |
| // instead — overshoot shouldn't move focus away from the list. | |
| setShowThinkingWarning(false) | |
| setIsSearchMode(true) | |
| setScrollOffset(0) | |
| } else { | |
| moveSelection(-1) | |
| } | |
| }, | |
| 'select:next': () => moveSelection(1), | |
| // Wheel. ScrollKeybindingHandler's scroll:line* returns false (not | |
| // consumed) when the ScrollBox content fits — which it always does | |
| // here because the list is paginated (slice). The event falls through | |
| // to this handler which navigates the list, clamping at boundaries. | |
| 'scroll:lineUp': () => moveSelection(-1), | |
| 'scroll:lineDown': () => moveSelection(1), | |
| 'select:accept': toggleSetting, | |
| 'settings:search': () => { | |
| setIsSearchMode(true) | |
| setSearchQuery('') | |
| }, | |
| }, | |
| { | |
| context: 'Settings', | |
| isActive: showSubmenu === null && !isSearchMode && !headerFocused, | |
| }, | |
| ) | |
| // Combined key handling across search/list modes. Branch order mirrors | |
| // the original useInput gate priority: submenu and header short-circuit | |
| // first (their own handlers own input), then search vs. list. | |
| const handleKeyDown = useCallback( | |
| (e: KeyboardEvent) => { | |
| if (showSubmenu !== null) return | |
| if (headerFocused) return | |
| // Search mode: Esc clears then exits, Enter/↓ moves to the list. | |
| if (isSearchMode) { | |
| if (e.key === 'escape') { | |
| e.preventDefault() | |
| if (searchQuery.length > 0) { | |
| setSearchQuery('') | |
| } else { | |
| setIsSearchMode(false) | |
| } | |
| return | |
| } | |
| if (e.key === 'return' || e.key === 'down' || e.key === 'wheeldown') { | |
| e.preventDefault() | |
| setIsSearchMode(false) | |
| setSelectedIndex(0) | |
| setScrollOffset(0) | |
| } | |
| return | |
| } | |
| // List mode: left/right/tab cycle the selected option's value. These | |
| // keys used to switch tabs; now they only do so when the tab row is | |
| // explicitly focused (see headerFocused in Settings.tsx). | |
| if (e.key === 'left' || e.key === 'right' || e.key === 'tab') { | |
| e.preventDefault() | |
| toggleSetting() | |
| return | |
| } | |
| // Fallback: printable characters (other than those bound to actions) | |
| // enter search mode. Carve out j/k// — useKeybindings (still on the | |
| // useInput path) consumes these via stopImmediatePropagation, but | |
| // onKeyDown dispatches independently so we must skip them explicitly. | |
| if (e.ctrl || e.meta) return | |
| if (e.key === 'j' || e.key === 'k' || e.key === '/') return | |
| if (e.key.length === 1 && e.key !== ' ') { | |
| e.preventDefault() | |
| setIsSearchMode(true) | |
| setSearchQuery(e.key) | |
| } | |
| }, | |
| [ | |
| showSubmenu, | |
| headerFocused, | |
| isSearchMode, | |
| searchQuery, | |
| setSearchQuery, | |
| toggleSetting, | |
| ], | |
| ) | |
| return ( | |
| <Box | |
| flexDirection="column" | |
| width="100%" | |
| tabIndex={0} | |
| autoFocus | |
| onKeyDown={handleKeyDown} | |
| > | |
| {showSubmenu === 'Theme' ? ( | |
| <> | |
| <ThemePicker | |
| onThemeSelect={setting => { | |
| isDirty.current = true | |
| setTheme(setting) | |
| setShowSubmenu(null) | |
| setTabsHidden(false) | |
| }} | |
| onCancel={() => { | |
| setShowSubmenu(null) | |
| setTabsHidden(false) | |
| }} | |
| hideEscToCancel | |
| skipExitHandling={true} // Skip exit handling as Config already handles it | |
| /> | |
| <Box> | |
| <Text dimColor italic> | |
| <Byline> | |
| <KeyboardShortcutHint shortcut="Enter" action="select" /> | |
| <ConfigurableShortcutHint | |
| action="confirm:no" | |
| context="Confirmation" | |
| fallback="Esc" | |
| description="cancel" | |
| /> | |
| </Byline> | |
| </Text> | |
| </Box> | |
| </> | |
| ) : showSubmenu === 'Model' ? ( | |
| <> | |
| <ModelPicker | |
| initial={mainLoopModel} | |
| onSelect={(model, _effort) => { | |
| isDirty.current = true | |
| onChangeMainModelConfig(model) | |
| setShowSubmenu(null) | |
| setTabsHidden(false) | |
| }} | |
| onCancel={() => { | |
| setShowSubmenu(null) | |
| setTabsHidden(false) | |
| }} | |
| showFastModeNotice={ | |
| isFastModeEnabled() | |
| ? isFastMode && | |
| isFastModeSupportedByModel(mainLoopModel) && | |
| isFastModeAvailable() | |
| : false | |
| } | |
| /> | |
| <Text dimColor> | |
| <Byline> | |
| <KeyboardShortcutHint shortcut="Enter" action="confirm" /> | |
| <ConfigurableShortcutHint | |
| action="confirm:no" | |
| context="Confirmation" | |
| fallback="Esc" | |
| description="cancel" | |
| /> | |
| </Byline> | |
| </Text> | |
| </> | |
| ) : showSubmenu === 'TeammateModel' ? ( | |
| <> | |
| <ModelPicker | |
| initial={globalConfig.teammateDefaultModel ?? null} | |
| skipSettingsWrite | |
| headerText="Default model for newly spawned teammates. The leader can override via the tool call's model parameter." | |
| onSelect={(model, _effort) => { | |
| setShowSubmenu(null) | |
| setTabsHidden(false) | |
| // First-open-then-Enter from unset: picker highlights "Default" | |
| // (initial=null) and confirming would write null, silently | |
| // switching Opus-fallback → follow-leader. Treat as no-op. | |
| if ( | |
| globalConfig.teammateDefaultModel === undefined && | |
| model === null | |
| ) { | |
| return | |
| } | |
| isDirty.current = true | |
| saveGlobalConfig(current => | |
| current.teammateDefaultModel === model | |
| ? current | |
| : { ...current, teammateDefaultModel: model }, | |
| ) | |
| setGlobalConfig({ | |
| ...getGlobalConfig(), | |
| teammateDefaultModel: model, | |
| }) | |
| setChanges(prev => ({ | |
| ...prev, | |
| teammateDefaultModel: teammateModelDisplayString(model), | |
| })) | |
| logEvent('tengu_teammate_default_model_changed', { | |
| model: | |
| model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| }} | |
| onCancel={() => { | |
| setShowSubmenu(null) | |
| setTabsHidden(false) | |
| }} | |
| /> | |
| <Text dimColor> | |
| <Byline> | |
| <KeyboardShortcutHint shortcut="Enter" action="confirm" /> | |
| <ConfigurableShortcutHint | |
| action="confirm:no" | |
| context="Confirmation" | |
| fallback="Esc" | |
| description="cancel" | |
| /> | |
| </Byline> | |
| </Text> | |
| </> | |
| ) : showSubmenu === 'ExternalIncludes' ? ( | |
| <> | |
| <ClaudeMdExternalIncludesDialog | |
| onDone={() => { | |
| setShowSubmenu(null) | |
| setTabsHidden(false) | |
| }} | |
| externalIncludes={getExternalClaudeMdIncludes(memoryFiles)} | |
| /> | |
| <Text dimColor> | |
| <Byline> | |
| <KeyboardShortcutHint shortcut="Enter" action="confirm" /> | |
| <ConfigurableShortcutHint | |
| action="confirm:no" | |
| context="Confirmation" | |
| fallback="Esc" | |
| description="disable external includes" | |
| /> | |
| </Byline> | |
| </Text> | |
| </> | |
| ) : showSubmenu === 'OutputStyle' ? ( | |
| <> | |
| <OutputStylePicker | |
| initialStyle={currentOutputStyle} | |
| onComplete={style => { | |
| isDirty.current = true | |
| setCurrentOutputStyle(style ?? DEFAULT_OUTPUT_STYLE_NAME) | |
| setShowSubmenu(null) | |
| setTabsHidden(false) | |
| // Save to local settings | |
| updateSettingsForSource('localSettings', { | |
| outputStyle: style, | |
| }) | |
| void logEvent('tengu_output_style_changed', { | |
| style: (style ?? | |
| DEFAULT_OUTPUT_STYLE_NAME) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| source: | |
| 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| settings_source: | |
| 'localSettings' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| }} | |
| onCancel={() => { | |
| setShowSubmenu(null) | |
| setTabsHidden(false) | |
| }} | |
| /> | |
| <Text dimColor> | |
| <Byline> | |
| <KeyboardShortcutHint shortcut="Enter" action="confirm" /> | |
| <ConfigurableShortcutHint | |
| action="confirm:no" | |
| context="Confirmation" | |
| fallback="Esc" | |
| description="cancel" | |
| /> | |
| </Byline> | |
| </Text> | |
| </> | |
| ) : showSubmenu === 'Language' ? ( | |
| <> | |
| <LanguagePicker | |
| initialLanguage={currentLanguage} | |
| onComplete={language => { | |
| isDirty.current = true | |
| setCurrentLanguage(language) | |
| setShowSubmenu(null) | |
| setTabsHidden(false) | |
| // Save to user settings | |
| updateSettingsForSource('userSettings', { | |
| language, | |
| }) | |
| void logEvent('tengu_language_changed', { | |
| language: (language ?? | |
| 'default') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| source: | |
| 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| }} | |
| onCancel={() => { | |
| setShowSubmenu(null) | |
| setTabsHidden(false) | |
| }} | |
| /> | |
| <Text dimColor> | |
| <Byline> | |
| <KeyboardShortcutHint shortcut="Enter" action="confirm" /> | |
| <ConfigurableShortcutHint | |
| action="confirm:no" | |
| context="Settings" | |
| fallback="Esc" | |
| description="cancel" | |
| /> | |
| </Byline> | |
| </Text> | |
| </> | |
| ) : showSubmenu === 'EnableAutoUpdates' ? ( | |
| <Dialog | |
| title="Enable Auto-Updates" | |
| onCancel={() => { | |
| setShowSubmenu(null) | |
| setTabsHidden(false) | |
| }} | |
| hideBorder | |
| hideInputGuide | |
| > | |
| {autoUpdaterDisabledReason?.type !== 'config' ? ( | |
| <> | |
| <Text> | |
| {autoUpdaterDisabledReason?.type === 'env' | |
| ? 'Auto-updates are controlled by an environment variable and cannot be changed here.' | |
| : 'Auto-updates are disabled in development builds.'} | |
| </Text> | |
| {autoUpdaterDisabledReason?.type === 'env' && ( | |
| <Text dimColor> | |
| Unset {autoUpdaterDisabledReason.envVar} to re-enable | |
| auto-updates. | |
| </Text> | |
| )} | |
| </> | |
| ) : ( | |
| <Select | |
| options={[ | |
| { | |
| label: 'Enable with latest channel', | |
| value: 'latest', | |
| }, | |
| { | |
| label: 'Enable with stable channel', | |
| value: 'stable', | |
| }, | |
| ]} | |
| onChange={(channel: string) => { | |
| isDirty.current = true | |
| setShowSubmenu(null) | |
| setTabsHidden(false) | |
| saveGlobalConfig(current => ({ | |
| ...current, | |
| autoUpdates: true, | |
| })) | |
| setGlobalConfig({ ...getGlobalConfig(), autoUpdates: true }) | |
| updateSettingsForSource('userSettings', { | |
| autoUpdatesChannel: channel as 'latest' | 'stable', | |
| minimumVersion: undefined, | |
| }) | |
| setSettingsData(prev => ({ | |
| ...prev, | |
| autoUpdatesChannel: channel as 'latest' | 'stable', | |
| minimumVersion: undefined, | |
| })) | |
| logEvent('tengu_autoupdate_enabled', { | |
| channel: | |
| channel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| }} | |
| /> | |
| )} | |
| </Dialog> | |
| ) : showSubmenu === 'ChannelDowngrade' ? ( | |
| <ChannelDowngradeDialog | |
| currentVersion={MACRO.VERSION} | |
| onChoice={(choice: ChannelDowngradeChoice) => { | |
| setShowSubmenu(null) | |
| setTabsHidden(false) | |
| if (choice === 'cancel') { | |
| // User cancelled - don't change anything | |
| return | |
| } | |
| isDirty.current = true | |
| // Switch to stable channel | |
| const newSettings: { | |
| autoUpdatesChannel: 'stable' | |
| minimumVersion?: string | |
| } = { | |
| autoUpdatesChannel: 'stable', | |
| } | |
| if (choice === 'stay') { | |
| // User wants to stay on current version until stable catches up | |
| newSettings.minimumVersion = MACRO.VERSION | |
| } | |
| updateSettingsForSource('userSettings', newSettings) | |
| setSettingsData(prev => ({ | |
| ...prev, | |
| ...newSettings, | |
| })) | |
| logEvent('tengu_autoupdate_channel_changed', { | |
| channel: | |
| 'stable' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| minimum_version_set: choice === 'stay', | |
| }) | |
| }} | |
| /> | |
| ) : ( | |
| <Box | |
| flexDirection="column" | |
| gap={1} | |
| marginY={insideModal ? undefined : 1} | |
| > | |
| <SearchBox | |
| query={searchQuery} | |
| isFocused={isSearchMode && !headerFocused} | |
| isTerminalFocused={isTerminalFocused} | |
| cursorOffset={searchCursorOffset} | |
| placeholder="Search settings…" | |
| /> | |
| <Box flexDirection="column"> | |
| {filteredSettingsItems.length === 0 ? ( | |
| <Text dimColor italic> | |
| No settings match "{searchQuery}" | |
| </Text> | |
| ) : ( | |
| <> | |
| {scrollOffset > 0 && ( | |
| <Text dimColor> | |
| {figures.arrowUp} {scrollOffset} more above | |
| </Text> | |
| )} | |
| {filteredSettingsItems | |
| .slice(scrollOffset, scrollOffset + maxVisible) | |
| .map((setting, i) => { | |
| const actualIndex = scrollOffset + i | |
| const isSelected = | |
| actualIndex === selectedIndex && | |
| !headerFocused && | |
| !isSearchMode | |
| return ( | |
| <React.Fragment key={setting.id}> | |
| <Box> | |
| <Box width={44}> | |
| <Text color={isSelected ? 'suggestion' : undefined}> | |
| {isSelected ? figures.pointer : ' '}{' '} | |
| {setting.label} | |
| </Text> | |
| </Box> | |
| <Box key={isSelected ? 'selected' : 'unselected'}> | |
| {setting.type === 'boolean' ? ( | |
| <> | |
| <Text | |
| color={isSelected ? 'suggestion' : undefined} | |
| > | |
| {setting.value.toString()} | |
| </Text> | |
| {showThinkingWarning && | |
| setting.id === 'thinkingEnabled' && ( | |
| <Text color="warning"> | |
| {' '} | |
| Changing thinking mode mid-conversation | |
| will increase latency and may reduce | |
| quality. | |
| </Text> | |
| )} | |
| </> | |
| ) : setting.id === 'theme' ? ( | |
| <Text | |
| color={isSelected ? 'suggestion' : undefined} | |
| > | |
| {THEME_LABELS[setting.value.toString()] ?? | |
| setting.value.toString()} | |
| </Text> | |
| ) : setting.id === 'notifChannel' ? ( | |
| <Text | |
| color={isSelected ? 'suggestion' : undefined} | |
| > | |
| <NotifChannelLabel | |
| value={setting.value.toString()} | |
| /> | |
| </Text> | |
| ) : setting.id === 'defaultPermissionMode' ? ( | |
| <Text | |
| color={isSelected ? 'suggestion' : undefined} | |
| > | |
| {permissionModeTitle( | |
| setting.value as PermissionMode, | |
| )} | |
| </Text> | |
| ) : setting.id === 'autoUpdatesChannel' && | |
| autoUpdaterDisabledReason ? ( | |
| <Box flexDirection="column"> | |
| <Text | |
| color={isSelected ? 'suggestion' : undefined} | |
| > | |
| disabled | |
| </Text> | |
| <Text dimColor> | |
| ( | |
| {formatAutoUpdaterDisabledReason( | |
| autoUpdaterDisabledReason, | |
| )} | |
| ) | |
| </Text> | |
| </Box> | |
| ) : ( | |
| <Text | |
| color={isSelected ? 'suggestion' : undefined} | |
| > | |
| {setting.value.toString()} | |
| </Text> | |
| )} | |
| </Box> | |
| </Box> | |
| </React.Fragment> | |
| ) | |
| })} | |
| {scrollOffset + maxVisible < filteredSettingsItems.length && ( | |
| <Text dimColor> | |
| {figures.arrowDown}{' '} | |
| {filteredSettingsItems.length - scrollOffset - maxVisible}{' '} | |
| more below | |
| </Text> | |
| )} | |
| </> | |
| )} | |
| </Box> | |
| {headerFocused ? ( | |
| <Text dimColor> | |
| <Byline> | |
| <KeyboardShortcutHint shortcut="←/→ tab" action="switch" /> | |
| <KeyboardShortcutHint shortcut="↓" action="return" /> | |
| <ConfigurableShortcutHint | |
| action="confirm:no" | |
| context="Settings" | |
| fallback="Esc" | |
| description="close" | |
| /> | |
| </Byline> | |
| </Text> | |
| ) : isSearchMode ? ( | |
| <Text dimColor> | |
| <Byline> | |
| <Text>Type to filter</Text> | |
| <KeyboardShortcutHint shortcut="Enter/↓" action="select" /> | |
| <KeyboardShortcutHint shortcut="↑" action="tabs" /> | |
| <ConfigurableShortcutHint | |
| action="confirm:no" | |
| context="Settings" | |
| fallback="Esc" | |
| description="clear" | |
| /> | |
| </Byline> | |
| </Text> | |
| ) : ( | |
| <Text dimColor> | |
| <Byline> | |
| <ConfigurableShortcutHint | |
| action="select:accept" | |
| context="Settings" | |
| fallback="Space" | |
| description="change" | |
| /> | |
| <ConfigurableShortcutHint | |
| action="settings:close" | |
| context="Settings" | |
| fallback="Enter" | |
| description="save" | |
| /> | |
| <ConfigurableShortcutHint | |
| action="settings:search" | |
| context="Settings" | |
| fallback="/" | |
| description="search" | |
| /> | |
| <ConfigurableShortcutHint | |
| action="confirm:no" | |
| context="Settings" | |
| fallback="Esc" | |
| description="cancel" | |
| /> | |
| </Byline> | |
| </Text> | |
| )} | |
| </Box> | |
| )} | |
| </Box> | |
| ) | |
| } | |
| function teammateModelDisplayString(value: string | null | undefined): string { | |
| if (value === undefined) { | |
| return modelDisplayString(getHardcodedTeammateModelFallback()) | |
| } | |
| if (value === null) return "Default (leader's model)" | |
| return modelDisplayString(value) | |
| } | |
| const THEME_LABELS: Record<string, string> = { | |
| auto: 'Auto (match terminal)', | |
| dark: 'Dark mode', | |
| light: 'Light mode', | |
| 'dark-daltonized': 'Dark mode (colorblind-friendly)', | |
| 'light-daltonized': 'Light mode (colorblind-friendly)', | |
| 'dark-ansi': 'Dark mode (ANSI colors only)', | |
| 'light-ansi': 'Light mode (ANSI colors only)', | |
| } | |
| function NotifChannelLabel({ value }: { value: string }): React.ReactNode { | |
| switch (value) { | |
| case 'auto': | |
| return 'Auto' | |
| case 'iterm2': | |
| return ( | |
| <Text> | |
| iTerm2 <Text dimColor>(OSC 9)</Text> | |
| </Text> | |
| ) | |
| case 'terminal_bell': | |
| return ( | |
| <Text> | |
| Terminal Bell <Text dimColor>(\a)</Text> | |
| </Text> | |
| ) | |
| case 'kitty': | |
| return ( | |
| <Text> | |
| Kitty <Text dimColor>(OSC 99)</Text> | |
| </Text> | |
| ) | |
| case 'ghostty': | |
| return ( | |
| <Text> | |
| Ghostty <Text dimColor>(OSC 777)</Text> | |
| </Text> | |
| ) | |
| case 'iterm2_with_bell': | |
| return 'iTerm2 w/ Bell' | |
| case 'notifications_disabled': | |
| return 'Disabled' | |
| default: | |
| return value | |
| } | |
| } | |