Roo-Code/webview-ui/src/components/settings/ContextManagementSettings.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

629 lines
23 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[]
maxOpenTabsContext: number
maxWorkspaceFiles: number
showRooIgnoredFiles?: boolean
enableSubfolderRules?: boolean
maxImageFileSize?: number
maxTotalImageSize?: number
maxImageDimension?: number
imageDownscaleQuality?: 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"
| "maxImageDimension"
| "imageDownscaleQuality"
| "profileThresholds"
| "includeDiagnosticMessages"
| "maxDiagnosticMessages"
| "writeDelayMs"
| "includeCurrentTime"
| "includeCurrentCost"
| "maxGitStatusFiles"
>
}
export const ContextManagementSettings = ({
autoCondenseContext,
autoCondenseContextPercent,
listApiConfigMeta,
maxOpenTabsContext,
maxWorkspaceFiles,
showRooIgnoredFiles,
enableSubfolderRules,
setCachedStateField,
maxImageFileSize,
maxTotalImageSize,
maxImageDimension,
imageDownscaleQuality,
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-max-image-dimension"
section="contextManagement"
label={t("settings:contextManagement.maxImageDimension.label")}>
<div className="flex flex-col gap-2">
<span className="font-medium">{t("settings:contextManagement.maxImageDimension.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={maxImageDimension ?? 0}
min={0}
max={8192}
step={256}
onChange={(e) => {
const newValue = parseInt(e.target.value, 10)
if (!isNaN(newValue) && newValue >= 0 && newValue <= 8192) {
setCachedStateField("maxImageDimension", newValue)
}
}}
onClick={(e) => e.currentTarget.select()}
data-testid="max-image-dimension-input"
/>
<span>
{(maxImageDimension ?? 0) === 0
? t("settings:contextManagement.maxImageDimension.disabled")
: t("settings:contextManagement.maxImageDimension.px")}
</span>
</div>
</div>
<div className="text-vscode-descriptionForeground text-sm mt-2">
{t("settings:contextManagement.maxImageDimension.description")}
</div>
</SearchableSetting>
{(maxImageDimension ?? 0) > 0 && (
<SearchableSetting
settingId="context-image-downscale-quality"
section="contextManagement"
label={t("settings:contextManagement.imageDownscaleQuality.label")}>
<span className="block font-medium mb-1">
{t("settings:contextManagement.imageDownscaleQuality.label")}
</span>
<div className="flex items-center gap-2">
<Slider
min={1}
max={100}
step={1}
value={[imageDownscaleQuality ?? 85]}
onValueChange={([value]) => setCachedStateField("imageDownscaleQuality", value)}
/>
<span className="w-10">{imageDownscaleQuality ?? 85}</span>
</div>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:contextManagement.imageDownscaleQuality.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>
{/* 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>
)
}