Files
orcs-code/src/components/Settings/Config.tsx
Anandan 2f162af60c Reduce internal-only labeling noise in source comments (#355)
This pass rewrites comment-only ANT-ONLY markers to neutral internal-only
language across the source tree without changing runtime strings, flags,
commands, or protocol identifiers. The goal is to lower obvious internal
prose leakage while keeping the diff mechanically safe and easy to review.

Constraint: Phase B is limited to comments/prose only; runtime strings and user-facing labels remain deferred
Rejected: Broad search-and-replace across strings and command descriptions | too risky for a prose-only pass
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Remaining ANT-ONLY hits are mostly runtime/user-facing strings and should be handled separately from comment cleanup
Tested: bun run build
Tested: bun run smoke
Tested: bun run verify:privacy
Tested: bun run test:provider
Tested: bun run test:provider-recommendation
Not-tested: Full repo typecheck (upstream baseline remains noisy)

Co-authored-by: anandh8x <test@example.com>
2026-04-04 23:26:14 +05:30

1841 lines
73 KiB
TypeScript

import { c as _c } from "react-compiler-runtime";
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
import { feature } from 'bun:bundle';
import { Box, Text, useTheme, useThemeSetting, useTerminalFocus } from '../../ink.js';
import type { KeyboardEvent } from '../../ink/events/keyboard-event.js';
import * as React from 'react';
import { useState, useCallback } from 'react';
import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js';
import figures from 'figures';
import { type GlobalConfig, saveGlobalConfig, getCurrentProjectConfig, type OutputStyle } from '../../utils/config.js';
import { normalizeApiKeyForConfig } from '../../utils/authPortable.js';
import { getGlobalConfig, getAutoUpdaterDisabledReason, formatAutoUpdaterDisabledReason, getRemoteControlAtStartup } from '../../utils/config.js';
import chalk from 'chalk';
import { permissionModeTitle, permissionModeFromString, toExternalPermissionMode, isExternalPermissionMode, EXTERNAL_PERMISSION_MODES, PERMISSION_MODES, type ExternalPermissionMode, type PermissionMode } from '../../utils/permissions/PermissionMode.js';
import { getAutoModeEnabledState, hasAutoModeOptInAnySource, transitionPlanAutoMode } from '../../utils/permissions/permissionSetup.js';
import { logError } from '../../utils/log.js';
import { logEvent, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from 'src/services/analytics/index.js';
import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js';
import { ThemePicker } from '../ThemePicker.js';
import { useAppState, useSetAppState, useAppStateStore } from '../../state/AppState.js';
import { ModelPicker } from '../ModelPicker.js';
import { modelDisplayString, isOpus1mMergeEnabled } from '../../utils/model/model.js';
import { isBilledAsExtraUsage } from '../../utils/extraUsage.js';
import { ClaudeMdExternalIncludesDialog } from '../ClaudeMdExternalIncludesDialog.js';
import { ChannelDowngradeDialog, type ChannelDowngradeChoice } from '../ChannelDowngradeDialog.js';
import { Dialog } from '../design-system/Dialog.js';
import { Select } from '../CustomSelect/index.js';
import { OutputStylePicker } from '../OutputStylePicker.js';
import { LanguagePicker } from '../LanguagePicker.js';
import { getExternalClaudeMdIncludes, getMemoryFiles, hasExternalClaudeMdIncludes } from 'src/utils/claudemd.js';
import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js';
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js';
import { Byline } from '../design-system/Byline.js';
import { useTabHeaderFocus } from '../design-system/Tabs.js';
import { useIsInsideModal } from '../../context/modalContext.js';
import { SearchBox } from '../SearchBox.js';
import { isSupportedTerminal, hasAccessToIDEExtensionDiffFeature } from '../../utils/ide.js';
import { getInitialSettings, getSettingsForSource, updateSettingsForSource } from '../../utils/settings/settings.js';
import { getUserMsgOptIn, setUserMsgOptIn } from '../../bootstrap/state.js';
import { DEFAULT_OUTPUT_STYLE_NAME } from 'src/constants/outputStyles.js';
import { isEnvTruthy, isRunningOnHomespace } from 'src/utils/envUtils.js';
import type { LocalJSXCommandContext, CommandResultDisplay } from '../../commands.js';
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js';
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js';
import { getCliTeammateModeOverride, clearCliTeammateModeOverride } from '../../utils/swarm/backends/teammateModeSnapshot.js';
import { getHardcodedTeammateModelFallback } from '../../utils/swarm/teammateModel.js';
import { useSearchInput } from '../../hooks/useSearchInput.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { clearFastModeCooldown, FAST_MODE_MODEL_DISPLAY, isFastModeAvailable, isFastModeEnabled, getFastModeModel, isFastModeSupportedByModel } from '../../utils/fastMode.js';
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js';
type Props = {
onClose: (result?: string, options?: {
display?: CommandResultDisplay;
}) => void;
context: LocalJSXCommandContext;
setTabsHidden: (hidden: boolean) => void;
onIsSearchModeChange?: (inSearchMode: boolean) => void;
contentHeight?: number;
};
type SettingBase = {
id: string;
label: string;
} | {
id: string;
label: React.ReactNode;
searchText: string;
};
type Setting = (SettingBase & {
value: boolean;
onChange(value: boolean): void;
type: 'boolean';
}) | (SettingBase & {
value: string;
options: string[];
onChange(value: string): void;
type: 'enum';
}) | (SettingBase & {
// For enums that are set by a custom component, we don't need to pass options,
// but we still need a value to display in the top-level config menu
value: string;
onChange(value: string): void;
type: 'managedEnum';
});
type SubMenu = 'Theme' | 'Model' | 'TeammateModel' | 'ExternalIncludes' | 'OutputStyle' | 'ChannelDowngrade' | 'Language' | 'EnableAutoUpdates';
export function Config({
onClose,
context,
setTabsHidden,
onIsSearchModeChange,
contentHeight
}: Props): React.ReactNode {
const {
headerFocused,
focusHeader
} = useTabHeaderFocus();
const insideModal = useIsInsideModal();
const [, setTheme] = useTheme();
const themeSetting = useThemeSetting();
const [globalConfig, setGlobalConfig] = useState(getGlobalConfig());
const initialConfig = React.useRef(getGlobalConfig());
const [settingsData, setSettingsData] = useState(getInitialSettings());
const initialSettingsData = React.useRef(getInitialSettings());
const [currentOutputStyle, setCurrentOutputStyle] = useState<OutputStyle>(settingsData?.outputStyle || DEFAULT_OUTPUT_STYLE_NAME);
const initialOutputStyle = React.useRef(currentOutputStyle);
const [currentLanguage, setCurrentLanguage] = useState<string | undefined>(settingsData?.language);
const initialLanguage = React.useRef(currentLanguage);
const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollOffset, setScrollOffset] = useState(0);
const [isSearchMode, setIsSearchMode] = useState(true);
const isTerminalFocused = useTerminalFocus();
const {
rows
} = useTerminalSize();
// contentHeight is set by Settings.tsx (same value passed to Tabs to fix
// pane height across all tabs — prevents layout jank when switching).
// Reserve ~10 rows for chrome (search box, gaps, footer, scroll hints).
// Fallback calc for standalone rendering (tests).
const paneCap = contentHeight ?? Math.min(Math.floor(rows * 0.8), 30);
const maxVisible = Math.max(5, paneCap - 10);
const mainLoopModel = useAppState(s => s.mainLoopModel);
const verbose = useAppState(s_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<SubMenu | null>(null);
const {
query: searchQuery,
setQuery: setSearchQuery,
cursorOffset: searchCursorOffset
} = useSearchInput({
isActive: isSearchMode && showSubmenu === null && !headerFocused,
onExit: () => setIsSearchMode(false),
onExitUp: focusHeader,
// Ctrl+C/D must reach Settings' useExitOnCtrlCD; 'd' also avoids
// double-action (delete-char + exit-pending).
passthroughCtrlKeys: ['c', 'd']
});
// Tell the parent when Config's own Esc handler is active so Settings cedes
// confirm:no. Only true when search mode owns the keyboard — not when the
// tab header is focused (then Settings must handle Esc-to-close).
const ownsEsc = isSearchMode && !headerFocused;
React.useEffect(() => {
onIsSearchModeChange?.(ownsEsc);
}, [ownsEsc, onIsSearchModeChange]);
const isConnectedToIde = hasAccessToIDEExtensionDiffFeature(context.options.mcpClients);
const isFileCheckpointingAvailable = !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING);
const memoryFiles = React.use(getMemoryFiles(true));
const shouldShowExternalIncludesToggle = hasExternalClaudeMdIncludes(memoryFiles);
const autoUpdaterDisabledReason = getAutoUpdaterDisabledReason();
function onChangeMainModelConfig(value: string | null): void {
const previousModel = mainLoopModel;
logEvent('tengu_config_model_changed', {
from_model: previousModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
to_model: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
setAppState(prev => ({
...prev,
mainLoopModel: value,
mainLoopModelForSession: null
}));
setChanges(prev_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 (internal-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 (internal-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
});
}
}] : []), {
id: 'flickerFreeMode',
label: 'Flicker-free mode',
value: globalConfig.flickerFreeMode ?? (process.env.USER_TYPE === 'ant'),
type: 'boolean' as const,
onChange(flickerFreeMode: boolean) {
saveGlobalConfig(current => ({
...current,
flickerFreeMode
}));
setGlobalConfig({
...getGlobalConfig(),
flickerFreeMode
});
logEvent('tengu_config_changed', {
setting: 'flickerFreeMode' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
value: String(flickerFreeMode) 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: <Text>
Use custom API key:{' '}
<Text bold>
{normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY)}
</Text>
</Text>,
searchText: 'Use custom API key',
value: Boolean(process.env.ANTHROPIC_API_KEY && globalConfig.customApiKeyResponses?.approved?.includes(normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY))),
type: 'boolean' as const,
onChange(useCustomKey: boolean) {
saveGlobalConfig(current_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<string, unknown> | undefined)?.fastMode : undefined, onClose]);
// Restore all state stores to their mount-time snapshots. Changes are
// applied to disk/AppState immediately on toggle, so "cancel" means
// actively writing the old values back.
const revertChanges = useCallback(() => {
// Theme: restores ThemeProvider React state. Must run before the global
// config overwrite since setTheme internally calls saveGlobalConfig with
// a partial update — we want the full snapshot to be the last write.
if (themeSetting !== initialThemeSetting.current) {
setTheme(initialThemeSetting.current);
}
// Global config: full overwrite from snapshot. saveGlobalConfig skips if
// the returned ref equals current (test mode checks ref; prod writes to
// disk but content is identical).
saveGlobalConfig(() => initialConfig.current);
// Settings files: restore each key Config may have touched. undefined
// deletes the key (updateSettingsForSource customizer at settings.ts:368).
const il = initialLocalSettings;
updateSettingsForSource('localSettings', {
spinnerTipsEnabled: il?.spinnerTipsEnabled,
prefersReducedMotion: il?.prefersReducedMotion,
defaultView: il?.defaultView,
outputStyle: il?.outputStyle
});
const iu = initialUserSettings;
updateSettingsForSource('userSettings', {
alwaysThinkingEnabled: iu?.alwaysThinkingEnabled,
fastMode: iu?.fastMode,
promptSuggestionEnabled: iu?.promptSuggestionEnabled,
autoUpdatesChannel: iu?.autoUpdatesChannel,
minimumVersion: iu?.minimumVersion,
language: iu?.language,
...(feature('TRANSCRIPT_CLASSIFIER') ? {
useAutoModeDuringPlan: (iu as {
useAutoModeDuringPlan?: boolean;
} | undefined)?.useAutoModeDuringPlan
} : {}),
// ThemePicker's Ctrl+T writes this key directly — include it so the
// disk state reverts along with the in-memory AppState.settings restore.
syntaxHighlightingDisabled: iu?.syntaxHighlightingDisabled,
// permissions: the defaultMode onChange (above) spreads the MERGED
// settingsData.permissions into userSettings — project/policy allow/deny
// arrays can leak to disk. Spread the full initial snapshot so the
// mergeWith array-customizer (settings.ts:375) replaces leaked arrays.
// Explicitly include defaultMode so undefined triggers the customizer's
// delete path even when iu.permissions lacks that key.
permissions: iu?.permissions === undefined ? undefined : {
...iu.permissions,
defaultMode: iu.permissions.defaultMode
}
});
// AppState: batch-restore all possibly-touched fields.
const ia = initialAppState;
setAppState(prev_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 <Box flexDirection="column" width="100%" tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
{showSubmenu === 'Theme' ? <>
<ThemePicker onThemeSelect={setting_1 => {
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
/>
<Box>
<Text dimColor italic>
<Byline>
<KeyboardShortcutHint shortcut="Enter" action="select" />
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />
</Byline>
</Text>
</Box>
</> : showSubmenu === 'Model' ? <>
<ModelPicker initial={mainLoopModel} onSelect={(model_0, _effort) => {
isDirty.current = true;
onChangeMainModelConfig(model_0);
setShowSubmenu(null);
setTabsHidden(false);
}} onCancel={() => {
setShowSubmenu(null);
setTabsHidden(false);
}} showFastModeNotice={isFastModeEnabled() ? isFastMode && isFastModeSupportedByModel(mainLoopModel) && isFastModeAvailable() : false} />
<Text dimColor>
<Byline>
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />
</Byline>
</Text>
</> : showSubmenu === 'TeammateModel' ? <>
<ModelPicker initial={globalConfig.teammateDefaultModel ?? null} skipSettingsWrite headerText="Default model for newly spawned teammates. The leader can override via the tool call's model parameter." onSelect={(model_1, _effort_0) => {
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);
}} />
<Text dimColor>
<Byline>
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />
</Byline>
</Text>
</> : showSubmenu === 'ExternalIncludes' ? <>
<ClaudeMdExternalIncludesDialog onDone={() => {
setShowSubmenu(null);
setTabsHidden(false);
}} externalIncludes={getExternalClaudeMdIncludes(memoryFiles)} />
<Text dimColor>
<Byline>
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="disable external includes" />
</Byline>
</Text>
</> : showSubmenu === 'OutputStyle' ? <>
<OutputStylePicker initialStyle={currentOutputStyle} onComplete={style => {
isDirty.current = true;
setCurrentOutputStyle(style ?? DEFAULT_OUTPUT_STYLE_NAME);
setShowSubmenu(null);
setTabsHidden(false);
// Save to local settings
updateSettingsForSource('localSettings', {
outputStyle: style
});
void logEvent('tengu_output_style_changed', {
style: (style ?? DEFAULT_OUTPUT_STYLE_NAME) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
source: 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
settings_source: 'localSettings' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
}} onCancel={() => {
setShowSubmenu(null);
setTabsHidden(false);
}} />
<Text dimColor>
<Byline>
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />
</Byline>
</Text>
</> : showSubmenu === 'Language' ? <>
<LanguagePicker initialLanguage={currentLanguage} onComplete={language => {
isDirty.current = true;
setCurrentLanguage(language);
setShowSubmenu(null);
setTabsHidden(false);
// Save to user settings
updateSettingsForSource('userSettings', {
language
});
void logEvent('tengu_language_changed', {
language: (language ?? 'default') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
source: 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
}} onCancel={() => {
setShowSubmenu(null);
setTabsHidden(false);
}} />
<Text dimColor>
<Byline>
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
<ConfigurableShortcutHint action="confirm:no" context="Settings" fallback="Esc" description="cancel" />
</Byline>
</Text>
</> : showSubmenu === 'EnableAutoUpdates' ? <Dialog title="Enable Auto-Updates" onCancel={() => {
setShowSubmenu(null);
setTabsHidden(false);
}} hideBorder hideInputGuide>
{autoUpdaterDisabledReason?.type !== 'config' ? <>
<Text>
{autoUpdaterDisabledReason?.type === 'env' ? 'Auto-updates are controlled by an environment variable and cannot be changed here.' : 'Auto-updates are disabled in development builds.'}
</Text>
{autoUpdaterDisabledReason?.type === 'env' && <Text dimColor>
Unset {autoUpdaterDisabledReason.envVar} to re-enable
auto-updates.
</Text>}
</> : <Select options={[{
label: 'Enable with latest channel',
value: 'latest'
}, {
label: 'Enable with stable channel',
value: 'stable'
}]} onChange={(channel: string) => {
isDirty.current = true;
setShowSubmenu(null);
setTabsHidden(false);
saveGlobalConfig(current_24 => ({
...current_24,
autoUpdates: true
}));
setGlobalConfig({
...getGlobalConfig(),
autoUpdates: true
});
updateSettingsForSource('userSettings', {
autoUpdatesChannel: channel as 'latest' | 'stable',
minimumVersion: undefined
});
setSettingsData(prev_26 => ({
...prev_26,
autoUpdatesChannel: channel as 'latest' | 'stable',
minimumVersion: undefined
}));
logEvent('tengu_autoupdate_enabled', {
channel: channel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
}} />}
</Dialog> : showSubmenu === 'ChannelDowngrade' ? <ChannelDowngradeDialog currentVersion={MACRO.VERSION} onChoice={(choice: ChannelDowngradeChoice) => {
setShowSubmenu(null);
setTabsHidden(false);
if (choice === 'cancel') {
// User cancelled - don't change anything
return;
}
isDirty.current = true;
// Switch to stable channel
const newSettings: {
autoUpdatesChannel: 'stable';
minimumVersion?: string;
} = {
autoUpdatesChannel: 'stable'
};
if (choice === 'stay') {
// User wants to stay on current version until stable catches up
newSettings.minimumVersion = MACRO.VERSION;
}
updateSettingsForSource('userSettings', newSettings);
setSettingsData(prev_27 => ({
...prev_27,
...newSettings
}));
logEvent('tengu_autoupdate_channel_changed', {
channel: 'stable' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
minimum_version_set: choice === 'stay'
});
}} /> : <Box flexDirection="column" gap={1} marginY={insideModal ? undefined : 1}>
<SearchBox query={searchQuery} isFocused={isSearchMode && !headerFocused} isTerminalFocused={isTerminalFocused} cursorOffset={searchCursorOffset} placeholder="Search settings…" />
<Box flexDirection="column">
{filteredSettingsItems.length === 0 ? <Text dimColor italic>
No settings match &quot;{searchQuery}&quot;
</Text> : <>
{scrollOffset > 0 && <Text dimColor>
{figures.arrowUp} {scrollOffset} more above
</Text>}
{filteredSettingsItems.slice(scrollOffset, scrollOffset + maxVisible).map((setting_2, i) => {
const actualIndex = scrollOffset + i;
const isSelected = actualIndex === selectedIndex && !headerFocused && !isSearchMode;
return <React.Fragment key={setting_2.id}>
<Box>
<Box width={44}>
<Text color={isSelected ? 'suggestion' : undefined}>
{isSelected ? figures.pointer : ' '}{' '}
{setting_2.label}
</Text>
</Box>
<Box key={isSelected ? 'selected' : 'unselected'}>
{setting_2.type === 'boolean' ? <>
<Text color={isSelected ? 'suggestion' : undefined}>
{setting_2.value.toString()}
</Text>
{showThinkingWarning && setting_2.id === 'thinkingEnabled' && <Text color="warning">
{' '}
Changing thinking mode mid-conversation
will increase latency and may reduce
quality.
</Text>}
</> : setting_2.id === 'theme' ? <Text color={isSelected ? 'suggestion' : undefined}>
{THEME_LABELS[setting_2.value.toString()] ?? setting_2.value.toString()}
</Text> : setting_2.id === 'notifChannel' ? <Text color={isSelected ? 'suggestion' : undefined}>
<NotifChannelLabel value={setting_2.value.toString()} />
</Text> : setting_2.id === 'defaultPermissionMode' ? <Text color={isSelected ? 'suggestion' : undefined}>
{permissionModeTitle(setting_2.value as PermissionMode)}
</Text> : setting_2.id === 'autoUpdatesChannel' && autoUpdaterDisabledReason ? <Box flexDirection="column">
<Text color={isSelected ? 'suggestion' : undefined}>
disabled
</Text>
<Text dimColor>
(
{formatAutoUpdaterDisabledReason(autoUpdaterDisabledReason)}
)
</Text>
</Box> : <Text color={isSelected ? 'suggestion' : undefined}>
{setting_2.value.toString()}
</Text>}
</Box>
</Box>
</React.Fragment>;
})}
{scrollOffset + maxVisible < filteredSettingsItems.length && <Text dimColor>
{figures.arrowDown}{' '}
{filteredSettingsItems.length - scrollOffset - maxVisible}{' '}
more below
</Text>}
</>}
</Box>
{headerFocused ? <Text dimColor>
<Byline>
<KeyboardShortcutHint shortcut="←/→ tab" action="switch" />
<KeyboardShortcutHint shortcut="↓" action="return" />
<ConfigurableShortcutHint action="confirm:no" context="Settings" fallback="Esc" description="close" />
</Byline>
</Text> : isSearchMode ? <Text dimColor>
<Byline>
<Text>Type to filter</Text>
<KeyboardShortcutHint shortcut="Enter/↓" action="select" />
<KeyboardShortcutHint shortcut="↑" action="tabs" />
<ConfigurableShortcutHint action="confirm:no" context="Settings" fallback="Esc" description="clear" />
</Byline>
</Text> : <Text dimColor>
<Byline>
<ConfigurableShortcutHint action="select:accept" context="Settings" fallback="Space" description="change" />
<ConfigurableShortcutHint action="settings:close" context="Settings" fallback="Enter" description="save" />
<ConfigurableShortcutHint action="settings:search" context="Settings" fallback="/" description="search" />
<ConfigurableShortcutHint action="confirm:no" context="Settings" fallback="Esc" description="cancel" />
</Byline>
</Text>}
</Box>}
</Box>;
}
function teammateModelDisplayString(value: string | null | undefined): string {
if (value === undefined) {
return modelDisplayString(getHardcodedTeammateModelFallback());
}
if (value === null) return "Default (leader's model)";
return modelDisplayString(value);
}
const THEME_LABELS: Record<string, string> = {
auto: 'Auto (match terminal)',
dark: 'Dark mode',
light: 'Light mode',
'dark-daltonized': 'Dark mode (colorblind-friendly)',
'light-daltonized': 'Light mode (colorblind-friendly)',
'dark-ansi': 'Dark mode (ANSI colors only)',
'light-ansi': 'Light mode (ANSI colors only)'
};
function NotifChannelLabel(t0) {
const $ = _c(4);
const {
value
} = t0;
switch (value) {
case "auto":
{
return "Auto";
}
case "iterm2":
{
let t1;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t1 = <Text>iTerm2 <Text dimColor={true}>(OSC 9)</Text></Text>;
$[0] = t1;
} else {
t1 = $[0];
}
return t1;
}
case "terminal_bell":
{
let t1;
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
t1 = <Text>Terminal Bell <Text dimColor={true}>(\a)</Text></Text>;
$[1] = t1;
} else {
t1 = $[1];
}
return t1;
}
case "kitty":
{
let t1;
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
t1 = <Text>Kitty <Text dimColor={true}>(OSC 99)</Text></Text>;
$[2] = t1;
} else {
t1 = $[2];
}
return t1;
}
case "ghostty":
{
let t1;
if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
t1 = <Text>Ghostty <Text dimColor={true}>(OSC 777)</Text></Text>;
$[3] = t1;
} else {
t1 = $[3];
}
return t1;
}
case "iterm2_with_bell":
{
return "iTerm2 w/ Bell";
}
case "notifications_disabled":
{
return "Disabled";
}
default:
{
return value;
}
}
}