Roo-Code/webview-ui/src/components/settings/SettingsView.tsx
Roo Code 3068bdca4b feat: add configurable terminal pool size with cleanup on task release
- Add maxTerminalPoolSize setting (1-20, default 5) to global settings schema
- Enforce pool size limit in TerminalRegistry.createTerminal() by disposing
  oldest idle terminals when at capacity
- Update releaseTerminalsForTask() to dispose idle terminals when a task ends
- Wire setting through ClineProvider, webviewMessageHandler, and ExtensionStateContext
- Add slider UI in Terminal Settings (Basic section)
- Add English i18n translation for the new setting
- Add tests for pool size enforcement and task release cleanup

Addresses #12153
2026-04-20 07:26:31 +00:00

963 lines
32 KiB
TypeScript

import React, {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react"
import {
CheckCheck,
GitBranch,
Bell,
Database,
SquareTerminal,
FlaskConical,
AlertTriangle,
Globe,
Info,
MessageSquare,
LucideIcon,
SquareSlash,
Glasses,
Plug,
Server,
Users2,
ArrowLeft,
GitCommitVertical,
GraduationCap,
} from "lucide-react"
import {
type ProviderSettings,
type ExperimentId,
type TelemetrySetting,
DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
ImageGenerationProvider,
} from "@roo-code/types"
import { vscode } from "@src/utils/vscode"
import { cn } from "@src/lib/utils"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { ExtensionStateContextType, useExtensionState } from "@src/context/ExtensionStateContext"
import {
AlertDialog,
AlertDialogContent,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogCancel,
AlertDialogAction,
AlertDialogHeader,
AlertDialogFooter,
Button,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
StandardTooltip,
} from "@src/components/ui"
import { Tab, TabContent, TabHeader, TabList, TabTrigger } from "../common/Tab"
import { SetCachedStateField, SetExperimentEnabled } from "./types"
import { SectionHeader } from "./SectionHeader"
import ApiConfigManager from "./ApiConfigManager"
import ApiOptions from "./ApiOptions"
import { AutoApproveSettings } from "./AutoApproveSettings"
import { CheckpointSettings } from "./CheckpointSettings"
import { NotificationSettings } from "./NotificationSettings"
import { ContextManagementSettings } from "./ContextManagementSettings"
import { TerminalSettings } from "./TerminalSettings"
import { ExperimentalSettings } from "./ExperimentalSettings"
import { LanguageSettings } from "./LanguageSettings"
import { About } from "./About"
import { Section } from "./Section"
import PromptsSettings from "./PromptsSettings"
import { SlashCommandsSettings } from "./SlashCommandsSettings"
import { SkillsSettings } from "./SkillsSettings"
import { UISettings } from "./UISettings"
import ModesView from "../modes/ModesView"
import McpView from "../mcp/McpView"
import { WorktreesView } from "../worktrees/WorktreesView"
import { SettingsSearch } from "./SettingsSearch"
import { useSearchIndexRegistry, SearchIndexProvider } from "./useSettingsSearch"
export const settingsTabsContainer = "flex flex-1 overflow-hidden [&.narrow_.tab-label]:hidden"
export const settingsTabList =
"w-48 data-[compact=true]:w-12 flex-shrink-0 flex flex-col overflow-y-auto overflow-x-hidden border-r border-vscode-sideBar-background"
export const settingsTabTrigger =
"whitespace-nowrap overflow-hidden min-w-0 h-12 px-4 py-3 box-border flex items-center border-l-2 border-transparent text-vscode-foreground opacity-70 hover:bg-vscode-list-hoverBackground data-[compact=true]:w-12 data-[compact=true]:p-4"
export const settingsTabTriggerActive = "opacity-100 border-vscode-focusBorder bg-vscode-list-activeSelectionBackground"
export interface SettingsViewRef {
checkUnsaveChanges: (then: () => void) => void
}
export const sectionNames = [
"providers",
"autoApprove",
"slashCommands",
"skills",
"checkpoints",
"notifications",
"contextManagement",
"terminal",
"modes",
"mcp",
"worktrees",
"prompts",
"ui",
"experimental",
"language",
"about",
] as const
export type SectionName = (typeof sectionNames)[number]
type SettingsViewProps = {
onDone: () => void
targetSection?: string
}
const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, targetSection }, ref) => {
const { t } = useAppTranslation()
const extensionState = useExtensionState()
const { currentApiConfigName, listApiConfigMeta, uriScheme, settingsImportedAt } = extensionState
const [isDiscardDialogShow, setDiscardDialogShow] = useState(false)
const [isChangeDetected, setChangeDetected] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined)
const [activeTab, setActiveTab] = useState<SectionName>(
targetSection && sectionNames.includes(targetSection as SectionName)
? (targetSection as SectionName)
: "providers",
)
const scrollPositions = useRef<Record<SectionName, number>>(
Object.fromEntries(sectionNames.map((s) => [s, 0])) as Record<SectionName, number>,
)
const contentRef = useRef<HTMLDivElement | null>(null)
const prevApiConfigName = useRef(currentApiConfigName)
const confirmDialogHandler = useRef<() => void>()
const [cachedState, setCachedState] = useState(() => extensionState)
const {
alwaysAllowReadOnly,
alwaysAllowReadOnlyOutsideWorkspace,
allowedCommands,
deniedCommands,
allowedMaxRequests,
allowedMaxCost,
language,
alwaysAllowExecute,
alwaysAllowMcp,
alwaysAllowModeSwitch,
alwaysAllowSubtasks,
alwaysAllowWrite,
alwaysAllowWriteOutsideWorkspace,
alwaysAllowWriteProtected,
autoCondenseContext,
autoCondenseContextPercent,
enableCheckpoints,
checkpointTimeout,
experiments,
maxOpenTabsContext,
maxWorkspaceFiles,
mcpEnabled,
soundEnabled,
ttsEnabled,
ttsSpeed,
soundVolume,
telemetrySetting,
terminalOutputPreviewSize,
terminalShellIntegrationTimeout,
terminalShellIntegrationDisabled, // Added from upstream
terminalCommandDelay,
terminalPowershellCounter,
terminalZshClearEolMark,
terminalZshOhMy,
terminalZshP10k,
terminalZdotdir,
maxTerminalPoolSize,
writeDelayMs,
showRooIgnoredFiles,
enableSubfolderRules,
maxImageFileSize,
maxTotalImageSize,
customSupportPrompts,
profileThresholds,
alwaysAllowFollowupQuestions,
followupAutoApproveTimeoutMs,
includeDiagnosticMessages,
maxDiagnosticMessages,
includeTaskHistoryInEnhance,
imageGenerationProvider,
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
reasoningBlockCollapsed,
enterBehavior,
includeCurrentTime,
includeCurrentCost,
maxGitStatusFiles,
} = cachedState
const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
useEffect(() => {
// Update only when currentApiConfigName is changed.
// Expected to be triggered by loadApiConfiguration/upsertApiConfiguration.
if (prevApiConfigName.current === currentApiConfigName) {
return
}
setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState }))
prevApiConfigName.current = currentApiConfigName
setChangeDetected(false)
}, [currentApiConfigName, extensionState])
// Bust the cache when settings are imported.
useEffect(() => {
if (settingsImportedAt) {
setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState }))
setChangeDetected(false)
}
}, [settingsImportedAt, extensionState])
const setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType> = useCallback((field, value) => {
setCachedState((prevState) => {
if (prevState[field] === value) {
return prevState
}
setChangeDetected(true)
return { ...prevState, [field]: value }
})
}, [])
const setApiConfigurationField = useCallback(
<K extends keyof ProviderSettings>(field: K, value: ProviderSettings[K], isUserAction: boolean = true) => {
setCachedState((prevState) => {
if (prevState.apiConfiguration?.[field] === value) {
return prevState
}
const previousValue = prevState.apiConfiguration?.[field]
// Helper to check if two values are semantically equal
const areValuesEqual = (a: any, b: any): boolean => {
if (a === b) return true
if (a == null && b == null) return true
if (typeof a !== typeof b) return false
if (typeof a === "object" && typeof b === "object") {
return JSON.stringify(a) === JSON.stringify(b)
}
return false
}
// Only skip change detection for automatic initialization (not user actions)
// This prevents the dirty state when the component initializes and auto-syncs values
const isInitialSync =
!isUserAction &&
(previousValue === undefined || previousValue === "" || previousValue === null) &&
value !== undefined &&
value !== "" &&
value !== null
// Also skip if it's an automatic sync with semantically equal values
const isAutomaticNoOpSync = !isUserAction && areValuesEqual(previousValue, value)
if (!isInitialSync && !isAutomaticNoOpSync) {
setChangeDetected(true)
}
return { ...prevState, apiConfiguration: { ...prevState.apiConfiguration, [field]: value } }
})
},
[],
)
const setExperimentEnabled: SetExperimentEnabled = useCallback((id: ExperimentId, enabled: boolean) => {
setCachedState((prevState) => {
if (prevState.experiments?.[id] === enabled) {
return prevState
}
setChangeDetected(true)
return { ...prevState, experiments: { ...prevState.experiments, [id]: enabled } }
})
}, [])
const setTelemetrySetting = useCallback((setting: TelemetrySetting) => {
setCachedState((prevState) => {
if (prevState.telemetrySetting === setting) {
return prevState
}
setChangeDetected(true)
return { ...prevState, telemetrySetting: setting }
})
}, [])
const setDebug = useCallback((debug: boolean) => {
setCachedState((prevState) => {
if (prevState.debug === debug) {
return prevState
}
setChangeDetected(true)
return { ...prevState, debug }
})
}, [])
const setImageGenerationProvider = useCallback((provider: ImageGenerationProvider) => {
setCachedState((prevState) => {
if (prevState.imageGenerationProvider !== provider) {
setChangeDetected(true)
}
return { ...prevState, imageGenerationProvider: provider }
})
}, [])
const setOpenRouterImageApiKey = useCallback((apiKey: string) => {
setCachedState((prevState) => {
if (prevState.openRouterImageApiKey !== apiKey) {
setChangeDetected(true)
}
return { ...prevState, openRouterImageApiKey: apiKey }
})
}, [])
const setImageGenerationSelectedModel = useCallback((model: string) => {
setCachedState((prevState) => {
if (prevState.openRouterImageGenerationSelectedModel !== model) {
setChangeDetected(true)
}
return { ...prevState, openRouterImageGenerationSelectedModel: model }
})
}, [])
const setCustomSupportPromptsField = useCallback((prompts: Record<string, string | undefined>) => {
setCachedState((prevState) => {
const previousStr = JSON.stringify(prevState.customSupportPrompts)
const newStr = JSON.stringify(prompts)
if (previousStr === newStr) {
return prevState
}
setChangeDetected(true)
return { ...prevState, customSupportPrompts: prompts }
})
}, [])
const isSettingValid = !errorMessage
const handleSubmit = () => {
if (isSettingValid) {
vscode.postMessage({
type: "updateSettings",
updatedSettings: {
language,
alwaysAllowReadOnly: alwaysAllowReadOnly ?? undefined,
alwaysAllowReadOnlyOutsideWorkspace: alwaysAllowReadOnlyOutsideWorkspace ?? undefined,
alwaysAllowWrite: alwaysAllowWrite ?? undefined,
alwaysAllowWriteOutsideWorkspace: alwaysAllowWriteOutsideWorkspace ?? undefined,
alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? undefined,
alwaysAllowExecute: alwaysAllowExecute ?? undefined,
alwaysAllowMcp,
alwaysAllowModeSwitch,
allowedCommands: allowedCommands ?? [],
deniedCommands: deniedCommands ?? [],
// Note that we use `null` instead of `undefined` since `JSON.stringify`
// will omit `undefined` when serializing the object and passing it to the
// extension host. We may need to do the same for other nullable fields.
allowedMaxRequests: allowedMaxRequests ?? null,
allowedMaxCost: allowedMaxCost ?? null,
autoCondenseContext,
autoCondenseContextPercent,
soundEnabled: soundEnabled ?? true,
soundVolume: soundVolume ?? 0.5,
ttsEnabled,
ttsSpeed,
enableCheckpoints: enableCheckpoints ?? false,
checkpointTimeout: checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
writeDelayMs,
terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? 30_000,
terminalShellIntegrationDisabled,
terminalCommandDelay,
terminalPowershellCounter,
terminalZshClearEolMark,
terminalZshOhMy,
terminalZshP10k,
terminalZdotdir,
maxTerminalPoolSize: maxTerminalPoolSize ?? 5,
terminalOutputPreviewSize: terminalOutputPreviewSize ?? "medium",
mcpEnabled,
maxOpenTabsContext: Math.min(Math.max(0, maxOpenTabsContext ?? 20), 500),
maxWorkspaceFiles: Math.min(Math.max(0, maxWorkspaceFiles ?? 200), 500),
showRooIgnoredFiles: showRooIgnoredFiles ?? true,
enableSubfolderRules: enableSubfolderRules ?? false,
maxImageFileSize: maxImageFileSize ?? 5,
maxTotalImageSize: maxTotalImageSize ?? 20,
includeDiagnosticMessages:
includeDiagnosticMessages !== undefined ? includeDiagnosticMessages : true,
maxDiagnosticMessages: maxDiagnosticMessages ?? 50,
alwaysAllowSubtasks,
alwaysAllowFollowupQuestions: alwaysAllowFollowupQuestions ?? false,
followupAutoApproveTimeoutMs,
includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true,
reasoningBlockCollapsed: reasoningBlockCollapsed ?? true,
enterBehavior: enterBehavior ?? "send",
includeCurrentTime: includeCurrentTime ?? true,
includeCurrentCost: includeCurrentCost ?? true,
maxGitStatusFiles: maxGitStatusFiles ?? 0,
profileThresholds,
imageGenerationProvider,
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
experiments,
customSupportPrompts,
},
})
// These have more complex logic so they aren't (yet) handled
// by the `updateSettings` message.
vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration })
vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting })
vscode.postMessage({ type: "debugSetting", bool: cachedState.debug })
setChangeDetected(false)
}
}
const checkUnsaveChanges = useCallback(
(then: () => void) => {
if (isChangeDetected) {
confirmDialogHandler.current = then
setDiscardDialogShow(true)
} else {
then()
}
},
[isChangeDetected],
)
useImperativeHandle(ref, () => ({ checkUnsaveChanges }), [checkUnsaveChanges])
const onConfirmDialogResult = useCallback(
(confirm: boolean) => {
if (confirm) {
// Discard changes: Reset state and flag
setCachedState(extensionState) // Revert to original state
setChangeDetected(false) // Reset change flag
confirmDialogHandler.current?.() // Execute the pending action (e.g., tab switch)
}
// If confirm is false (Cancel), do nothing, dialog closes automatically
},
[extensionState], // Depend on extensionState to get the latest original state
)
// Handle tab changes with unsaved changes check
const handleTabChange = useCallback(
(newTab: SectionName) => {
if (contentRef.current) {
scrollPositions.current[activeTab] = contentRef.current.scrollTop
}
setActiveTab(newTab)
},
[activeTab],
)
useLayoutEffect(() => {
if (contentRef.current) {
contentRef.current.scrollTop = scrollPositions.current[activeTab] ?? 0
}
}, [activeTab])
// Store direct DOM element refs for each tab
const tabRefs = useRef<Record<SectionName, HTMLButtonElement | null>>(
Object.fromEntries(sectionNames.map((name) => [name, null])) as Record<SectionName, HTMLButtonElement | null>,
)
// Track whether we're in compact mode
const [isCompactMode, setIsCompactMode] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
// Setup resize observer to detect when we should switch to compact mode
useEffect(() => {
if (!containerRef.current) return
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
// If container width is less than 500px, switch to compact mode
setIsCompactMode(entry.contentRect.width < 500)
}
})
observer.observe(containerRef.current)
return () => {
observer?.disconnect()
}
}, [])
const sections: { id: SectionName; icon: LucideIcon }[] = useMemo(
() => [
{ id: "providers", icon: Plug },
{ id: "modes", icon: Users2 },
{ id: "skills", icon: GraduationCap },
{ id: "slashCommands", icon: SquareSlash },
{ id: "autoApprove", icon: CheckCheck },
{ id: "mcp", icon: Server },
{ id: "checkpoints", icon: GitCommitVertical },
{ id: "notifications", icon: Bell },
{ id: "contextManagement", icon: Database },
{ id: "terminal", icon: SquareTerminal },
{ id: "prompts", icon: MessageSquare },
{ id: "worktrees", icon: GitBranch },
{ id: "ui", icon: Glasses },
{ id: "experimental", icon: FlaskConical },
{ id: "language", icon: Globe },
{ id: "about", icon: Info },
],
[], // No dependencies needed now
)
// Update target section logic to set active tab
useEffect(() => {
if (targetSection && sectionNames.includes(targetSection as SectionName)) {
setActiveTab(targetSection as SectionName)
}
}, [targetSection])
// Function to scroll the active tab into view for vertical layout
const scrollToActiveTab = useCallback(() => {
const activeTabElement = tabRefs.current[activeTab]
if (activeTabElement) {
activeTabElement.scrollIntoView({
behavior: "auto",
block: "nearest",
})
}
}, [activeTab])
// Effect to scroll when the active tab changes
useEffect(() => {
scrollToActiveTab()
}, [activeTab, scrollToActiveTab])
// Effect to scroll when the webview becomes visible
useLayoutEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message = event.data
if (message.type === "action" && message.action === "didBecomeVisible") {
scrollToActiveTab()
}
}
window.addEventListener("message", handleMessage)
return () => {
window.removeEventListener("message", handleMessage)
}
}, [scrollToActiveTab])
// Search index registry - settings register themselves on mount
const getSectionLabel = useCallback((section: SectionName) => t(`settings:sections.${section}`), [t])
const { contextValue: searchContextValue, index: searchIndex } = useSearchIndexRegistry(getSectionLabel)
// Track which tabs have been indexed (visited at least once)
const [indexingTabIndex, setIndexingTabIndex] = useState(0)
const initialTab = useRef<SectionName>(activeTab)
const isIndexing = indexingTabIndex < sectionNames.length
const isIndexingComplete = !isIndexing
const tabTitlesRegistered = useRef(false)
// Index all tabs by cycling through them on mount
useLayoutEffect(() => {
if (indexingTabIndex >= sectionNames.length) {
// All tabs indexed, now register tab titles as searchable items
if (!tabTitlesRegistered.current && searchContextValue) {
sections.forEach(({ id }) => {
const tabTitle = t(`settings:sections.${id}`)
// Register each tab title as a searchable item
// Using a special naming convention for tab titles: "tab-{sectionName}"
searchContextValue.registerSetting({
settingId: `tab-${id}`,
section: id,
label: tabTitle,
})
})
tabTitlesRegistered.current = true
// Return to initial tab
setActiveTab(initialTab.current)
}
return
}
// Move to the next tab on next render
setIndexingTabIndex((prev) => prev + 1)
}, [indexingTabIndex, searchContextValue, sections, t])
// Determine which tab content to render (for indexing or active display)
const renderTab = isIndexing ? sectionNames[indexingTabIndex] : activeTab
// Handle search navigation - switch to the correct tab and scroll to the element
const handleSearchNavigate = useCallback(
(section: SectionName, settingId: string) => {
// Switch to the correct tab
handleTabChange(section)
// Wait for the tab to render, then find element by settingId and scroll to it
requestAnimationFrame(() => {
setTimeout(() => {
const element = document.querySelector(`[data-setting-id="${settingId}"]`)
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "center" })
// Add highlight animation
element.classList.add("settings-highlight")
setTimeout(() => {
element.classList.remove("settings-highlight")
}, 1500)
}
}, 100) // Small delay to ensure tab content is rendered
})
},
[handleTabChange],
)
return (
<Tab>
<TabHeader className="flex justify-between items-center gap-2">
<div className="flex items-center gap-2 grow">
<StandardTooltip content={t("settings:header.doneButtonTooltip")}>
<Button variant="ghost" className="px-1.5 -ml-2" onClick={() => checkUnsaveChanges(onDone)}>
<ArrowLeft />
<span className="sr-only">{t("settings:common.done")}</span>
</Button>
</StandardTooltip>
<h3 className="text-vscode-foreground m-0 flex-shrink-0">{t("settings:header.title")}</h3>
</div>
<div className="flex items-center gap-2 shrink-0">
{isIndexingComplete && (
<SettingsSearch index={searchIndex} onNavigate={handleSearchNavigate} sections={sections} />
)}
<StandardTooltip
content={
!isSettingValid
? errorMessage
: isChangeDetected
? t("settings:header.saveButtonTooltip")
: t("settings:header.nothingChangedTooltip")
}>
<Button
variant={isSettingValid ? "primary" : "secondary"}
className={!isSettingValid ? "!border-vscode-errorForeground" : ""}
onClick={handleSubmit}
disabled={!isChangeDetected || !isSettingValid}
data-testid="save-button">
{t("settings:common.save")}
</Button>
</StandardTooltip>
</div>
</TabHeader>
{/* Vertical tabs layout */}
<div ref={containerRef} className={cn(settingsTabsContainer, isCompactMode && "narrow")}>
{/* Tab sidebar */}
<TabList
value={activeTab}
onValueChange={(value) => handleTabChange(value as SectionName)}
className={cn(settingsTabList)}
data-compact={isCompactMode}
data-testid="settings-tab-list">
{sections.map(({ id, icon: Icon }) => {
const isSelected = id === activeTab
const onSelect = () => handleTabChange(id)
// Base TabTrigger component definition
// We pass isSelected manually for styling, but onSelect is handled conditionally
const triggerComponent = (
<TabTrigger
ref={(element) => (tabRefs.current[id] = element)}
value={id}
isSelected={isSelected} // Pass manually for styling state
className={cn(
isSelected // Use manual isSelected for styling
? `${settingsTabTrigger} ${settingsTabTriggerActive}`
: settingsTabTrigger,
"cursor-pointer focus:ring-0", // Remove the focus ring styling
)}
data-testid={`tab-${id}`}
data-compact={isCompactMode}>
<div className={cn("flex items-center gap-2", isCompactMode && "justify-center")}>
<Icon className="w-4 h-4" />
<span className="tab-label">{t(`settings:sections.${id}`)}</span>
</div>
</TabTrigger>
)
if (isCompactMode) {
// Wrap in Tooltip and manually add onClick to the trigger
return (
<TooltipProvider key={id} delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild onClick={onSelect}>
{/* Clone to avoid ref issues if triggerComponent itself had a key */}
{React.cloneElement(triggerComponent)}
</TooltipTrigger>
<TooltipContent side="right" className="text-base">
<p className="m-0">{t(`settings:sections.${id}`)}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
} else {
// Render trigger directly; TabList will inject onSelect via cloning
// Ensure the element passed to TabList has the key
return React.cloneElement(triggerComponent, { key: id })
}
})}
</TabList>
{/* Content area - renders only the active tab (or indexing tab during initial indexing) */}
<TabContent
ref={contentRef}
className={cn("p-0 flex-1 overflow-auto", isIndexing && "opacity-0")}
data-testid="settings-content">
<SearchIndexProvider value={searchContextValue}>
{/* Providers Section */}
{renderTab === "providers" && (
<div>
<SectionHeader>{t("settings:sections.providers")}</SectionHeader>
<Section>
<ApiConfigManager
currentApiConfigName={currentApiConfigName}
listApiConfigMeta={listApiConfigMeta}
onSelectConfig={(configName: string) =>
checkUnsaveChanges(() =>
vscode.postMessage({ type: "loadApiConfiguration", text: configName }),
)
}
onDeleteConfig={(configName: string) =>
vscode.postMessage({ type: "deleteApiConfiguration", text: configName })
}
onRenameConfig={(oldName: string, newName: string) => {
vscode.postMessage({
type: "renameApiConfiguration",
values: { oldName, newName },
apiConfiguration,
})
prevApiConfigName.current = newName
}}
onUpsertConfig={(configName: string) =>
vscode.postMessage({
type: "upsertApiConfiguration",
text: configName,
apiConfiguration,
})
}
/>
<ApiOptions
uriScheme={uriScheme}
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
errorMessage={errorMessage}
setErrorMessage={setErrorMessage}
/>
</Section>
</div>
)}
{/* Auto-Approve Section */}
{renderTab === "autoApprove" && (
<AutoApproveSettings
alwaysAllowReadOnly={alwaysAllowReadOnly}
alwaysAllowReadOnlyOutsideWorkspace={alwaysAllowReadOnlyOutsideWorkspace}
alwaysAllowWrite={alwaysAllowWrite}
alwaysAllowWriteOutsideWorkspace={alwaysAllowWriteOutsideWorkspace}
alwaysAllowWriteProtected={alwaysAllowWriteProtected}
alwaysAllowMcp={alwaysAllowMcp}
alwaysAllowModeSwitch={alwaysAllowModeSwitch}
alwaysAllowSubtasks={alwaysAllowSubtasks}
alwaysAllowExecute={alwaysAllowExecute}
alwaysAllowFollowupQuestions={alwaysAllowFollowupQuestions}
followupAutoApproveTimeoutMs={followupAutoApproveTimeoutMs}
allowedCommands={allowedCommands}
allowedMaxRequests={allowedMaxRequests ?? undefined}
allowedMaxCost={allowedMaxCost ?? undefined}
deniedCommands={deniedCommands}
setCachedStateField={setCachedStateField}
/>
)}
{/* Slash Commands Section */}
{renderTab === "slashCommands" && <SlashCommandsSettings />}
{/* Skills Section */}
{renderTab === "skills" && <SkillsSettings />}
{/* Checkpoints Section */}
{renderTab === "checkpoints" && (
<CheckpointSettings
enableCheckpoints={enableCheckpoints}
checkpointTimeout={checkpointTimeout}
setCachedStateField={setCachedStateField}
/>
)}
{/* Notifications Section */}
{renderTab === "notifications" && (
<NotificationSettings
ttsEnabled={ttsEnabled}
ttsSpeed={ttsSpeed}
soundEnabled={soundEnabled}
soundVolume={soundVolume}
setCachedStateField={setCachedStateField}
/>
)}
{/* Context Management Section */}
{renderTab === "contextManagement" && (
<ContextManagementSettings
autoCondenseContext={autoCondenseContext}
autoCondenseContextPercent={autoCondenseContextPercent}
listApiConfigMeta={listApiConfigMeta ?? []}
maxOpenTabsContext={maxOpenTabsContext}
maxWorkspaceFiles={maxWorkspaceFiles ?? 200}
showRooIgnoredFiles={showRooIgnoredFiles}
enableSubfolderRules={enableSubfolderRules}
maxImageFileSize={maxImageFileSize}
maxTotalImageSize={maxTotalImageSize}
profileThresholds={profileThresholds}
includeDiagnosticMessages={includeDiagnosticMessages}
maxDiagnosticMessages={maxDiagnosticMessages}
writeDelayMs={writeDelayMs}
includeCurrentTime={includeCurrentTime}
includeCurrentCost={includeCurrentCost}
maxGitStatusFiles={maxGitStatusFiles}
customSupportPrompts={customSupportPrompts || {}}
setCustomSupportPrompts={setCustomSupportPromptsField}
setCachedStateField={setCachedStateField}
/>
)}
{/* Terminal Section */}
{renderTab === "terminal" && (
<TerminalSettings
terminalOutputPreviewSize={terminalOutputPreviewSize}
terminalShellIntegrationTimeout={terminalShellIntegrationTimeout}
terminalShellIntegrationDisabled={terminalShellIntegrationDisabled}
terminalCommandDelay={terminalCommandDelay}
terminalPowershellCounter={terminalPowershellCounter}
terminalZshClearEolMark={terminalZshClearEolMark}
terminalZshOhMy={terminalZshOhMy}
terminalZshP10k={terminalZshP10k}
terminalZdotdir={terminalZdotdir}
maxTerminalPoolSize={maxTerminalPoolSize}
setCachedStateField={setCachedStateField}
/>
)}
{/* Modes Section */}
{renderTab === "modes" && <ModesView />}
{/* MCP Section */}
{renderTab === "mcp" && <McpView />}
{/* Worktrees Section */}
{renderTab === "worktrees" && <WorktreesView />}
{/* Prompts Section */}
{renderTab === "prompts" && (
<PromptsSettings
customSupportPrompts={customSupportPrompts || {}}
setCustomSupportPrompts={setCustomSupportPromptsField}
includeTaskHistoryInEnhance={includeTaskHistoryInEnhance}
setIncludeTaskHistoryInEnhance={(value) =>
setCachedStateField("includeTaskHistoryInEnhance", value)
}
/>
)}
{/* UI Section */}
{renderTab === "ui" && (
<UISettings
reasoningBlockCollapsed={reasoningBlockCollapsed ?? true}
enterBehavior={enterBehavior ?? "send"}
setCachedStateField={setCachedStateField}
/>
)}
{/* Experimental Section */}
{renderTab === "experimental" && (
<ExperimentalSettings
setExperimentEnabled={setExperimentEnabled}
experiments={experiments}
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
imageGenerationProvider={imageGenerationProvider}
openRouterImageApiKey={openRouterImageApiKey as string | undefined}
openRouterImageGenerationSelectedModel={
openRouterImageGenerationSelectedModel as string | undefined
}
setImageGenerationProvider={setImageGenerationProvider}
setOpenRouterImageApiKey={setOpenRouterImageApiKey}
setImageGenerationSelectedModel={setImageGenerationSelectedModel}
/>
)}
{/* Language Section */}
{renderTab === "language" && (
<LanguageSettings language={language || "en"} setCachedStateField={setCachedStateField} />
)}
{/* About Section */}
{renderTab === "about" && (
<About
telemetrySetting={telemetrySetting}
setTelemetrySetting={setTelemetrySetting}
debug={cachedState.debug}
setDebug={setDebug}
/>
)}
</SearchIndexProvider>
</TabContent>
</div>
<AlertDialog open={isDiscardDialogShow} onOpenChange={setDiscardDialogShow}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<AlertTriangle className="w-5 h-5 text-yellow-500" />
{t("settings:unsavedChangesDialog.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings:unsavedChangesDialog.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => onConfirmDialogResult(false)}>
{t("settings:unsavedChangesDialog.cancelButton")}
</AlertDialogCancel>
<AlertDialogAction onClick={() => onConfirmDialogResult(true)}>
{t("settings:unsavedChangesDialog.discardButton")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Tab>
)
})
export default memo(SettingsView)