Roo-Code/webview-ui/src/components/settings/SettingsView.tsx
Roo Code d39a4e1294 feat: add configurable image downscaling before LLM upload
Adds a new image downscaling feature that resizes images before sending
them to the LLM, reducing token usage for high-resolution screenshots.

Changes:
- Add sharp dependency for image processing
- New settings: maxImageDimension (0=disabled) and imageDownscaleQuality (1-100)
- New resize-image.ts utility using sharp for proportional downscaling
- Integrate resizing into all image entry points: selectImages,
  readImageAsDataUrlWithBuffer, processImageFile, resolveImageMentions
- Add settings UI controls in Context Management settings tab
- Add English translation strings for the new settings
- Downscaling disabled by default (opt-in)

Closes #11912
2026-03-12 01:15:03 +00:00

966 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,
writeDelayMs,
showRooIgnoredFiles,
enableSubfolderRules,
maxImageFileSize,
maxTotalImageSize,
maxImageDimension,
imageDownscaleQuality,
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,
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,
maxImageDimension: maxImageDimension ?? 0,
imageDownscaleQuality: imageDownscaleQuality ?? 85,
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}
maxImageDimension={maxImageDimension}
imageDownscaleQuality={imageDownscaleQuality}
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}
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)