diff --git a/.changeset/bright-taxis-share.md b/.changeset/bright-taxis-share.md new file mode 100644 index 00000000000..281322cc380 --- /dev/null +++ b/.changeset/bright-taxis-share.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Redesigned context condensing thresholds so global behavior stays percentage-based while each profile can optionally override with either percent-of-limit or fixed token triggers. diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index e4aa01c322e..df892a58643 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -53,6 +53,17 @@ export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15 * GlobalSettings */ +// kilocode_change start +export const profileCondenseOverrideModeSchema = z.enum(["percent", "tokens"]) +export const profileCondenseOverrideSchema = z.object({ + enabled: z.boolean(), + mode: profileCondenseOverrideModeSchema, + percent: z.number(), + tokens: z.number(), +}) +export type ProfileCondenseOverride = z.infer +// kilocode_change end + export const globalSettingsSchema = z.object({ currentApiConfigName: z.string().optional(), listApiConfigMeta: z.array(providerSettingsEntrySchema).optional(), @@ -244,6 +255,9 @@ export const globalSettingsSchema = z.object({ */ enterBehavior: z.enum(["send", "newline"]).optional(), profileThresholds: z.record(z.string(), z.number()).optional(), + // kilocode_change start + profileCondenseOverrides: z.record(z.string(), profileCondenseOverrideSchema).optional(), + // kilocode_change end hasOpenedModeSelector: z.boolean().optional(), hasCompletedOnboarding: z.boolean().optional(), // kilocode_change: Track if user has completed onboarding flow lastModeExportPath: z.string().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 118a266f85f..4eb0c8b6d6e 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -556,6 +556,9 @@ export type ExtensionState = Pick< | "codebaseIndexConfig" | "codebaseIndexModels" | "profileThresholds" + // kilocode_change start + | "profileCondenseOverrides" + // kilocode_change end | "systemNotificationsEnabled" // kilocode_change | "includeDiagnosticMessages" | "maxDiagnosticMessages" @@ -640,6 +643,12 @@ export type ExtensionState = Pick< // eslint-disable-next-line @typescript-eslint/no-explicit-any marketplaceInstalledMetadata?: { project: Record; global: Record } profileThresholds: Record + // kilocode_change start + profileCondenseOverrides?: Record< + string, + { enabled: boolean; mode: "percent" | "tokens"; percent: number; tokens: number } + > + // kilocode_change end hasOpenedModeSelector: boolean hasCompletedOnboarding?: boolean // kilocode_change: Track if user has completed onboarding flow openRouterImageApiKey?: string diff --git a/src/core/context-management/__tests__/context-management.spec.ts b/src/core/context-management/__tests__/context-management.spec.ts index 3ee36fc5956..68b9b96b8ca 100644 --- a/src/core/context-management/__tests__/context-management.spec.ts +++ b/src/core/context-management/__tests__/context-management.spec.ts @@ -1062,6 +1062,130 @@ describe("Context Management", () => { }) }) + // kilocode_change start + describe("profile condense overrides", () => { + const contextWindow = 100000 + const maxTokens = 30000 + const baseOptions = { + contextWindow, + maxTokens, + autoCondenseContext: true, + autoCondenseContextPercent: 70, + profileThresholds: {}, + currentProfileId: "profile-1", + } + + it("keeps global-only trigger behavior when profile override is disabled", () => { + const withoutOverrides = willManageContext({ + ...baseOptions, + totalTokens: 69999, + lastMessageTokens: 1, + }) + + const withDisabledOverride = willManageContext({ + ...baseOptions, + totalTokens: 69999, + lastMessageTokens: 1, + profileCondenseOverrides: { + "profile-1": { + enabled: false, + mode: "tokens", + percent: 80, + tokens: 1000, + }, + }, + }) + + expect(withDisabledOverride).toBe(withoutOverrides) + expect(withoutOverrides).toBe(true) + }) + + it("triggers at exact token threshold in profile token mode", () => { + const shouldNotTrigger = willManageContext({ + ...baseOptions, + totalTokens: 44998, + lastMessageTokens: 1, + profileCondenseOverrides: { + "profile-1": { + enabled: true, + mode: "tokens", + percent: 80, + tokens: 45000, + }, + }, + }) + const shouldTrigger = willManageContext({ + ...baseOptions, + totalTokens: 44999, + lastMessageTokens: 1, + profileCondenseOverrides: { + "profile-1": { + enabled: true, + mode: "tokens", + percent: 80, + tokens: 45000, + }, + }, + }) + + expect(shouldNotTrigger).toBe(false) + expect(shouldTrigger).toBe(true) + }) + + it("converts profile percent mode using effective budget", () => { + // effective budget = 100000 * 0.9 - 30000 = 60000 + // 50% of effective budget = 30000 + const shouldNotTrigger = willManageContext({ + ...baseOptions, + totalTokens: 29998, + lastMessageTokens: 1, + profileCondenseOverrides: { + "profile-1": { + enabled: true, + mode: "percent", + percent: 50, + tokens: 45000, + }, + }, + }) + const shouldTrigger = willManageContext({ + ...baseOptions, + totalTokens: 29999, + lastMessageTokens: 1, + profileCondenseOverrides: { + "profile-1": { + enabled: true, + mode: "percent", + percent: 50, + tokens: 45000, + }, + }, + }) + + expect(shouldNotTrigger).toBe(false) + expect(shouldTrigger).toBe(true) + }) + + it("always triggers on hard overflow regardless of override mode/value", () => { + const shouldTrigger = willManageContext({ + ...baseOptions, + totalTokens: 60000, + lastMessageTokens: 1, + profileCondenseOverrides: { + "profile-1": { + enabled: true, + mode: "tokens", + percent: 5, + tokens: 1, + }, + }, + }) + + expect(shouldTrigger).toBe(true) + }) + }) + // kilocode_change end + /** * Tests for the getMaxTokens function (private but tested through manageContext) */ diff --git a/src/core/context-management/index.ts b/src/core/context-management/index.ts index a94a53c9d5a..ee1c12816a5 100644 --- a/src/core/context-management/index.ts +++ b/src/core/context-management/index.ts @@ -24,6 +24,99 @@ import { ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types" */ export const TOKEN_BUFFER_PERCENTAGE = 0.1 +// kilocode_change start +export type ProfileCondenseOverride = { + enabled: boolean + mode: "percent" | "tokens" + percent: number + tokens: number +} + +type CondenseTriggerResolution = { + reservedTokens: number + allowedTokens: number + effectiveBudget: number + mode: "global_percent" | "profile_percent" | "profile_tokens" + thresholdPercent?: number + thresholdTokens?: number +} + +type ResolveCondenseTriggerOptions = { + contextWindow: number + maxTokens?: number | null + autoCondenseContextPercent: number + profileThresholds: Record + profileCondenseOverrides?: Record + currentProfileId: string +} + +const clampNumber = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max) + +const resolveCondenseTrigger = ({ + contextWindow, + maxTokens, + autoCondenseContextPercent, + profileThresholds, + profileCondenseOverrides = {}, + currentProfileId, +}: ResolveCondenseTriggerOptions): CondenseTriggerResolution => { + const reservedTokens = maxTokens || ANTHROPIC_DEFAULT_MAX_TOKENS + const allowedTokens = contextWindow * (1 - TOKEN_BUFFER_PERCENTAGE) - reservedTokens + const effectiveBudget = Math.max(0, Math.floor(allowedTokens)) + + const profileOverride = profileCondenseOverrides[currentProfileId] + if (profileOverride?.enabled) { + if (profileOverride.mode === "tokens") { + const tokenThreshold = clampNumber( + Math.floor(profileOverride.tokens), + 1, + Math.max(1, Math.floor(effectiveBudget)), + ) + return { + reservedTokens, + allowedTokens, + effectiveBudget, + mode: "profile_tokens", + thresholdTokens: tokenThreshold, + } + } + + const percentThreshold = clampNumber( + Math.floor(profileOverride.percent), + MIN_CONDENSE_THRESHOLD, + MAX_CONDENSE_THRESHOLD, + ) + const thresholdTokens = Math.max(1, Math.floor((percentThreshold / 100) * Math.max(1, effectiveBudget))) + return { + reservedTokens, + allowedTokens, + effectiveBudget, + mode: "profile_percent", + thresholdPercent: percentThreshold, + thresholdTokens, + } + } + + let effectiveThreshold = autoCondenseContextPercent + const profileThreshold = profileThresholds[currentProfileId] + if (profileThreshold !== undefined) { + if (profileThreshold === -1) { + effectiveThreshold = autoCondenseContextPercent + } else if (profileThreshold >= MIN_CONDENSE_THRESHOLD && profileThreshold <= MAX_CONDENSE_THRESHOLD) { + effectiveThreshold = profileThreshold + } + } + + return { + reservedTokens, + allowedTokens, + effectiveBudget, + mode: "global_percent", + thresholdPercent: effectiveThreshold, + } +} +// kilocode_change end + /** * Counts tokens for user content using the provider's token counting implementation. * @@ -144,6 +237,8 @@ export type WillManageContextOptions = { autoCondenseContext: boolean autoCondenseContextPercent: number profileThresholds: Record + // kilocode_change + profileCondenseOverrides?: Record currentProfileId: string lastMessageTokens: number } @@ -164,35 +259,39 @@ export function willManageContext({ autoCondenseContext, autoCondenseContextPercent, profileThresholds, + // kilocode_change + profileCondenseOverrides = {}, currentProfileId, lastMessageTokens, }: WillManageContextOptions): boolean { + // kilocode_change start + const trigger = resolveCondenseTrigger({ + contextWindow, + maxTokens, + autoCondenseContextPercent, + profileThresholds, + profileCondenseOverrides, + currentProfileId, + }) + // kilocode_change end + if (!autoCondenseContext) { // When auto-condense is disabled, only truncation can occur - const reservedTokens = maxTokens || ANTHROPIC_DEFAULT_MAX_TOKENS const prevContextTokens = totalTokens + lastMessageTokens - const allowedTokens = contextWindow * (1 - TOKEN_BUFFER_PERCENTAGE) - reservedTokens - return prevContextTokens > allowedTokens + return prevContextTokens > trigger.allowedTokens } - const reservedTokens = maxTokens || ANTHROPIC_DEFAULT_MAX_TOKENS const prevContextTokens = totalTokens + lastMessageTokens - const allowedTokens = contextWindow * (1 - TOKEN_BUFFER_PERCENTAGE) - reservedTokens + if (prevContextTokens > trigger.allowedTokens) { + return true + } - // Determine the effective threshold to use - let effectiveThreshold = autoCondenseContextPercent - const profileThreshold = profileThresholds[currentProfileId] - if (profileThreshold !== undefined) { - if (profileThreshold === -1) { - effectiveThreshold = autoCondenseContextPercent - } else if (profileThreshold >= MIN_CONDENSE_THRESHOLD && profileThreshold <= MAX_CONDENSE_THRESHOLD) { - effectiveThreshold = profileThreshold - } - // Invalid values fall back to global setting (effectiveThreshold already set) + if (trigger.mode === "global_percent") { + const contextPercent = (100 * prevContextTokens) / contextWindow + return contextPercent >= (trigger.thresholdPercent ?? autoCondenseContextPercent) } - const contextPercent = (100 * prevContextTokens) / contextWindow - return contextPercent >= effectiveThreshold || prevContextTokens > allowedTokens + return prevContextTokens >= (trigger.thresholdTokens ?? 1) } /** @@ -218,6 +317,8 @@ export type ContextManagementOptions = { customCondensingPrompt?: string condensingApiHandler?: ApiHandler profileThresholds: Record + // kilocode_change + profileCondenseOverrides?: Record currentProfileId: string useNativeTools?: boolean } @@ -248,14 +349,13 @@ export async function manageContext({ customCondensingPrompt, condensingApiHandler, profileThresholds, + // kilocode_change + profileCondenseOverrides = {}, currentProfileId, useNativeTools, }: ContextManagementOptions): Promise { let error: string | undefined let cost = 0 - // Calculate the maximum tokens reserved for response - const reservedTokens = maxTokens || ANTHROPIC_DEFAULT_MAX_TOKENS - // Estimate tokens for the last message (which is always a user message) const lastMessage = messages[messages.length - 1] const lastMessageContent = lastMessage.content @@ -266,33 +366,31 @@ export async function manageContext({ // Calculate total effective tokens (totalTokens never includes the last message) const prevContextTokens = totalTokens + lastMessageTokens - // Calculate available tokens for conversation history - // Truncate if we're within TOKEN_BUFFER_PERCENTAGE of the context window - const allowedTokens = contextWindow * (1 - TOKEN_BUFFER_PERCENTAGE) - reservedTokens + // kilocode_change start + const trigger = resolveCondenseTrigger({ + contextWindow, + maxTokens, + autoCondenseContextPercent, + profileThresholds, + profileCondenseOverrides, + currentProfileId, + }) + // kilocode_change end - // Determine the effective threshold to use - let effectiveThreshold = autoCondenseContextPercent - const profileThreshold = profileThresholds[currentProfileId] - if (profileThreshold !== undefined) { - if (profileThreshold === -1) { - // Special case: -1 means inherit from global setting - effectiveThreshold = autoCondenseContextPercent - } else if (profileThreshold >= MIN_CONDENSE_THRESHOLD && profileThreshold <= MAX_CONDENSE_THRESHOLD) { - // Valid custom threshold - effectiveThreshold = profileThreshold - } else { - // Invalid threshold value, fall back to global setting - console.warn( - `Invalid profile threshold ${profileThreshold} for profile "${currentProfileId}". Using global default of ${autoCondenseContextPercent}%`, - ) - effectiveThreshold = autoCondenseContextPercent + if (autoCondenseContext) { + // kilocode_change start + let shouldCondense = prevContextTokens > trigger.allowedTokens + if (!shouldCondense) { + if (trigger.mode === "global_percent") { + const contextPercent = (100 * prevContextTokens) / contextWindow + shouldCondense = contextPercent >= (trigger.thresholdPercent ?? autoCondenseContextPercent) + } else { + shouldCondense = prevContextTokens >= (trigger.thresholdTokens ?? 1) + } } - } - // If no specific threshold is found for the profile, fall back to global setting + // kilocode_change end - if (autoCondenseContext) { - const contextPercent = (100 * prevContextTokens) / contextWindow - if (contextPercent >= effectiveThreshold || prevContextTokens > allowedTokens) { + if (shouldCondense) { // Attempt to intelligently condense the context const result = await summarizeConversation( messages, @@ -315,7 +413,7 @@ export async function manageContext({ } // Fall back to sliding window truncation if needed - if (prevContextTokens > allowedTokens) { + if (prevContextTokens > trigger.allowedTokens) { const truncationResult = truncateConversation(messages, 0.5, taskId) // Calculate new context tokens after truncation by counting non-truncated messages diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 80731efb03d..d53593c402e 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -4246,6 +4246,9 @@ export class Task extends EventEmitter implements TaskLike { private async handleContextWindowExceededError(): Promise { const state = await this.providerRef.deref()?.getState() const { profileThresholds = {} } = state ?? {} + // kilocode_change start + const { profileCondenseOverrides = {} } = state ?? {} + // kilocode_change end const { contextTokens } = this.getTokenUsage() // kilocode_change start: Initialize virtual quota fallback handler @@ -4292,6 +4295,8 @@ export class Task extends EventEmitter implements TaskLike { systemPrompt: await this.getSystemPrompt(), taskId: this.taskId, profileThresholds, + // kilocode_change + profileCondenseOverrides, currentProfileId, useNativeTools, }) @@ -4402,6 +4407,9 @@ export class Task extends EventEmitter implements TaskLike { autoCondenseContext = true, autoCondenseContextPercent = 100, profileThresholds = {}, + // kilocode_change start + profileCondenseOverrides = {}, + // kilocode_change end } = state ?? {} // Get condensing configuration for automatic triggers. @@ -4487,6 +4495,8 @@ export class Task extends EventEmitter implements TaskLike { autoCondenseContext, autoCondenseContextPercent, profileThresholds, + // kilocode_change + profileCondenseOverrides, currentProfileId, lastMessageTokens, }) @@ -4513,6 +4523,8 @@ export class Task extends EventEmitter implements TaskLike { customCondensingPrompt, condensingApiHandler, profileThresholds, + // kilocode_change + profileCondenseOverrides, currentProfileId, useNativeTools, }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 538303741bf..03adb2b8906 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2293,6 +2293,9 @@ export class ClineProvider codebaseIndexConfig, codebaseIndexModels, profileThresholds, + // kilocode_change start + profileCondenseOverrides, + // kilocode_change end systemNotificationsEnabled, // kilocode_change dismissedNotificationIds, // kilocode_change morphApiKey, // kilocode_change @@ -2517,6 +2520,9 @@ export class ClineProvider // undefined means no MDM policy, true means compliant, false means non-compliant mdmCompliant: this.mdmService?.requiresCloudAuth() ? this.checkMdmCompliance() : undefined, profileThresholds: profileThresholds ?? {}, + // kilocode_change start + profileCondenseOverrides: profileCondenseOverrides ?? {}, + // kilocode_change end cloudApiUrl: getRooCodeApiUrl(), hasOpenedModeSelector: this.getGlobalState("hasOpenedModeSelector") ?? false, hasCompletedOnboarding: this.getGlobalState("hasCompletedOnboarding"), // kilocode_change: Track onboarding completion - undefined means new user @@ -2841,6 +2847,9 @@ export class ClineProvider stateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, }, profileThresholds: stateValues.profileThresholds ?? {}, + // kilocode_change start + profileCondenseOverrides: stateValues.profileCondenseOverrides ?? {}, + // kilocode_change end includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true, maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50, includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index e188a1aa00d..877ade74a74 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -620,6 +620,7 @@ describe("ClineProvider", () => { sharingEnabled: false, publicSharingEnabled: false, profileThresholds: {}, + profileCondenseOverrides: {}, hasOpenedModeSelector: false, diagnosticsEnabled: true, openRouterImageApiKey: undefined, @@ -809,6 +810,34 @@ describe("ClineProvider", () => { expect(state).toHaveProperty("writeDelayMs") }) + // kilocode_change start + test("getState includes profileCondenseOverrides default", async () => { + const state = await provider.getState() + + expect(state.profileCondenseOverrides).toEqual({}) + }) + + test("handles profileCondenseOverrides updates through updateSettings", async () => { + await provider.resolveWebviewView(mockWebviewView) + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + + const overrides = { + "test-profile-id": { + enabled: true, + mode: "tokens" as const, + percent: 75, + tokens: 25000, + }, + } + + await messageHandler({ type: "updateSettings", updatedSettings: { profileCondenseOverrides: overrides } }) + + expect(updateGlobalStateSpy).toHaveBeenCalledWith("profileCondenseOverrides", overrides) + expect(mockContext.globalState.update).toHaveBeenCalledWith("profileCondenseOverrides", overrides) + expect((await provider.getState()).profileCondenseOverrides).toEqual(overrides) + }) + // kilocode_change end + test("language is set to VSCode language", async () => { // Mock VSCode language as Spanish ;(vscode.env as any).language = "pt-BR" diff --git a/webview-ui/src/components/settings/ContextManagementSettings.tsx b/webview-ui/src/components/settings/ContextManagementSettings.tsx index 5b0737d602e..36829d38aa8 100644 --- a/webview-ui/src/components/settings/ContextManagementSettings.tsx +++ b/webview-ui/src/components/settings/ContextManagementSettings.tsx @@ -11,12 +11,21 @@ import { SetCachedStateField } from "./types" import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" import { SearchableSetting } from "./SearchableSetting" -import { vscode } from "@/utils/vscode" + +// kilocode_change start +type ProfileCondenseOverrideValue = { + enabled: boolean + mode: "percent" | "tokens" + percent: number + tokens: number +} + +const clampNumber = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max) +// kilocode_change end type ContextManagementSettingsProps = HTMLAttributes & { autoCondenseContext: boolean autoCondenseContextPercent: number - listApiConfigMeta: any[] maxOpenTabsContext: number maxWorkspaceFiles: number showRooIgnoredFiles?: boolean @@ -26,7 +35,12 @@ type ContextManagementSettingsProps = HTMLAttributes & { maxTotalImageSize?: number maxConcurrentFileReads?: number allowVeryLargeReads?: boolean // kilocode_change - profileThresholds?: Record + // kilocode_change start + profileCondenseOverrides?: Record + currentProfileName: string + currentProfileId: string + condenseEffectiveBudgetTokens: number + // kilocode_change end includeDiagnosticMessages?: boolean maxDiagnosticMessages?: number writeDelayMs: number @@ -45,7 +59,8 @@ type ContextManagementSettingsProps = HTMLAttributes & { | "maxTotalImageSize" | "maxConcurrentFileReads" | "allowVeryLargeReads" // kilocode_change - | "profileThresholds" + // kilocode_change + | "profileCondenseOverrides" | "includeDiagnosticMessages" | "maxDiagnosticMessages" | "writeDelayMs" @@ -58,7 +73,6 @@ type ContextManagementSettingsProps = HTMLAttributes & { export const ContextManagementSettings = ({ autoCondenseContext, autoCondenseContextPercent, - listApiConfigMeta, maxOpenTabsContext, maxWorkspaceFiles, showRooIgnoredFiles, @@ -69,7 +83,12 @@ export const ContextManagementSettings = ({ maxTotalImageSize, maxConcurrentFileReads, allowVeryLargeReads, // kilocode_change - profileThresholds = {}, + // kilocode_change start + profileCondenseOverrides = {}, + currentProfileName, + currentProfileId, + condenseEffectiveBudgetTokens, + // kilocode_change end includeDiagnosticMessages, maxDiagnosticMessages, writeDelayMs, @@ -80,34 +99,62 @@ export const ContextManagementSettings = ({ ...props }: ContextManagementSettingsProps) => { const { t } = useAppTranslation() - const [selectedThresholdProfile, setSelectedThresholdProfile] = React.useState("default") + // kilocode_change start + const [tokenClampNotice, setTokenClampNotice] = React.useState(null) + const hardLimitTokens = Math.max(1, Math.floor(condenseEffectiveBudgetTokens || 0)) - // Helper function to get the current threshold value based on selected profile - const getCurrentThresholdValue = () => { - if (selectedThresholdProfile === "default") { - return autoCondenseContextPercent + const defaultProfileOverride = React.useMemo(() => { + const safePercent = clampNumber(autoCondenseContextPercent, 5, 100) + return { + enabled: false, + mode: "percent", + percent: safePercent, + tokens: Math.max(1, Math.floor((safePercent / 100) * hardLimitTokens)), } - const profileThreshold = profileThresholds[selectedThresholdProfile] - if (profileThreshold === undefined || profileThreshold === -1) { - return autoCondenseContextPercent // Use default if profile not configured or set to -1 + }, [autoCondenseContextPercent, hardLimitTokens]) + + const normalizeOverride = React.useCallback( + (override?: Partial): ProfileCondenseOverrideValue => ({ + enabled: override?.enabled ?? defaultProfileOverride.enabled, + mode: override?.mode === "tokens" ? "tokens" : "percent", + percent: clampNumber(Math.floor(override?.percent ?? defaultProfileOverride.percent), 5, 100), + tokens: clampNumber(Math.floor(override?.tokens ?? defaultProfileOverride.tokens), 1, hardLimitTokens), + }), + [defaultProfileOverride, hardLimitTokens], + ) + + const currentProfileOverride = normalizeOverride(profileCondenseOverrides[currentProfileId]) + + const updateCurrentProfileOverride = (updates: Partial) => { + const nextOverrides = { + ...profileCondenseOverrides, + [currentProfileId]: normalizeOverride({ ...currentProfileOverride, ...updates }), } - return profileThreshold + setCachedStateField("profileCondenseOverrides", nextOverrides) } - // Helper function to handle threshold changes - const handleThresholdChange = (value: number) => { - if (selectedThresholdProfile === "default") { - setCachedStateField("autoCondenseContextPercent", value) - } else { - const newThresholds = { - ...profileThresholds, - [selectedThresholdProfile]: value, - } + const profilePercentTokenEquivalent = Math.max( + 1, + Math.floor((currentProfileOverride.percent / 100) * Math.max(1, hardLimitTokens)), + ) - setCachedStateField("profileThresholds", newThresholds) - vscode.postMessage({ type: "updateSettings", updatedSettings: { profileThresholds: newThresholds } }) + React.useEffect(() => { + const existingOverride = profileCondenseOverrides[currentProfileId] + if (!existingOverride || !existingOverride.enabled || existingOverride.mode !== "tokens") { + return } - } + + const clampedTokens = clampNumber(Math.floor(existingOverride.tokens), 1, hardLimitTokens) + if (clampedTokens !== existingOverride.tokens) { + updateCurrentProfileOverride({ tokens: clampedTokens }) + setTokenClampNotice( + t("settings:contextManagement.condensingThreshold.profileTokenClamped", { + tokens: clampedTokens, + }), + ) + } + }, [currentProfileId, hardLimitTokens, profileCondenseOverrides, t]) // eslint-disable-line react-hooks/exhaustive-deps + // kilocode_change end return (
@@ -491,71 +538,124 @@ export const ContextManagementSettings = ({
{t("settings:contextManagement.condensingThreshold.label")}
-
- -
- - {/* Threshold Slider */}
handleThresholdChange(value)} + value={[autoCondenseContextPercent]} + onValueChange={([value]) => setCachedStateField("autoCondenseContextPercent", value)} data-testid="condense-threshold-slider" /> - {getCurrentThresholdValue()}% + {autoCondenseContextPercent}%
- {selectedThresholdProfile === "default" - ? t("settings:contextManagement.condensingThreshold.defaultDescription", { - threshold: autoCondenseContextPercent, - }) - : t("settings:contextManagement.condensingThreshold.profileDescription")} + {t("settings:contextManagement.condensingThreshold.defaultDescription", { + threshold: autoCondenseContextPercent, + })}
+ {/* kilocode_change start */} +
+ { + setTokenClampNotice(null) + updateCurrentProfileOverride({ enabled: e.target.checked }) + }} + data-testid="condense-current-profile-override-checkbox"> + + {t("settings:contextManagement.condensingThreshold.profileOverrideLabel")} + + +
+ {t("settings:contextManagement.condensingThreshold.currentProfileLabel", { + profile: currentProfileName, + })} +
+
+ {t("settings:contextManagement.condensingThreshold.hardLimitLabel", { + tokens: hardLimitTokens.toLocaleString(), + })} +
+ + {currentProfileOverride.mode === "percent" ? ( +
+
+ { + setTokenClampNotice(null) + updateCurrentProfileOverride({ percent: value }) + }} + disabled={!currentProfileOverride.enabled} + data-testid="condense-profile-percent-slider" + /> + {currentProfileOverride.percent}% +
+
+ {t("settings:contextManagement.condensingThreshold.percentEquivalent", { + tokens: profilePercentTokenEquivalent.toLocaleString(), + })} +
+
+ ) : ( +
+ { + const parsed = parseInt(e.target.value, 10) + if (Number.isNaN(parsed)) { + return + } + setTokenClampNotice(null) + updateCurrentProfileOverride({ tokens: parsed }) + }} + disabled={!currentProfileOverride.enabled} + onClick={(e) => e.currentTarget.select()} + data-testid="condense-profile-token-input" + className="w-32 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 disabled:opacity-50" + /> + {t("settings:contextManagement.condensingThreshold.tokensUnit")} +
+ )} + {tokenClampNotice && ( +
+ {tokenClampNotice} +
+ )} +
+
+ {t("settings:contextManagement.condensingThreshold.profileDescription")} +
+ {/* kilocode_change end */} )} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 467584d61a2..e0525e98978 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -39,9 +39,11 @@ import { type ExperimentId, type TelemetrySetting, type ProfileType, // kilocode_change - autocomplete profile type system + ANTHROPIC_DEFAULT_MAX_TOKENS, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, ImageGenerationProvider, } from "@roo-code/types" +import { getModelMaxOutputTokens } from "@roo/api" import { vscode } from "@src/utils/vscode" import { cn } from "@src/lib/utils" @@ -90,6 +92,7 @@ import AgentBehaviourView from "../kilocode/settings/AgentBehaviourView" // kilo // import McpView from "../mcp/McpView" // kilocode_change: own view import { SettingsSearch } from "./SettingsSearch" import { useSearchIndexRegistry, SearchIndexProvider } from "./useSettingsSearch" +import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel" export const settingsTabsContainer = "flex flex-1 overflow-hidden [&.narrow_.tab-label]:hidden" export const settingsTabList = @@ -99,6 +102,9 @@ export const settingsTabTrigger = export const settingsTabTriggerActive = "opacity-100 border-vscode-focusBorder bg-vscode-list-activeSelectionBackground hover:bg-vscode-list-activeSelectionBackground cursor-default" // kilocode_change add hover:bg-* and cursor-default +// kilocode_change +const CONDENSE_TOKEN_BUFFER_PERCENTAGE = 0.1 + export interface SettingsViewRef { checkUnsaveChanges: (then: () => void) => void } @@ -238,7 +244,8 @@ const SettingsView = forwardRef((props, ref) customCondensingPrompt, yoloGatekeeperApiConfigId, // kilocode_change: AI gatekeeper for YOLO mode customSupportPrompts, - profileThresholds, + // kilocode_change + profileCondenseOverrides, systemNotificationsEnabled, // kilocode_change alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs, @@ -267,6 +274,36 @@ const SettingsView = forwardRef((props, ref) } = cachedState const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration]) + // kilocode_change start + const currentProfileMeta = useMemo( + () => (listApiConfigMeta ?? []).find((profile) => profile.name === editingApiConfigName), + [listApiConfigMeta, editingApiConfigName], + ) + const currentCondenseProfileId = currentProfileMeta?.id ?? "default" + const currentCondenseProfileName = editingApiConfigName || currentApiConfigName || "default" + const { id: condenseModelId, info: condenseModelInfo } = useSelectedModel(apiConfiguration) + + const condenseReservedTokens = useMemo(() => { + if (!condenseModelInfo) { + return ANTHROPIC_DEFAULT_MAX_TOKENS + } + return ( + getModelMaxOutputTokens({ + modelId: condenseModelId, + model: condenseModelInfo, + settings: apiConfiguration, + }) ?? ANTHROPIC_DEFAULT_MAX_TOKENS + ) + }, [condenseModelId, condenseModelInfo, apiConfiguration]) + + const condenseEffectiveBudgetTokens = useMemo(() => { + const contextWindow = condenseModelInfo?.contextWindow ?? 0 + return Math.max( + 0, + Math.floor(contextWindow * (1 - CONDENSE_TOKEN_BUFFER_PERCENTAGE) - condenseReservedTokens), + ) + }, [condenseModelInfo?.contextWindow, condenseReservedTokens]) + // kilocode_change end useEffect(() => { // Update only when currentApiConfigName is changed. @@ -600,7 +637,8 @@ const SettingsView = forwardRef((props, ref) includeCurrentTime: includeCurrentTime ?? true, includeCurrentCost: includeCurrentCost ?? true, maxGitStatusFiles: maxGitStatusFiles ?? 0, - profileThresholds, + // kilocode_change + profileCondenseOverrides, imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, @@ -1209,7 +1247,6 @@ const SettingsView = forwardRef((props, ref) ((props, ref) maxTotalImageSize={maxTotalImageSize} maxConcurrentFileReads={maxConcurrentFileReads} allowVeryLargeReads={allowVeryLargeReads /* kilocode_change */} - profileThresholds={profileThresholds} + // kilocode_change start + profileCondenseOverrides={profileCondenseOverrides} + currentProfileName={currentCondenseProfileName} + currentProfileId={currentCondenseProfileId} + condenseEffectiveBudgetTokens={condenseEffectiveBudgetTokens} + // kilocode_change end includeDiagnosticMessages={includeDiagnosticMessages} maxDiagnosticMessages={maxDiagnosticMessages} writeDelayMs={writeDelayMs} diff --git a/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx index 65734b63547..29ecd9d3fe4 100644 --- a/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx @@ -1,565 +1,314 @@ -// npx vitest src/components/settings/__tests__/ContextManagementSettings.spec.tsx +import React from "react" +import { fireEvent, render, screen, waitFor } from "@/utils/test-utils" -import { render, screen, fireEvent, waitFor } from "@/utils/test-utils" import { ContextManagementSettings } from "../ContextManagementSettings" -// Mock the translation hook -vi.mock("@/hooks/useAppTranslation", () => ({ +vi.mock("@/i18n/TranslationContext", () => ({ useAppTranslation: () => ({ - t: (key: string) => { - // Return specific translations for our test cases - if (key === "settings:contextManagement.diagnostics.maxMessages.unlimitedLabel") { - return "Unlimited" - } - return key - }, + t: (key: string) => key, }), })) -// Mock the UI components vi.mock("@/components/ui", () => ({ - ...vi.importActual("@/components/ui"), - Slider: ({ value, onValueChange, "data-testid": dataTestId, disabled, min, max }: any) => ( + Slider: ({ value, onValueChange, "data-testid": dataTestId, disabled, min, max, step }: any) => ( onValueChange([parseFloat(e.target.value)])} - onKeyDown={(e) => { - const currentValue = value?.[0] ?? 0 - if (e.key === "ArrowRight") { - onValueChange([currentValue + 1]) - } else if (e.key === "ArrowLeft") { - onValueChange([currentValue - 1]) - } - }} data-testid={dataTestId} disabled={disabled} - role="slider" /> ), - Input: ({ value, onChange, "data-testid": dataTestId, ...props }: any) => ( - + Input: ({ value, onChange, "data-testid": dataTestId, disabled, ...props }: any) => ( + ), - Button: ({ children, onClick, ...props }: any) => ( - ), - Select: ({ children, ...props }: any) => ( -
- {children} -
+ Select: ({ value, onValueChange, disabled, "data-testid": dataTestId }: any) => ( + ), - SelectTrigger: ({ children, ...props }: any) =>
{children}
, - SelectValue: ({ children, ...props }: any) =>
{children}
, - SelectContent: ({ children, ...props }: any) =>
{children}
, - SelectItem: ({ children, ...props }: any) =>
{children}
, + SelectTrigger: ({ children }: any) => <>{children}, + SelectValue: () => null, + SelectContent: ({ children }: any) => <>{children}, + SelectItem: () => null, })) -// Mock vscode utilities - this is necessary since we're not in a VSCode environment - -vi.mock("@/utils/vscode", () => ({ - vscode: { - postMessage: vi.fn(), - }, -})) - -// Mock VSCode components to behave like standard HTML elements vi.mock("@vscode/webview-ui-toolkit/react", () => ({ VSCodeCheckbox: ({ checked, onChange, children, "data-testid": dataTestId, ...props }: any) => ( ), - VSCodeTextArea: ({ value, onChange, ...props }: any) =>