mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-19 00:01:19 +00:00
- 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
963 lines
32 KiB
TypeScript
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)
|