diff --git "a/src/components/Settings/Config.tsx.bak" "b/src/components/Settings/Config.tsx.bak" new file mode 100644--- /dev/null +++ "b/src/components/Settings/Config.tsx.bak" @@ -0,0 +1,1822 @@ +import { c as _c } from "react/compiler-runtime"; +// 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_0 => s_0.verbose); + const thinkingEnabled = useAppState(s_1 => s_1.thinkingEnabled); + const isFastMode = useAppState(s_2 => isFastModeEnabled() ? s_2.fastMode : false); + const promptSuggestionEnabled = useAppState(s_3 => s_3.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_4 = store.getState(); + return { + mainLoopModel: s_4.mainLoopModel, + mainLoopModelForSession: s_4.mainLoopModelForSession, + verbose: s_4.verbose, + thinkingEnabled: s_4.thinkingEnabled, + fastMode: s_4.fastMode, + promptSuggestionEnabled: s_4.promptSuggestionEnabled, + isBriefOnly: s_4.isBriefOnly, + replBridgeEnabled: s_4.replBridgeEnabled, + replBridgeOutboundOnly: s_4.replBridgeOutboundOnly, + settings: s_4.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_0 => { + const valStr = modelDisplayString(value) + (isBilledAsExtraUsage(value, false, isOpus1mMergeEnabled()) ? ' · Billed as extra usage' : ''); + if ('model' in prev_0) { + const { + model, + ...rest + } = prev_0; + return { + ...rest, + model: valStr + }; + } + return { + ...prev_0, + model: valStr + }; + }); + } + function onChangeVerbose(value_0: boolean): void { + // Update the global config to persist the setting + saveGlobalConfig(current => ({ + ...current, + verbose: value_0 + })); + setGlobalConfig({ + ...getGlobalConfig(), + verbose: value_0 + }); + + // Update the app state for immediate UI feedback + setAppState(prev_1 => ({ + ...prev_1, + verbose: value_0 + })); + setChanges(prev_2 => { + if ('verbose' in prev_2) { + const { + verbose: verbose_0, + ...rest_0 + } = prev_2; + return rest_0; + } + return { + ...prev_2, + verbose: value_0 + }; + }); + } + + // 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_0 => ({ + ...current_0, + 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_3 => ({ + ...prev_3, + 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_4 => ({ + ...prev_4, + prefersReducedMotion + })); + // Sync to AppState so components react immediately + setAppState(prev_5 => ({ + ...prev_5, + settings: { + ...prev_5.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_6 => ({ + ...prev_6, + 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_0: boolean) { + clearFastModeCooldown(); + updateSettingsForSource('userSettings', { + fastMode: enabled_0 ? true : undefined + }); + if (enabled_0) { + setAppState(prev_7 => ({ + ...prev_7, + mainLoopModel: getFastModeModel(), + mainLoopModelForSession: null, + fastMode: true + })); + setChanges(prev_8 => ({ + ...prev_8, + model: getFastModeModel(), + 'Fast mode': 'ON' + })); + } else { + setAppState(prev_9 => ({ + ...prev_9, + fastMode: false + })); + setChanges(prev_10 => ({ + ...prev_10, + '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_1: boolean) { + setAppState(prev_11 => ({ + ...prev_11, + promptSuggestionEnabled: enabled_1 + })); + updateSettingsForSource('userSettings', { + promptSuggestionEnabled: enabled_1 ? undefined : false + }); + } + }] : []), + // Speculation toggle (ant-only) + ...("external" === 'ant' ? [{ + id: 'speculationEnabled', + label: 'Speculative execution', + value: globalConfig.speculationEnabled ?? true, + type: 'boolean' as const, + onChange(enabled_2: boolean) { + saveGlobalConfig(current_1 => { + if (current_1.speculationEnabled === enabled_2) return current_1; + return { + ...current_1, + speculationEnabled: enabled_2 + }; + }); + setGlobalConfig({ + ...getGlobalConfig(), + speculationEnabled: enabled_2 + }); + logEvent('tengu_speculation_setting_changed', { + enabled: enabled_2 + }); + } + }] : []), ...(isFileCheckpointingAvailable ? [{ + id: 'fileCheckpointingEnabled', + label: 'Rewind code (checkpoints)', + value: globalConfig.fileCheckpointingEnabled, + type: 'boolean' as const, + onChange(enabled_3: boolean) { + saveGlobalConfig(current_2 => ({ + ...current_2, + fileCheckpointingEnabled: enabled_3 + })); + setGlobalConfig({ + ...getGlobalConfig(), + fileCheckpointingEnabled: enabled_3 + }); + logEvent('tengu_file_history_snapshots_setting_changed', { + enabled: enabled_3 + }); + } + }] : []), { + 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_3 => ({ + ...current_3, + 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_4 => ({ + ...current_4, + 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_5 => ({ + ...current_5, + 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_12 => ({ + ...prev_12, + permissions: { + ...prev_12?.permissions, + defaultMode: validatedMode as (typeof PERMISSION_MODES)[number] + } + })); + // Track changes + setChanges(prev_13 => ({ + ...prev_13, + 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_14 => ({ + ...prev_14, + useAutoModeDuringPlan + })); + // Internal writes suppress the file watcher, so + // applySettingsChange won't fire. Reconcile directly so + // mid-plan toggles take effect immediately. + setAppState(prev_15 => { + const next = transitionPlanAutoMode(prev_15.toolPermissionContext); + if (next === prev_15.toolPermissionContext) return prev_15; + return { + ...prev_15, + toolPermissionContext: next + }; + }); + setChanges(prev_16 => ({ + ...prev_16, + '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_6 => ({ + ...current_6, + 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_7 => ({ + ...current_7, + 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_8 => ({ + ...current_8, + 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_9 => ({ + ...current_9, + 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_10 => ({ + ...current_10, + taskCompleteNotifEnabled + })); + setGlobalConfig({ + ...getGlobalConfig(), + taskCompleteNotifEnabled + }); + } + }, { + id: 'inputNeededNotifEnabled', + label: 'Push when input needed', + value: globalConfig.inputNeededNotifEnabled ?? false, + type: 'boolean' as const, + onChange(inputNeededNotifEnabled: boolean) { + saveGlobalConfig(current_11 => ({ + ...current_11, + inputNeededNotifEnabled + })); + setGlobalConfig({ + ...getGlobalConfig(), + inputNeededNotifEnabled + }); + } + }, { + id: 'agentPushNotifEnabled', + label: 'Push when Claude decides', + value: globalConfig.agentPushNotifEnabled ?? false, + type: 'boolean' as const, + onChange(agentPushNotifEnabled: boolean) { + saveGlobalConfig(current_12 => ({ + ...current_12, + 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_17 => ({ + ...prev_17, + defaultView + })); + const nextBrief = defaultView === 'chat'; + setAppState(prev_18 => { + if (prev_18.isBriefOnly === nextBrief) return prev_18; + return { + ...prev_18, + 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_19 => ({ + ...prev_19, + '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_1: string) { + saveGlobalConfig(current_13 => ({ + ...current_13, + editorMode: value_1 as GlobalConfig['editorMode'] + })); + setGlobalConfig({ + ...getGlobalConfig(), + editorMode: value_1 as GlobalConfig['editorMode'] + }); + logEvent('tengu_editor_mode_changed', { + mode: value_1 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_4: boolean) { + saveGlobalConfig(current_14 => { + if (current_14.prStatusFooterEnabled === enabled_4) return current_14; + return { + ...current_14, + prStatusFooterEnabled: enabled_4 + }; + }); + setGlobalConfig({ + ...getGlobalConfig(), + prStatusFooterEnabled: enabled_4 + }); + logEvent('tengu_pr_status_footer_setting_changed', { + enabled: enabled_4 + }); + } + }, { + 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_15 => ({ + ...current_15, + 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_16 => ({ + ...current_16, + 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_17 => ({ + ...current_17, + 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_5: boolean) { + saveGlobalConfig(current_18 => ({ + ...current_18, + claudeInChromeDefaultEnabled: enabled_5 + })); + setGlobalConfig({ + ...getGlobalConfig(), + claudeInChromeDefaultEnabled: enabled_5 + }); + logEvent('tengu_claude_in_chrome_setting_changed', { + enabled: enabled_5 + }); + } + }, + // 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_0: string) { + if (mode_0 !== 'auto' && mode_0 !== 'tmux' && mode_0 !== 'in-process') { + return; + } + // Clear CLI override and set new mode (pass mode to avoid race condition) + clearCliTeammateModeOverride(mode_0); + saveGlobalConfig(current_19 => ({ + ...current_19, + teammateMode: mode_0 + })); + setGlobalConfig({ + ...getGlobalConfig(), + teammateMode: mode_0 + }); + logEvent('tengu_teammate_mode_changed', { + mode: mode_0 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_0: string) { + if (selected_0 === 'default') { + // Unset the config key so it falls back to the platform default + saveGlobalConfig(current_20 => { + if (current_20.remoteControlAtStartup === undefined) return current_20; + const next_0 = { + ...current_20 + }; + delete next_0.remoteControlAtStartup; + return next_0; + }); + setGlobalConfig({ + ...getGlobalConfig(), + remoteControlAtStartup: undefined + }); + } else { + const enabled_6 = selected_0 === 'true'; + saveGlobalConfig(current_21 => { + if (current_21.remoteControlAtStartup === enabled_6) return current_21; + return { + ...current_21, + remoteControlAtStartup: enabled_6 + }; + }); + setGlobalConfig({ + ...getGlobalConfig(), + remoteControlAtStartup: enabled_6 + }); + } + // Sync to AppState so useReplBridge reacts immediately + const resolved = getRemoteControlAtStartup(); + setAppState(prev_20 => { + if (prev_20.replBridgeEnabled === resolved && !prev_20.replBridgeOutboundOnly) return prev_20; + return { + ...prev_20, + 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_22 => { + const updated = { + ...current_22 + }; + 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_0 => k_0 !== truncatedKey) + }; + } else { + updated.customApiKeyResponses = { + ...updated.customApiKeyResponses, + approved: (updated.customApiKeyResponses.approved ?? []).filter(k_1 => k_1 !== truncatedKey), + rejected: [...(updated.customApiKeyResponses.rejected ?? []).filter(k_2 => k_2 !== 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_21 => { + if (selectedIndex < prev_21) return selectedIndex; + if (selectedIndex >= prev_21 + maxVisible) return selectedIndex - maxVisible + 1; + return prev_21; + }); + }, [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_0: number) => { + setScrollOffset(prev_22 => { + if (newIndex_0 < prev_22) return newIndex_0; + if (newIndex_0 >= prev_22 + maxVisible) return newIndex_0 - maxVisible + 1; + return prev_22; + }); + }, [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_2]) => { + logEvent('tengu_config_changed', { + key: key as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + value: value_2 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + }); + return `Set ${key} to ${chalk.bold(value_2)}`; + }); + // 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_23 => ({ + ...prev_23, + 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_23.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_0 = filteredSettingsItems[selectedIndex]; + if (!setting_0 || !setting_0.onChange) { + return; + } + if (setting_0.type === 'boolean') { + isDirty.current = true; + setting_0.onChange(!setting_0.value); + if (setting_0.id === 'thinkingEnabled') { + const newValue = !setting_0.value; + const backToInitial = newValue === initialThinkingEnabled.current; + if (backToInitial) { + setShowThinkingWarning(false); + } else if (context.messages.some(m_0 => m_0.type === 'assistant')) { + setShowThinkingWarning(true); + } + } + return; + } + if (setting_0.id === 'theme' || setting_0.id === 'model' || setting_0.id === 'teammateDefaultModel' || setting_0.id === 'showExternalIncludesDialog' || setting_0.id === 'outputStyle' || setting_0.id === 'language') { + // managedEnum items open a submenu — isDirty is set by the submenu's + // completion callback, not here (submenu may be cancelled). + switch (setting_0.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_0.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_24 => ({ + ...prev_24, + autoUpdatesChannel: 'latest', + minimumVersion: undefined + })); + logEvent('tengu_autoupdate_channel_changed', { + channel: 'latest' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + }); + } + return; + } + if (setting_0.type === 'enum') { + isDirty.current = true; + const currentIndex = setting_0.options.indexOf(setting_0.value); + const nextIndex = (currentIndex + 1) % setting_0.options.length; + setting_0.onChange(setting_0.options[nextIndex]!); + return; + } + }, [autoUpdaterDisabledReason, filteredSettingsItems, selectedIndex, settingsData?.autoUpdatesChannel, setTabsHidden]); + const moveSelection = (delta: -1 | 1): void => { + setShowThinkingWarning(false); + const newIndex_1 = Math.max(0, Math.min(filteredSettingsItems.length - 1, selectedIndex + delta)); + setSelectedIndex(newIndex_1); + adjustScrollOffset(newIndex_1); + }; + 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_1); + 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_0); + 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_1 === null) { + return; + } + isDirty.current = true; + saveGlobalConfig(current_23 => current_23.teammateDefaultModel === model_1 ? current_23 : { + ...current_23, + teammateDefaultModel: model_1 + }); + setGlobalConfig({ + ...getGlobalConfig(), + teammateDefaultModel: model_1 + }); + setChanges(prev_25 => ({ + ...prev_25, + teammateDefaultModel: teammateModelDisplayString(model_1) + })); + logEvent('tengu_teammate_default_model_changed', { + model: model_1 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. + } + :