Roo-Code/webview-ui/src/components/settings/ContextManagementSettings.tsx
Roo Code e509e15636 feat: restore dedicated model selector for context condensing
Adds a condensingApiConfigId setting that allows users to select a
dedicated API configuration for context condensing operations. This
restores the ability to use a cheaper/faster model (e.g. Gemini Flash)
for condensing while keeping an expensive model (e.g. Claude Opus) as
the active coding model.

Changes:
- Add condensingApiConfigId to global settings schema and ExtensionState
- Add webview message handler for condensingApiConfigId
- Build dedicated API handler in Task.ts for manual and auto condensing
- Add API config dropdown in ContextManagementSettings UI
- Update tests for new combobox count and default props

Closes #11785
2026-03-25 14:36:09 +00:00

613 lines
22 KiB
TypeScript

import { HTMLAttributes } from "react"
import React from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { VSCodeCheckbox, VSCodeTextArea } from "@vscode/webview-ui-toolkit/react"
import { FoldVertical } from "lucide-react"
import { supportPrompt } from "@roo/support-prompt"
import { cn } from "@/lib/utils"
import {
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Slider,
Button,
StandardTooltip,
} from "@/components/ui"
import { SetCachedStateField } from "./types"
import { SectionHeader } from "./SectionHeader"
import { Section } from "./Section"
import { SearchableSetting } from "./SearchableSetting"
import { vscode } from "@/utils/vscode"
type ContextManagementSettingsProps = HTMLAttributes<HTMLDivElement> & {
autoCondenseContext: boolean
autoCondenseContextPercent: number
listApiConfigMeta: any[]
condensingApiConfigId?: string
setCondensingApiConfigId: (value: string) => void
maxOpenTabsContext: number
maxWorkspaceFiles: number
showRooIgnoredFiles?: boolean
enableSubfolderRules?: boolean
maxImageFileSize?: number
maxTotalImageSize?: number
profileThresholds?: Record<string, number>
includeDiagnosticMessages?: boolean
maxDiagnosticMessages?: number
writeDelayMs: number
includeCurrentTime?: boolean
includeCurrentCost?: boolean
maxGitStatusFiles?: number
customSupportPrompts: Record<string, string | undefined>
setCustomSupportPrompts: (prompts: Record<string, string | undefined>) => void
setCachedStateField: SetCachedStateField<
| "autoCondenseContext"
| "autoCondenseContextPercent"
| "maxOpenTabsContext"
| "maxWorkspaceFiles"
| "showRooIgnoredFiles"
| "enableSubfolderRules"
| "maxImageFileSize"
| "maxTotalImageSize"
| "profileThresholds"
| "includeDiagnosticMessages"
| "maxDiagnosticMessages"
| "writeDelayMs"
| "includeCurrentTime"
| "includeCurrentCost"
| "maxGitStatusFiles"
>
}
export const ContextManagementSettings = ({
autoCondenseContext,
autoCondenseContextPercent,
listApiConfigMeta,
condensingApiConfigId,
setCondensingApiConfigId,
maxOpenTabsContext,
maxWorkspaceFiles,
showRooIgnoredFiles,
enableSubfolderRules,
setCachedStateField,
maxImageFileSize,
maxTotalImageSize,
profileThresholds = {},
includeDiagnosticMessages,
maxDiagnosticMessages,
writeDelayMs,
includeCurrentTime,
includeCurrentCost,
maxGitStatusFiles,
customSupportPrompts,
setCustomSupportPrompts,
className,
...props
}: ContextManagementSettingsProps) => {
const { t } = useAppTranslation()
const [selectedThresholdProfile, setSelectedThresholdProfile] = React.useState<string>("default")
// Helper function to get the CONDENSE prompt value
const getCondensePromptValue = (): string => {
return supportPrompt.get(customSupportPrompts, "CONDENSE")
}
// Helper function to update the CONDENSE prompt
const updateCondensePrompt = (value: string | undefined) => {
const updatedPrompts = { ...customSupportPrompts }
if (value === undefined) {
delete updatedPrompts["CONDENSE"]
} else {
updatedPrompts["CONDENSE"] = value
}
setCustomSupportPrompts(updatedPrompts)
}
// Helper function to reset the CONDENSE prompt to default
const handleCondenseReset = () => {
const updatedPrompts = { ...customSupportPrompts }
delete updatedPrompts["CONDENSE"]
setCustomSupportPrompts(updatedPrompts)
}
// Helper function to get the current threshold value based on selected profile
const getCurrentThresholdValue = () => {
if (selectedThresholdProfile === "default") {
return autoCondenseContextPercent
}
const profileThreshold = profileThresholds[selectedThresholdProfile]
if (profileThreshold === undefined || profileThreshold === -1) {
return autoCondenseContextPercent // Use default if profile not configured or set to -1
}
return profileThreshold
}
// Helper function to handle threshold changes
const handleThresholdChange = (value: number) => {
if (selectedThresholdProfile === "default") {
setCachedStateField("autoCondenseContextPercent", value)
} else {
const newThresholds = {
...profileThresholds,
[selectedThresholdProfile]: value,
}
setCachedStateField("profileThresholds", newThresholds)
vscode.postMessage({ type: "updateSettings", updatedSettings: { profileThresholds: newThresholds } })
}
}
return (
<div className={cn("flex flex-col gap-2", className)} {...props}>
<SectionHeader description={t("settings:contextManagement.description")}>
{t("settings:sections.contextManagement")}
</SectionHeader>
<Section>
<SearchableSetting
settingId="context-open-tabs"
section="contextManagement"
label={t("settings:contextManagement.openTabs.label")}>
<span className="block font-medium mb-1">{t("settings:contextManagement.openTabs.label")}</span>
<div className="flex items-center gap-2">
<Slider
min={0}
max={500}
step={1}
value={[maxOpenTabsContext ?? 20]}
onValueChange={([value]) => setCachedStateField("maxOpenTabsContext", value)}
data-testid="open-tabs-limit-slider"
/>
<span className="w-10">{maxOpenTabsContext ?? 20}</span>
</div>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:contextManagement.openTabs.description")}
</div>
</SearchableSetting>
<SearchableSetting
settingId="context-workspace-files"
section="contextManagement"
label={t("settings:contextManagement.workspaceFiles.label")}>
<span className="block font-medium mb-1">
{t("settings:contextManagement.workspaceFiles.label")}
</span>
<div className="flex items-center gap-2">
<Slider
min={0}
max={500}
step={1}
value={[maxWorkspaceFiles ?? 200]}
onValueChange={([value]) => setCachedStateField("maxWorkspaceFiles", value)}
data-testid="workspace-files-limit-slider"
/>
<span className="w-10">{maxWorkspaceFiles ?? 200}</span>
</div>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:contextManagement.workspaceFiles.description")}
</div>
</SearchableSetting>
<SearchableSetting
settingId="context-max-git-status-files"
section="contextManagement"
label={t("settings:contextManagement.maxGitStatusFiles.label")}>
<span className="block font-medium mb-1">
{t("settings:contextManagement.maxGitStatusFiles.label")}
</span>
<div className="flex items-center gap-2">
<Slider
min={0}
max={50}
step={1}
value={[maxGitStatusFiles ?? 0]}
onValueChange={([value]) => setCachedStateField("maxGitStatusFiles", value)}
data-testid="max-git-status-files-slider"
/>
<span className="w-10">{maxGitStatusFiles ?? 0}</span>
</div>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:contextManagement.maxGitStatusFiles.description")}
</div>
</SearchableSetting>
<SearchableSetting
settingId="context-show-rooignored-files"
section="contextManagement"
label={t("settings:contextManagement.rooignore.label")}>
<VSCodeCheckbox
checked={showRooIgnoredFiles}
onChange={(e: any) => setCachedStateField("showRooIgnoredFiles", e.target.checked)}
data-testid="show-rooignored-files-checkbox">
<label className="block font-medium mb-1">
{t("settings:contextManagement.rooignore.label")}
</label>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-3">
{t("settings:contextManagement.rooignore.description")}
</div>
</SearchableSetting>
<SearchableSetting
settingId="context-enable-subfolder-rules"
section="contextManagement"
label={t("settings:contextManagement.enableSubfolderRules.label")}>
<VSCodeCheckbox
checked={enableSubfolderRules}
onChange={(e: any) => setCachedStateField("enableSubfolderRules", e.target.checked)}
data-testid="enable-subfolder-rules-checkbox">
<label className="block font-medium mb-1">
{t("settings:contextManagement.enableSubfolderRules.label")}
</label>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-3">
{t("settings:contextManagement.enableSubfolderRules.description")}
</div>
</SearchableSetting>
<SearchableSetting
settingId="context-max-image-file-size"
section="contextManagement"
label={t("settings:contextManagement.maxImageFileSize.label")}>
<div className="flex flex-col gap-2">
<span className="font-medium">{t("settings:contextManagement.maxImageFileSize.label")}</span>
<div className="flex items-center gap-4">
<Input
type="number"
pattern="[0-9]*"
className="w-24 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border px-2 py-1 rounded text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={maxImageFileSize ?? 5}
min={1}
max={100}
onChange={(e) => {
const newValue = parseInt(e.target.value, 10)
if (!isNaN(newValue) && newValue >= 1 && newValue <= 100) {
setCachedStateField("maxImageFileSize", newValue)
}
}}
onClick={(e) => e.currentTarget.select()}
data-testid="max-image-file-size-input"
/>
<span>{t("settings:contextManagement.maxImageFileSize.mb")}</span>
</div>
</div>
<div className="text-vscode-descriptionForeground text-sm mt-2">
{t("settings:contextManagement.maxImageFileSize.description")}
</div>
</SearchableSetting>
<SearchableSetting
settingId="context-max-total-image-size"
section="contextManagement"
label={t("settings:contextManagement.maxTotalImageSize.label")}>
<div className="flex flex-col gap-2">
<span className="font-medium">{t("settings:contextManagement.maxTotalImageSize.label")}</span>
<div className="flex items-center gap-4">
<Input
type="number"
pattern="[0-9]*"
className="w-24 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border px-2 py-1 rounded text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={maxTotalImageSize ?? 20}
min={1}
max={500}
onChange={(e) => {
const newValue = parseInt(e.target.value, 10)
if (!isNaN(newValue) && newValue >= 1 && newValue <= 500) {
setCachedStateField("maxTotalImageSize", newValue)
}
}}
onClick={(e) => e.currentTarget.select()}
data-testid="max-total-image-size-input"
/>
<span>{t("settings:contextManagement.maxTotalImageSize.mb")}</span>
</div>
</div>
<div className="text-vscode-descriptionForeground text-sm mt-2">
{t("settings:contextManagement.maxTotalImageSize.description")}
</div>
</SearchableSetting>
<SearchableSetting
settingId="context-include-diagnostic-messages"
section="contextManagement"
label={t("settings:contextManagement.diagnostics.includeMessages.label")}>
<VSCodeCheckbox
checked={includeDiagnosticMessages}
onChange={(e: any) => setCachedStateField("includeDiagnosticMessages", e.target.checked)}
data-testid="include-diagnostic-messages-checkbox">
<label className="block font-medium mb-1">
{t("settings:contextManagement.diagnostics.includeMessages.label")}
</label>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-3">
{t("settings:contextManagement.diagnostics.includeMessages.description")}
</div>
</SearchableSetting>
<SearchableSetting
settingId="context-max-diagnostic-messages"
section="contextManagement"
label={t("settings:contextManagement.diagnostics.maxMessages.label")}>
<span className="block font-medium mb-1">
{t("settings:contextManagement.diagnostics.maxMessages.label")}
</span>
<div className="flex items-center gap-2">
<Slider
min={1}
max={100}
step={1}
value={[
maxDiagnosticMessages !== undefined && maxDiagnosticMessages <= 0
? 100
: (maxDiagnosticMessages ?? 50),
]}
onValueChange={([value]) => {
// When slider reaches 100, set to -1 (unlimited)
setCachedStateField("maxDiagnosticMessages", value === 100 ? -1 : value)
}}
data-testid="max-diagnostic-messages-slider"
aria-label={t("settings:contextManagement.diagnostics.maxMessages.label")}
aria-valuemin={1}
aria-valuemax={100}
aria-valuenow={
maxDiagnosticMessages !== undefined && maxDiagnosticMessages <= 0
? 100
: (maxDiagnosticMessages ?? 50)
}
aria-valuetext={
(maxDiagnosticMessages !== undefined && maxDiagnosticMessages <= 0) ||
maxDiagnosticMessages === 100
? t("settings:contextManagement.diagnostics.maxMessages.unlimitedLabel")
: `${maxDiagnosticMessages ?? 50} ${t("settings:contextManagement.diagnostics.maxMessages.label")}`
}
/>
<span className="w-20 text-sm font-medium">
{(maxDiagnosticMessages !== undefined && maxDiagnosticMessages <= 0) ||
maxDiagnosticMessages === 100
? t("settings:contextManagement.diagnostics.maxMessages.unlimitedLabel")
: (maxDiagnosticMessages ?? 50)}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setCachedStateField("maxDiagnosticMessages", 50)}
title={t("settings:contextManagement.diagnostics.maxMessages.resetTooltip")}
className="p-1 h-6 w-6"
disabled={maxDiagnosticMessages === 50}>
<span className="codicon codicon-discard" />
</Button>
</div>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:contextManagement.diagnostics.maxMessages.description")}
</div>
</SearchableSetting>
<SearchableSetting
settingId="context-write-delay"
section="contextManagement"
label={t("settings:contextManagement.diagnostics.delayAfterWrite.label")}>
<span className="block font-medium mb-1">
{t("settings:contextManagement.diagnostics.delayAfterWrite.label")}
</span>
<div className="flex items-center gap-2">
<Slider
min={0}
max={5000}
step={100}
value={[writeDelayMs]}
onValueChange={([value]) => setCachedStateField("writeDelayMs", value)}
data-testid="write-delay-slider"
/>
<span className="w-20">{writeDelayMs}ms</span>
</div>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:contextManagement.diagnostics.delayAfterWrite.description")}
</div>
</SearchableSetting>
<SearchableSetting
settingId="context-include-current-time"
section="contextManagement"
label={t("settings:contextManagement.includeCurrentTime.label")}>
<VSCodeCheckbox
checked={includeCurrentTime}
onChange={(e: any) => setCachedStateField("includeCurrentTime", e.target.checked)}
data-testid="include-current-time-checkbox">
<label className="block font-medium mb-1">
{t("settings:contextManagement.includeCurrentTime.label")}
</label>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-3">
{t("settings:contextManagement.includeCurrentTime.description")}
</div>
</SearchableSetting>
<SearchableSetting
settingId="context-include-current-cost"
section="contextManagement"
label={t("settings:contextManagement.includeCurrentCost.label")}>
<VSCodeCheckbox
checked={includeCurrentCost}
onChange={(e: any) => setCachedStateField("includeCurrentCost", e.target.checked)}
data-testid="include-current-cost-checkbox">
<label className="block font-medium mb-1">
{t("settings:contextManagement.includeCurrentCost.label")}
</label>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-3">
{t("settings:contextManagement.includeCurrentCost.description")}
</div>
</SearchableSetting>
</Section>
<Section className="pt-2">
{/* Context Condensing Prompt Editor */}
<SearchableSetting
settingId="context-condense-prompt"
section="contextManagement"
label={t("prompts:supportPrompts.types.CONDENSE.label")}>
<div className="flex justify-between items-center mb-1">
<label className="block font-medium">{t("prompts:supportPrompts.types.CONDENSE.label")}</label>
<StandardTooltip content={t("prompts:supportPrompts.resetPrompt", { promptType: "CONDENSE" })}>
<Button variant="ghost" size="icon" onClick={handleCondenseReset}>
<span className="codicon codicon-discard"></span>
</Button>
</StandardTooltip>
</div>
<div className="text-sm text-vscode-descriptionForeground mb-2">
{t("prompts:supportPrompts.types.CONDENSE.description")}
</div>
<VSCodeTextArea
resize="vertical"
value={getCondensePromptValue()}
onInput={(e) => {
const value =
(e as unknown as CustomEvent)?.detail?.target?.value ??
((e as any).target as HTMLTextAreaElement).value
updateCondensePrompt(value)
}}
rows={6}
className="w-full"
data-testid="condense-prompt-textarea"
/>
</SearchableSetting>
{/* Condensing API Configuration */}
<SearchableSetting
settingId="context-condensing-api-config"
section="contextManagement"
label={t("settings:contextManagement.condensingApiConfiguration.label")}>
<div>
<label className="block font-medium mb-1">
{t("settings:contextManagement.condensingApiConfiguration.label")}
</label>
<div className="text-sm text-vscode-descriptionForeground mb-2">
{t("settings:contextManagement.condensingApiConfiguration.description")}
</div>
<Select
value={condensingApiConfigId || "-"}
onValueChange={(value) => {
const newConfigId = value === "-" ? "" : value
setCondensingApiConfigId(newConfigId)
vscode.postMessage({
type: "condensingApiConfigId",
text: value === "-" ? "" : value,
})
}}>
<SelectTrigger data-testid="condensing-api-config-select" className="w-full">
<SelectValue
placeholder={t(
"settings:contextManagement.condensingApiConfiguration.useCurrentConfig",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="-">
{t("settings:contextManagement.condensingApiConfiguration.useCurrentConfig")}
</SelectItem>
{(listApiConfigMeta || []).map((config: any) => (
<SelectItem
key={config.id}
value={config.id}
data-testid={`condensing-${config.id}-option`}>
{config.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</SearchableSetting>
{/* Auto Condense Context */}
<SearchableSetting
settingId="context-auto-condense"
section="contextManagement"
label={t("settings:contextManagement.autoCondenseContext.name")}>
<VSCodeCheckbox
checked={autoCondenseContext}
onChange={(e: any) => setCachedStateField("autoCondenseContext", e.target.checked)}
data-testid="auto-condense-context-checkbox">
<span className="font-medium">{t("settings:contextManagement.autoCondenseContext.name")}</span>
</VSCodeCheckbox>
</SearchableSetting>
{autoCondenseContext && (
<div className="flex flex-col gap-3 pl-3 border-l-2 border-vscode-button-background">
<div className="flex items-center gap-4 font-bold">
<FoldVertical size={16} />
<div>{t("settings:contextManagement.condensingThreshold.label")}</div>
</div>
<div>
<Select
value={selectedThresholdProfile || "default"}
onValueChange={(value) => {
setSelectedThresholdProfile(value)
}}
data-testid="threshold-profile-select">
<SelectTrigger className="w-full">
<SelectValue
placeholder={
t("settings:contextManagement.condensingThreshold.selectProfile") ||
"Select profile for threshold"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
{t("settings:contextManagement.condensingThreshold.defaultProfile") ||
"Default (applies to all unconfigured profiles)"}
</SelectItem>
{(listApiConfigMeta || []).map((config) => {
const profileThreshold = profileThresholds[config.id]
const thresholdDisplay =
profileThreshold !== undefined
? profileThreshold === -1
? ` ${t(
"settings:contextManagement.condensingThreshold.usesGlobal",
{
threshold: autoCondenseContextPercent,
},
)}`
: ` (${profileThreshold}%)`
: ""
return (
<SelectItem key={config.id} value={config.id}>
{config.name}
{thresholdDisplay}
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
{/* Threshold Slider */}
<div>
<div className="flex items-center gap-2">
<Slider
min={10}
max={100}
step={1}
value={[getCurrentThresholdValue()]}
onValueChange={([value]) => handleThresholdChange(value)}
data-testid="condense-threshold-slider"
/>
<span className="w-20">{getCurrentThresholdValue()}%</span>
</div>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{selectedThresholdProfile === "default"
? t("settings:contextManagement.condensingThreshold.defaultDescription", {
threshold: autoCondenseContextPercent,
})
: t("settings:contextManagement.condensingThreshold.profileDescription")}
</div>
</div>
</div>
)}
</Section>
</div>
)
}