// 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( settingsData?.outputStyle || DEFAULT_OUTPUT_STYLE_NAME, ) const initialOutputStyle = React.useRef(currentOutputStyle) const [currentLanguage, setCurrentLanguage] = useState( 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(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) ...(process.env.USER_TYPE === '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: ( Use custom API key:{' '} {normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY)} ), 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 | 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 ( {showSubmenu === 'Theme' ? ( <> { 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 /> ) : showSubmenu === 'Model' ? ( <> { isDirty.current = true onChangeMainModelConfig(model) setShowSubmenu(null) setTabsHidden(false) }} onCancel={() => { setShowSubmenu(null) setTabsHidden(false) }} showFastModeNotice={ isFastModeEnabled() ? isFastMode && isFastModeSupportedByModel(mainLoopModel) && isFastModeAvailable() : false } /> ) : showSubmenu === 'TeammateModel' ? ( <> { 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) }} /> ) : showSubmenu === 'ExternalIncludes' ? ( <> { setShowSubmenu(null) setTabsHidden(false) }} externalIncludes={getExternalClaudeMdIncludes(memoryFiles)} /> ) : showSubmenu === 'OutputStyle' ? ( <> { 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) }} /> ) : showSubmenu === '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) }} /> ) : showSubmenu === 'EnableAutoUpdates' ? ( { setShowSubmenu(null) setTabsHidden(false) }} hideBorder hideInputGuide > {autoUpdaterDisabledReason?.type !== 'config' ? ( <> {autoUpdaterDisabledReason?.type === 'env' ? 'Auto-updates are controlled by an environment variable and cannot be changed here.' : 'Auto-updates are disabled in development builds.'} {autoUpdaterDisabledReason?.type === 'env' && ( Unset {autoUpdaterDisabledReason.envVar} to re-enable auto-updates. )} ) : (