From 1c8f9ed6836f3183e7d481aa4e44b32e93007e92 Mon Sep 17 00:00:00 2001 From: Adam Larson Date: Tue, 4 Mar 2025 01:48:40 -0500 Subject: [PATCH 01/14] first pass on speculative decoding with LMStudio --- src/api/providers/lmstudio.ts | 29 ++++++-- src/core/webview/ClineProvider.ts | 10 +++ src/shared/api.ts | 2 + src/shared/globalState.ts | 2 + .../src/components/settings/ApiOptions.tsx | 73 +++++++++++++++++++ 5 files changed, 111 insertions(+), 5 deletions(-) diff --git a/src/api/providers/lmstudio.ts b/src/api/providers/lmstudio.ts index 5308ebb85c..9a3ab187bf 100644 --- a/src/api/providers/lmstudio.ts +++ b/src/api/providers/lmstudio.ts @@ -30,13 +30,24 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan ] try { - const stream = await this.client.chat.completions.create({ + // Create params object with optional draft model + const params: any = { model: this.getModel().id, messages: openAiMessages, temperature: this.options.modelTemperature ?? LMSTUDIO_DEFAULT_TEMPERATURE, stream: true, - }) - for await (const chunk of stream) { + } + + // Add draft model if speculative decoding is enabled and a draft model is specified + if (this.options.lmStudioSpeculativeDecodingEnabled && this.options.lmStudioDraftModelId) { + params.draft_model = this.options.lmStudioDraftModelId + } + + const results = await this.client.chat.completions.create(params) + + // Stream handling + // @ts-ignore + for await (const chunk of results) { const delta = chunk.choices[0]?.delta if (delta?.content) { yield { @@ -62,12 +73,20 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan async completePrompt(prompt: string): Promise { try { - const response = await this.client.chat.completions.create({ + // Create params object with optional draft model + const params: any = { model: this.getModel().id, messages: [{ role: "user", content: prompt }], temperature: this.options.modelTemperature ?? LMSTUDIO_DEFAULT_TEMPERATURE, stream: false, - }) + } + + // Add draft model if speculative decoding is enabled and a draft model is specified + if (this.options.lmStudioSpeculativeDecodingEnabled && this.options.lmStudioDraftModelId) { + params.draft_model = this.options.lmStudioDraftModelId + } + + const response = await this.client.chat.completions.create(params) return response.choices[0]?.message.content || "" } catch (error) { throw new Error( diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 0a2e385b6a..d0e68420b5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1676,6 +1676,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { modelTemperature, modelMaxTokens, modelMaxThinkingTokens, + lmStudioDraftModelId, + lmStudioSpeculativeDecodingEnabled, } = apiConfiguration await Promise.all([ this.updateGlobalState("apiProvider", apiProvider), @@ -1725,6 +1727,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.updateGlobalState("modelTemperature", modelTemperature), this.updateGlobalState("modelMaxTokens", modelMaxTokens), this.updateGlobalState("anthropicThinking", modelMaxThinkingTokens), + this.updateGlobalState("lmStudioDraftModelId", lmStudioDraftModelId), + this.updateGlobalState("lmStudioSpeculativeDecodingEnabled", lmStudioSpeculativeDecodingEnabled), ]) if (this.cline) { this.cline.api = buildApiHandler(apiConfiguration) @@ -2221,6 +2225,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { modelMaxThinkingTokens, maxOpenTabsContext, browserToolEnabled, + lmStudioSpeculativeDecodingEnabled, + lmStudioDraftModelId, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -2306,6 +2312,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("anthropicThinking") as Promise, this.getGlobalState("maxOpenTabsContext") as Promise, this.getGlobalState("browserToolEnabled") as Promise, + this.getGlobalState("lmStudioSpeculativeDecodingEnabled") as Promise, + this.getGlobalState("lmStudioDraftModelId") as Promise, ]) let apiProvider: ApiProvider @@ -2371,6 +2379,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { modelTemperature, modelMaxTokens, modelMaxThinkingTokens, + lmStudioSpeculativeDecodingEnabled, + lmStudioDraftModelId, }, lastShownAnnouncementId, customInstructions, diff --git a/src/shared/api.ts b/src/shared/api.ts index 462afc46cd..2ce7162640 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -49,6 +49,8 @@ export interface ApiHandlerOptions { ollamaBaseUrl?: string lmStudioModelId?: string lmStudioBaseUrl?: string + lmStudioDraftModelId?: string + lmStudioSpeculativeDecodingEnabled?: boolean geminiApiKey?: string openAiNativeApiKey?: string mistralApiKey?: string diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index 64a222be81..88f9824151 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -41,6 +41,8 @@ export type GlobalStateKey = | "ollamaBaseUrl" | "lmStudioModelId" | "lmStudioBaseUrl" + | "lmStudioDraftModelId" + | "lmStudioSpeculativeDecodingEnabled" | "anthropicBaseUrl" | "azureApiVersion" | "openAiStreamingEnabled" diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index e69be19ef2..b9050a999f 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -1107,6 +1107,79 @@ const ApiOptions = ({ ))} )} +
+ { + // Explicitly set the boolean value using direct method + setApiConfigurationField("lmStudioSpeculativeDecodingEnabled", checked) + }}> + Enable Speculative Decoding + +
+ {apiConfiguration?.lmStudioSpeculativeDecodingEnabled && ( + <> + + Draft Model ID + +
+ + + Draft model must be from the same model family for speculative decoding to work + correctly. + +
+ {lmStudioModels.length > 0 && ( + <> +
+ Select Draft Model +
+ + {lmStudioModels.map((model) => ( + + {model} + + ))} + + {lmStudioModels.length === 0 && ( +
+ + No draft models found. Please ensure LM Studio is running with Server Mode + enabled. +
+ )} + + )} + + )} +

Date: Thu, 27 Feb 2025 14:37:34 +0700 Subject: [PATCH 02/14] Feat ContextProxy to improve state management - Add ContextProxy class as a wrapper around VSCode's ExtensionContext - Implement batched state updates for performance optimization - Update ClineProvider to use ContextProxy instead of direct context access - Add comprehensive test coverage for ContextProxy - Extract SECRET_KEYS and GLOBAL_STATE_KEYS constants for better maintainability --- src/core/__tests__/contextProxy.test.ts | 282 +++++++++ src/core/contextProxy.ts | 123 ++++ src/core/webview/ClineProvider.ts | 577 ++++++------------ .../webview/__tests__/ClineProvider.test.ts | 130 ++++ src/shared/globalState.ts | 91 ++- 5 files changed, 797 insertions(+), 406 deletions(-) create mode 100644 src/core/__tests__/contextProxy.test.ts create mode 100644 src/core/contextProxy.ts diff --git a/src/core/__tests__/contextProxy.test.ts b/src/core/__tests__/contextProxy.test.ts new file mode 100644 index 0000000000..794cd91497 --- /dev/null +++ b/src/core/__tests__/contextProxy.test.ts @@ -0,0 +1,282 @@ +import * as vscode from "vscode" +import { ContextProxy } from "../contextProxy" +import { logger } from "../../utils/logging" + +// Mock the logger +jest.mock("../../utils/logging", () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})) + +// Mock VSCode API +jest.mock("vscode", () => ({ + Uri: { + file: jest.fn((path) => ({ path })), + }, + ExtensionMode: { + Development: 1, + Production: 2, + Test: 3, + }, +})) + +describe("ContextProxy", () => { + let proxy: ContextProxy + let mockContext: any + let mockGlobalState: any + let mockSecrets: any + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks() + + // Mock globalState + mockGlobalState = { + get: jest.fn(), + update: jest.fn().mockResolvedValue(undefined), + } + + // Mock secrets + mockSecrets = { + get: jest.fn(), + store: jest.fn().mockResolvedValue(undefined), + delete: jest.fn().mockResolvedValue(undefined), + } + + // Mock the extension context + mockContext = { + globalState: mockGlobalState, + secrets: mockSecrets, + extensionUri: { path: "/test/extension" }, + extensionPath: "/test/extension", + globalStorageUri: { path: "/test/storage" }, + logUri: { path: "/test/logs" }, + extension: { packageJSON: { version: "1.0.0" } }, + extensionMode: vscode.ExtensionMode.Development, + } + + // Create proxy instance + proxy = new ContextProxy(mockContext) + }) + + describe("read-only pass-through properties", () => { + it("should return extension properties from the original context", () => { + expect(proxy.extensionUri).toBe(mockContext.extensionUri) + expect(proxy.extensionPath).toBe(mockContext.extensionPath) + expect(proxy.globalStorageUri).toBe(mockContext.globalStorageUri) + expect(proxy.logUri).toBe(mockContext.logUri) + expect(proxy.extension).toBe(mockContext.extension) + expect(proxy.extensionMode).toBe(mockContext.extensionMode) + }) + }) + + describe("getGlobalState", () => { + it("should return pending change when it exists", async () => { + // Set up a pending change + await proxy.updateGlobalState("test-key", "new-value") + + // Should return the pending value + const result = await proxy.getGlobalState("test-key") + expect(result).toBe("new-value") + + // Original context should not be called + expect(mockGlobalState.get).not.toHaveBeenCalled() + }) + + it("should fall back to original context when no pending change exists", async () => { + // Set up original context value + mockGlobalState.get.mockReturnValue("original-value") + + // Should get from original context + const result = await proxy.getGlobalState("test-key") + expect(result).toBe("original-value") + expect(mockGlobalState.get).toHaveBeenCalledWith("test-key", undefined) + }) + + it("should handle default values correctly", async () => { + // No value in either pending or original + mockGlobalState.get.mockImplementation((key: string, defaultValue: any) => defaultValue) + + // Should return the default value + const result = await proxy.getGlobalState("test-key", "default-value") + expect(result).toBe("default-value") + }) + }) + + describe("updateGlobalState", () => { + it("should buffer changes without calling original context", async () => { + await proxy.updateGlobalState("test-key", "new-value") + + // Should have called logger.debug + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("buffering state update")) + + // Should not have called original context + expect(mockGlobalState.update).not.toHaveBeenCalled() + + // Should have stored the value in pendingStateChanges + const storedValue = await proxy.getGlobalState("test-key") + expect(storedValue).toBe("new-value") + }) + + it("should throw an error when context is disposed", async () => { + await proxy.dispose() + + await expect(proxy.updateGlobalState("test-key", "new-value")).rejects.toThrow( + "Cannot update state on disposed context", + ) + }) + }) + + describe("getSecret", () => { + it("should return pending secret when it exists", async () => { + // Set up a pending secret + await proxy.storeSecret("api-key", "secret123") + + // Should return the pending value + const result = await proxy.getSecret("api-key") + expect(result).toBe("secret123") + + // Original context should not be called + expect(mockSecrets.get).not.toHaveBeenCalled() + }) + + it("should fall back to original context when no pending secret exists", async () => { + // Set up original context value + mockSecrets.get.mockResolvedValue("original-secret") + + // Should get from original context + const result = await proxy.getSecret("api-key") + expect(result).toBe("original-secret") + expect(mockSecrets.get).toHaveBeenCalledWith("api-key") + }) + }) + + describe("storeSecret", () => { + it("should buffer secret changes without calling original context", async () => { + await proxy.storeSecret("api-key", "new-secret") + + // Should have called logger.debug + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("buffering secret update")) + + // Should not have called original context + expect(mockSecrets.store).not.toHaveBeenCalled() + + // Should have stored the value in pendingSecretChanges + const storedValue = await proxy.getSecret("api-key") + expect(storedValue).toBe("new-secret") + }) + + it("should handle undefined value for secret deletion", async () => { + await proxy.storeSecret("api-key", undefined) + + // Should have stored undefined in pendingSecretChanges + const storedValue = await proxy.getSecret("api-key") + expect(storedValue).toBeUndefined() + }) + + it("should throw an error when context is disposed", async () => { + await proxy.dispose() + + await expect(proxy.storeSecret("api-key", "new-secret")).rejects.toThrow( + "Cannot store secret on disposed context", + ) + }) + }) + + describe("saveChanges", () => { + it("should apply state changes to original context", async () => { + // Set up pending changes + await proxy.updateGlobalState("key1", "value1") + await proxy.updateGlobalState("key2", "value2") + + // Save changes + await proxy.saveChanges() + + // Should have called update on original context + expect(mockGlobalState.update).toHaveBeenCalledTimes(2) + expect(mockGlobalState.update).toHaveBeenCalledWith("key1", "value1") + expect(mockGlobalState.update).toHaveBeenCalledWith("key2", "value2") + + // Should have cleared pending changes + expect(proxy.hasPendingChanges()).toBe(false) + }) + + it("should apply secret changes to original context", async () => { + // Set up pending changes + await proxy.storeSecret("secret1", "value1") + await proxy.storeSecret("secret2", undefined) + + // Save changes + await proxy.saveChanges() + + // Should have called store and delete on original context + expect(mockSecrets.store).toHaveBeenCalledTimes(1) + expect(mockSecrets.store).toHaveBeenCalledWith("secret1", "value1") + expect(mockSecrets.delete).toHaveBeenCalledTimes(1) + expect(mockSecrets.delete).toHaveBeenCalledWith("secret2") + + // Should have cleared pending changes + expect(proxy.hasPendingChanges()).toBe(false) + }) + + it("should do nothing when there are no pending changes", async () => { + await proxy.saveChanges() + + expect(mockGlobalState.update).not.toHaveBeenCalled() + expect(mockSecrets.store).not.toHaveBeenCalled() + expect(mockSecrets.delete).not.toHaveBeenCalled() + }) + + it("should throw an error when context is disposed", async () => { + await proxy.dispose() + + await expect(proxy.saveChanges()).rejects.toThrow("Cannot save changes on disposed context") + }) + }) + + describe("dispose", () => { + it("should save pending changes to original context", async () => { + // Set up pending changes + await proxy.updateGlobalState("key1", "value1") + await proxy.storeSecret("secret1", "value1") + + // Dispose + await proxy.dispose() + + // Should have saved changes + expect(mockGlobalState.update).toHaveBeenCalledWith("key1", "value1") + expect(mockSecrets.store).toHaveBeenCalledWith("secret1", "value1") + + // Should be marked as disposed + expect(proxy.hasPendingChanges()).toBe(false) + }) + }) + + describe("hasPendingChanges", () => { + it("should return false when no changes are pending", () => { + expect(proxy.hasPendingChanges()).toBe(false) + }) + + it("should return true when state changes are pending", async () => { + await proxy.updateGlobalState("key", "value") + expect(proxy.hasPendingChanges()).toBe(true) + }) + + it("should return true when secret changes are pending", async () => { + await proxy.storeSecret("key", "value") + expect(proxy.hasPendingChanges()).toBe(true) + }) + + it("should return false after changes are saved", async () => { + await proxy.updateGlobalState("key", "value") + expect(proxy.hasPendingChanges()).toBe(true) + + await proxy.saveChanges() + expect(proxy.hasPendingChanges()).toBe(false) + }) + }) +}) diff --git a/src/core/contextProxy.ts b/src/core/contextProxy.ts new file mode 100644 index 0000000000..e4672ae225 --- /dev/null +++ b/src/core/contextProxy.ts @@ -0,0 +1,123 @@ +import * as vscode from "vscode" +import { logger } from "../utils/logging" + +/** + * A proxy class for vscode.ExtensionContext that buffers state changes + * and only commits them when explicitly requested or during disposal. + */ +export class ContextProxy { + private readonly originalContext: vscode.ExtensionContext + private pendingStateChanges: Map + private pendingSecretChanges: Map + private disposed: boolean + + constructor(context: vscode.ExtensionContext) { + this.originalContext = context + this.pendingStateChanges = new Map() + this.pendingSecretChanges = new Map() + this.disposed = false + logger.debug("ContextProxy created") + } + + // Read-only pass-through properties + get extensionUri(): vscode.Uri { + return this.originalContext.extensionUri + } + get extensionPath(): string { + return this.originalContext.extensionPath + } + get globalStorageUri(): vscode.Uri { + return this.originalContext.globalStorageUri + } + get logUri(): vscode.Uri { + return this.originalContext.logUri + } + get extension(): vscode.Extension | undefined { + return this.originalContext.extension + } + get extensionMode(): vscode.ExtensionMode { + return this.originalContext.extensionMode + } + + // State management methods + async getGlobalState(key: string): Promise + async getGlobalState(key: string, defaultValue: T): Promise + async getGlobalState(key: string, defaultValue?: T): Promise { + // Check pending changes first + if (this.pendingStateChanges.has(key)) { + const value = this.pendingStateChanges.get(key) as T | undefined + return value !== undefined ? value : (defaultValue as T | undefined) + } + // Fall back to original context + return this.originalContext.globalState.get(key, defaultValue as T) + } + + async updateGlobalState(key: string, value: T): Promise { + if (this.disposed) { + throw new Error("Cannot update state on disposed context") + } + logger.debug(`ContextProxy: buffering state update for key "${key}"`) + this.pendingStateChanges.set(key, value) + } + + // Secret storage methods + async getSecret(key: string): Promise { + // Check pending changes first + if (this.pendingSecretChanges.has(key)) { + return this.pendingSecretChanges.get(key) + } + // Fall back to original context + return this.originalContext.secrets.get(key) + } + + async storeSecret(key: string, value?: string): Promise { + if (this.disposed) { + throw new Error("Cannot store secret on disposed context") + } + logger.debug(`ContextProxy: buffering secret update for key "${key}"`) + this.pendingSecretChanges.set(key, value) + } + + // Save pending changes to actual context + async saveChanges(): Promise { + if (this.disposed) { + throw new Error("Cannot save changes on disposed context") + } + + // Apply state changes + if (this.pendingStateChanges.size > 0) { + logger.debug(`ContextProxy: applying ${this.pendingStateChanges.size} buffered state changes`) + for (const [key, value] of this.pendingStateChanges.entries()) { + await this.originalContext.globalState.update(key, value) + } + this.pendingStateChanges.clear() + } + + // Apply secret changes + if (this.pendingSecretChanges.size > 0) { + logger.debug(`ContextProxy: applying ${this.pendingSecretChanges.size} buffered secret changes`) + for (const [key, value] of this.pendingSecretChanges.entries()) { + if (value === undefined) { + await this.originalContext.secrets.delete(key) + } else { + await this.originalContext.secrets.store(key, value) + } + } + this.pendingSecretChanges.clear() + } + } + + // Called when the provider is disposing + async dispose(): Promise { + if (!this.disposed) { + logger.debug("ContextProxy: disposing and saving pending changes") + await this.saveChanges() + this.disposed = true + } + } + + // Method to check if there are pending changes + hasPendingChanges(): boolean { + return this.pendingStateChanges.size > 0 || this.pendingSecretChanges.size > 0 + } +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index d0e68420b5..d9a1525730 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -12,7 +12,7 @@ import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api" import { findLast } from "../../shared/array" import { CustomSupportPrompts, supportPrompt } from "../../shared/support-prompt" import { GlobalFileNames } from "../../shared/globalFileNames" -import type { SecretKey, GlobalStateKey } from "../../shared/globalState" +import { SecretKey, GlobalStateKey, SECRET_KEYS, GLOBAL_STATE_KEYS } from "../../shared/globalState" import { HistoryItem } from "../../shared/HistoryItem" import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage" import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage" @@ -34,6 +34,7 @@ import { getDiffStrategy } from "../diff/DiffStrategy" import { SYSTEM_PROMPT } from "../prompts/system" import { ConfigManager } from "../config/ConfigManager" import { CustomModesManager } from "../config/CustomModesManager" +import { ContextProxy } from "../contextProxy" import { buildApiHandler } from "../../api" import { getOpenRouterModels } from "../../api/providers/openrouter" import { getGlamaModels } from "../../api/providers/glama" @@ -65,6 +66,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { private workspaceTracker?: WorkspaceTracker protected mcpHub?: McpHub // Change from private to protected private latestAnnouncementId = "feb-27-2025-automatic-checkpoints" // update to some unique identifier when we add a new announcement + private contextProxy: ContextProxy configManager: ConfigManager customModesManager: CustomModesManager @@ -73,6 +75,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { private readonly outputChannel: vscode.OutputChannel, ) { this.outputChannel.appendLine("ClineProvider instantiated") + this.contextProxy = new ContextProxy(context) ClineProvider.activeInstances.add(this) this.workspaceTracker = new WorkspaceTracker(this) this.configManager = new ConfigManager(this.context) @@ -115,6 +118,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.mcpHub = undefined this.customModesManager?.dispose() this.outputChannel.appendLine("Disposed all disposables") + // Dispose the context proxy to commit any pending changes + await this.contextProxy.dispose() + this.outputChannel.appendLine("Disposed context proxy") ClineProvider.activeInstances.delete(this) // Unregister from McpServerManager @@ -241,11 +247,11 @@ export class ClineProvider implements vscode.WebviewViewProvider { webviewView.webview.options = { // Allow scripts in the webview enableScripts: true, - localResourceRoots: [this.context.extensionUri], + localResourceRoots: [this.contextProxy.extensionUri], } webviewView.webview.html = - this.context.extensionMode === vscode.ExtensionMode.Development + this.contextProxy.extensionMode === vscode.ExtensionMode.Development ? await this.getHMRHtmlContent(webviewView.webview) : this.getHtmlContent(webviewView.webview) @@ -389,8 +395,13 @@ export class ClineProvider implements vscode.WebviewViewProvider { } const nonce = getNonce() - const stylesUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.css"]) - const codiconsUri = getUri(webview, this.context.extensionUri, [ + const stylesUri = getUri(webview, this.contextProxy.extensionUri, [ + "webview-ui", + "build", + "assets", + "index.css", + ]) + const codiconsUri = getUri(webview, this.contextProxy.extensionUri, [ "node_modules", "@vscode", "codicons", @@ -456,15 +467,20 @@ export class ClineProvider implements vscode.WebviewViewProvider { // then convert it to a uri we can use in the webview. // The CSS file from the React build output - const stylesUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.css"]) + const stylesUri = getUri(webview, this.contextProxy.extensionUri, [ + "webview-ui", + "build", + "assets", + "index.css", + ]) // The JS file from the React build output - const scriptUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.js"]) + const scriptUri = getUri(webview, this.contextProxy.extensionUri, ["webview-ui", "build", "assets", "index.js"]) // The codicon font from the React build output // https://github.com/microsoft/vscode-extension-samples/blob/main/webview-codicons-sample/src/extension.ts // we installed this package in the extension so that we can access it how its intended from the extension (the font file is likely bundled in vscode), and we just import the css fileinto our react app we don't have access to it // don't forget to add font-src ${webview.cspSource}; - const codiconsUri = getUri(webview, this.context.extensionUri, [ + const codiconsUri = getUri(webview, this.contextProxy.extensionUri, [ "node_modules", "@vscode", "codicons", @@ -1249,7 +1265,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { // Try to get enhancement config first, fall back to current config let configToUse: ApiConfiguration = apiConfiguration if (enhancementApiConfigId) { - const config = listApiConfigMeta?.find((c) => c.id === enhancementApiConfigId) + const config = listApiConfigMeta?.find( + (c: ApiConfigMeta) => c.id === enhancementApiConfigId, + ) if (config?.name) { const loadedConfig = await this.configManager.loadConfig(config.name) if (loadedConfig.apiProvider) { @@ -1628,108 +1646,21 @@ export class ClineProvider implements vscode.WebviewViewProvider { } } - const { - apiProvider, - apiModelId, - apiKey, - glamaModelId, - glamaModelInfo, - glamaApiKey, - openRouterApiKey, - awsAccessKey, - awsSecretKey, - awsSessionToken, - awsRegion, - awsUseCrossRegionInference, - awsProfile, - awsUseProfile, - vertexProjectId, - vertexRegion, - openAiBaseUrl, - openAiApiKey, - openAiModelId, - openAiCustomModelInfo, - openAiUseAzure, - ollamaModelId, - ollamaBaseUrl, - lmStudioModelId, - lmStudioBaseUrl, - anthropicBaseUrl, - geminiApiKey, - openAiNativeApiKey, - deepSeekApiKey, - azureApiVersion, - openAiStreamingEnabled, - openRouterModelId, - openRouterBaseUrl, - openRouterModelInfo, - openRouterUseMiddleOutTransform, - vsCodeLmModelSelector, - mistralApiKey, - mistralCodestralUrl, - unboundApiKey, - unboundModelId, - unboundModelInfo, - requestyApiKey, - requestyModelId, - requestyModelInfo, - modelTemperature, - modelMaxTokens, - modelMaxThinkingTokens, - lmStudioDraftModelId, - lmStudioSpeculativeDecodingEnabled, - } = apiConfiguration - await Promise.all([ - this.updateGlobalState("apiProvider", apiProvider), - this.updateGlobalState("apiModelId", apiModelId), - this.storeSecret("apiKey", apiKey), - this.updateGlobalState("glamaModelId", glamaModelId), - this.updateGlobalState("glamaModelInfo", glamaModelInfo), - this.storeSecret("glamaApiKey", glamaApiKey), - this.storeSecret("openRouterApiKey", openRouterApiKey), - this.storeSecret("awsAccessKey", awsAccessKey), - this.storeSecret("awsSecretKey", awsSecretKey), - this.storeSecret("awsSessionToken", awsSessionToken), - this.updateGlobalState("awsRegion", awsRegion), - this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference), - this.updateGlobalState("awsProfile", awsProfile), - this.updateGlobalState("awsUseProfile", awsUseProfile), - this.updateGlobalState("vertexProjectId", vertexProjectId), - this.updateGlobalState("vertexRegion", vertexRegion), - this.updateGlobalState("openAiBaseUrl", openAiBaseUrl), - this.storeSecret("openAiApiKey", openAiApiKey), - this.updateGlobalState("openAiModelId", openAiModelId), - this.updateGlobalState("openAiCustomModelInfo", openAiCustomModelInfo), - this.updateGlobalState("openAiUseAzure", openAiUseAzure), - this.updateGlobalState("ollamaModelId", ollamaModelId), - this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl), - this.updateGlobalState("lmStudioModelId", lmStudioModelId), - this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl), - this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl), - this.storeSecret("geminiApiKey", geminiApiKey), - this.storeSecret("openAiNativeApiKey", openAiNativeApiKey), - this.storeSecret("deepSeekApiKey", deepSeekApiKey), - this.updateGlobalState("azureApiVersion", azureApiVersion), - this.updateGlobalState("openAiStreamingEnabled", openAiStreamingEnabled), - this.updateGlobalState("openRouterModelId", openRouterModelId), - this.updateGlobalState("openRouterModelInfo", openRouterModelInfo), - this.updateGlobalState("openRouterBaseUrl", openRouterBaseUrl), - this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform), - this.updateGlobalState("vsCodeLmModelSelector", vsCodeLmModelSelector), - this.storeSecret("mistralApiKey", mistralApiKey), - this.updateGlobalState("mistralCodestralUrl", mistralCodestralUrl), - this.storeSecret("unboundApiKey", unboundApiKey), - this.updateGlobalState("unboundModelId", unboundModelId), - this.updateGlobalState("unboundModelInfo", unboundModelInfo), - this.storeSecret("requestyApiKey", requestyApiKey), - this.updateGlobalState("requestyModelId", requestyModelId), - this.updateGlobalState("requestyModelInfo", requestyModelInfo), - this.updateGlobalState("modelTemperature", modelTemperature), - this.updateGlobalState("modelMaxTokens", modelMaxTokens), - this.updateGlobalState("anthropicThinking", modelMaxThinkingTokens), - this.updateGlobalState("lmStudioDraftModelId", lmStudioDraftModelId), - this.updateGlobalState("lmStudioSpeculativeDecodingEnabled", lmStudioSpeculativeDecodingEnabled), - ]) + // Create an array of promises to update state + const promises: Promise[] = [] + + // For each property in apiConfiguration, update the appropriate state + Object.entries(apiConfiguration).forEach(([key, value]) => { + // Check if this key is a secret + if (SECRET_KEYS.includes(key as SecretKey)) { + promises.push(this.storeSecret(key as SecretKey, value)) + } else { + promises.push(this.updateGlobalState(key as GlobalStateKey, value)) + } + }) + + await Promise.all(promises) + if (this.cline) { this.cline.api = buildApiHandler(apiConfiguration) } @@ -1790,13 +1721,13 @@ export class ClineProvider implements vscode.WebviewViewProvider { } async ensureSettingsDirectoryExists(): Promise { - const settingsDir = path.join(this.context.globalStorageUri.fsPath, "settings") + const settingsDir = path.join(this.contextProxy.globalStorageUri.fsPath, "settings") await fs.mkdir(settingsDir, { recursive: true }) return settingsDir } private async ensureCacheDirectoryExists() { - const cacheDir = path.join(this.context.globalStorageUri.fsPath, "cache") + const cacheDir = path.join(this.contextProxy.globalStorageUri.fsPath, "cache") await fs.mkdir(cacheDir, { recursive: true }) return cacheDir } @@ -1884,7 +1815,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { const history = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || [] const historyItem = history.find((item) => item.id === id) if (historyItem) { - const taskDirPath = path.join(this.context.globalStorageUri.fsPath, "tasks", id) + const taskDirPath = path.join(this.contextProxy.globalStorageUri.fsPath, "tasks", id) const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory) const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages) const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath) @@ -2049,7 +1980,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false, uriScheme: vscode.env.uriScheme, currentTaskItem: this.cline?.taskId - ? (taskHistory || []).find((item) => item.id === this.cline?.taskId) + ? (taskHistory || []).find((item: HistoryItem) => item.id === this.cline?.taskId) : undefined, clineMessages: this.cline?.clineMessages || [], taskHistory: (taskHistory || []) @@ -2140,189 +2071,41 @@ export class ClineProvider implements vscode.WebviewViewProvider { */ async getState() { - const [ - storedApiProvider, - apiModelId, - apiKey, - glamaApiKey, - glamaModelId, - glamaModelInfo, - openRouterApiKey, - awsAccessKey, - awsSecretKey, - awsSessionToken, - awsRegion, - awsUseCrossRegionInference, - awsProfile, - awsUseProfile, - vertexProjectId, - vertexRegion, - openAiBaseUrl, - openAiApiKey, - openAiModelId, - openAiCustomModelInfo, - openAiUseAzure, - ollamaModelId, - ollamaBaseUrl, - lmStudioModelId, - lmStudioBaseUrl, - anthropicBaseUrl, - geminiApiKey, - openAiNativeApiKey, - deepSeekApiKey, - mistralApiKey, - mistralCodestralUrl, - azureApiVersion, - openAiStreamingEnabled, - openRouterModelId, - openRouterModelInfo, - openRouterBaseUrl, - openRouterUseMiddleOutTransform, - lastShownAnnouncementId, - customInstructions, - alwaysAllowReadOnly, - alwaysAllowWrite, - alwaysAllowExecute, - alwaysAllowBrowser, - alwaysAllowMcp, - alwaysAllowModeSwitch, - taskHistory, - allowedCommands, - soundEnabled, - diffEnabled, - enableCheckpoints, - soundVolume, - browserViewportSize, - fuzzyMatchThreshold, - preferredLanguage, - writeDelayMs, - screenshotQuality, - terminalOutputLineLimit, - mcpEnabled, - enableMcpServerCreation, - alwaysApproveResubmit, - requestDelaySeconds, - rateLimitSeconds, - currentApiConfigName, - listApiConfigMeta, - vsCodeLmModelSelector, - mode, - modeApiConfigs, - customModePrompts, - customSupportPrompts, - enhancementApiConfigId, - autoApprovalEnabled, - customModes, - experiments, - unboundApiKey, - unboundModelId, - unboundModelInfo, - requestyApiKey, - requestyModelId, - requestyModelInfo, - modelTemperature, - modelMaxTokens, - modelMaxThinkingTokens, - maxOpenTabsContext, - browserToolEnabled, - lmStudioSpeculativeDecodingEnabled, - lmStudioDraftModelId, - ] = await Promise.all([ - this.getGlobalState("apiProvider") as Promise, - this.getGlobalState("apiModelId") as Promise, - this.getSecret("apiKey") as Promise, - this.getSecret("glamaApiKey") as Promise, - this.getGlobalState("glamaModelId") as Promise, - this.getGlobalState("glamaModelInfo") as Promise, - this.getSecret("openRouterApiKey") as Promise, - this.getSecret("awsAccessKey") as Promise, - this.getSecret("awsSecretKey") as Promise, - this.getSecret("awsSessionToken") as Promise, - this.getGlobalState("awsRegion") as Promise, - this.getGlobalState("awsUseCrossRegionInference") as Promise, - this.getGlobalState("awsProfile") as Promise, - this.getGlobalState("awsUseProfile") as Promise, - this.getGlobalState("vertexProjectId") as Promise, - this.getGlobalState("vertexRegion") as Promise, - this.getGlobalState("openAiBaseUrl") as Promise, - this.getSecret("openAiApiKey") as Promise, - this.getGlobalState("openAiModelId") as Promise, - this.getGlobalState("openAiCustomModelInfo") as Promise, - this.getGlobalState("openAiUseAzure") as Promise, - this.getGlobalState("ollamaModelId") as Promise, - this.getGlobalState("ollamaBaseUrl") as Promise, - this.getGlobalState("lmStudioModelId") as Promise, - this.getGlobalState("lmStudioBaseUrl") as Promise, - this.getGlobalState("anthropicBaseUrl") as Promise, - this.getSecret("geminiApiKey") as Promise, - this.getSecret("openAiNativeApiKey") as Promise, - this.getSecret("deepSeekApiKey") as Promise, - this.getSecret("mistralApiKey") as Promise, - this.getGlobalState("mistralCodestralUrl") as Promise, - this.getGlobalState("azureApiVersion") as Promise, - this.getGlobalState("openAiStreamingEnabled") as Promise, - this.getGlobalState("openRouterModelId") as Promise, - this.getGlobalState("openRouterModelInfo") as Promise, - this.getGlobalState("openRouterBaseUrl") as Promise, - this.getGlobalState("openRouterUseMiddleOutTransform") as Promise, - this.getGlobalState("lastShownAnnouncementId") as Promise, - this.getGlobalState("customInstructions") as Promise, - this.getGlobalState("alwaysAllowReadOnly") as Promise, - this.getGlobalState("alwaysAllowWrite") as Promise, - this.getGlobalState("alwaysAllowExecute") as Promise, - this.getGlobalState("alwaysAllowBrowser") as Promise, - this.getGlobalState("alwaysAllowMcp") as Promise, - this.getGlobalState("alwaysAllowModeSwitch") as Promise, - this.getGlobalState("taskHistory") as Promise, - this.getGlobalState("allowedCommands") as Promise, - this.getGlobalState("soundEnabled") as Promise, - this.getGlobalState("diffEnabled") as Promise, - this.getGlobalState("enableCheckpoints") as Promise, - this.getGlobalState("soundVolume") as Promise, - this.getGlobalState("browserViewportSize") as Promise, - this.getGlobalState("fuzzyMatchThreshold") as Promise, - this.getGlobalState("preferredLanguage") as Promise, - this.getGlobalState("writeDelayMs") as Promise, - this.getGlobalState("screenshotQuality") as Promise, - this.getGlobalState("terminalOutputLineLimit") as Promise, - this.getGlobalState("mcpEnabled") as Promise, - this.getGlobalState("enableMcpServerCreation") as Promise, - this.getGlobalState("alwaysApproveResubmit") as Promise, - this.getGlobalState("requestDelaySeconds") as Promise, - this.getGlobalState("rateLimitSeconds") as Promise, - this.getGlobalState("currentApiConfigName") as Promise, - this.getGlobalState("listApiConfigMeta") as Promise, - this.getGlobalState("vsCodeLmModelSelector") as Promise, - this.getGlobalState("mode") as Promise, - this.getGlobalState("modeApiConfigs") as Promise | undefined>, - this.getGlobalState("customModePrompts") as Promise, - this.getGlobalState("customSupportPrompts") as Promise, - this.getGlobalState("enhancementApiConfigId") as Promise, - this.getGlobalState("autoApprovalEnabled") as Promise, - this.customModesManager.getCustomModes(), - this.getGlobalState("experiments") as Promise | undefined>, - this.getSecret("unboundApiKey") as Promise, - this.getGlobalState("unboundModelId") as Promise, - this.getGlobalState("unboundModelInfo") as Promise, - this.getSecret("requestyApiKey") as Promise, - this.getGlobalState("requestyModelId") as Promise, - this.getGlobalState("requestyModelInfo") as Promise, - this.getGlobalState("modelTemperature") as Promise, - this.getGlobalState("modelMaxTokens") as Promise, - this.getGlobalState("anthropicThinking") as Promise, - this.getGlobalState("maxOpenTabsContext") as Promise, - this.getGlobalState("browserToolEnabled") as Promise, - this.getGlobalState("lmStudioSpeculativeDecodingEnabled") as Promise, - this.getGlobalState("lmStudioDraftModelId") as Promise, + // Create an object to store all fetched values + const stateValues: Record = {} as Record + const secretValues: Record = {} as Record + + // Create promise arrays for global state and secrets + const statePromises = GLOBAL_STATE_KEYS.map((key) => this.getGlobalState(key)) + const secretPromises = SECRET_KEYS.map((key) => this.getSecret(key)) + + // Add promise for custom modes which is handled separately + const customModesPromise = this.customModesManager.getCustomModes() + + // Wait for all promises to resolve + const [stateResults, secretResults, customModes] = await Promise.all([ + Promise.all(statePromises), + Promise.all(secretPromises), + customModesPromise, ]) + // Populate stateValues and secretValues + GLOBAL_STATE_KEYS.forEach((key, index) => { + stateValues[key] = stateResults[index] + }) + + SECRET_KEYS.forEach((key, index) => { + secretValues[key] = secretResults[index] + }) + + // Determine apiProvider with the same logic as before let apiProvider: ApiProvider - if (storedApiProvider) { - apiProvider = storedApiProvider + if (stateValues.apiProvider) { + apiProvider = stateValues.apiProvider } else { // Either new user or legacy user that doesn't have the apiProvider stored in state // (If they're using OpenRouter or Bedrock, then apiProvider state will exist) - if (apiKey) { + if (secretValues.apiKey) { apiProvider = "anthropic" } else { // New users should default to openrouter @@ -2330,80 +2113,73 @@ export class ClineProvider implements vscode.WebviewViewProvider { } } + // Build the apiConfiguration object combining state values and secrets + const apiConfiguration: ApiConfiguration = { + apiProvider, + apiModelId: stateValues.apiModelId, + glamaModelId: stateValues.glamaModelId, + glamaModelInfo: stateValues.glamaModelInfo, + awsRegion: stateValues.awsRegion, + awsUseCrossRegionInference: stateValues.awsUseCrossRegionInference, + awsProfile: stateValues.awsProfile, + awsUseProfile: stateValues.awsUseProfile, + vertexProjectId: stateValues.vertexProjectId, + vertexRegion: stateValues.vertexRegion, + openAiBaseUrl: stateValues.openAiBaseUrl, + openAiModelId: stateValues.openAiModelId, + openAiCustomModelInfo: stateValues.openAiCustomModelInfo, + openAiUseAzure: stateValues.openAiUseAzure, + ollamaModelId: stateValues.ollamaModelId, + ollamaBaseUrl: stateValues.ollamaBaseUrl, + lmStudioModelId: stateValues.lmStudioModelId, + lmStudioBaseUrl: stateValues.lmStudioBaseUrl, + anthropicBaseUrl: stateValues.anthropicBaseUrl, + modelMaxThinkingTokens: stateValues.modelMaxThinkingTokens, + mistralCodestralUrl: stateValues.mistralCodestralUrl, + azureApiVersion: stateValues.azureApiVersion, + openAiStreamingEnabled: stateValues.openAiStreamingEnabled, + openRouterModelId: stateValues.openRouterModelId, + openRouterModelInfo: stateValues.openRouterModelInfo, + openRouterBaseUrl: stateValues.openRouterBaseUrl, + openRouterUseMiddleOutTransform: stateValues.openRouterUseMiddleOutTransform, + vsCodeLmModelSelector: stateValues.vsCodeLmModelSelector, + unboundModelId: stateValues.unboundModelId, + unboundModelInfo: stateValues.unboundModelInfo, + requestyModelId: stateValues.requestyModelId, + requestyModelInfo: stateValues.requestyModelInfo, + modelTemperature: stateValues.modelTemperature, + modelMaxTokens: stateValues.modelMaxTokens, + lmStudioSpeculativeDecodingEnabled: stateValues.lmStudioSpeculativeDecodingEnabled, + lmStudioDraftModelId: stateValues.lmStudioDraftModelId, + // Add all secrets + ...secretValues, + } + + // Return the same structure as before return { - apiConfiguration: { - apiProvider, - apiModelId, - apiKey, - glamaApiKey, - glamaModelId, - glamaModelInfo, - openRouterApiKey, - awsAccessKey, - awsSecretKey, - awsSessionToken, - awsRegion, - awsUseCrossRegionInference, - awsProfile, - awsUseProfile, - vertexProjectId, - vertexRegion, - openAiBaseUrl, - openAiApiKey, - openAiModelId, - openAiCustomModelInfo, - openAiUseAzure, - ollamaModelId, - ollamaBaseUrl, - lmStudioModelId, - lmStudioBaseUrl, - anthropicBaseUrl, - geminiApiKey, - openAiNativeApiKey, - deepSeekApiKey, - mistralApiKey, - mistralCodestralUrl, - azureApiVersion, - openAiStreamingEnabled, - openRouterModelId, - openRouterModelInfo, - openRouterBaseUrl, - openRouterUseMiddleOutTransform, - vsCodeLmModelSelector, - unboundApiKey, - unboundModelId, - unboundModelInfo, - requestyApiKey, - requestyModelId, - requestyModelInfo, - modelTemperature, - modelMaxTokens, - modelMaxThinkingTokens, - lmStudioSpeculativeDecodingEnabled, - lmStudioDraftModelId, - }, - lastShownAnnouncementId, - customInstructions, - alwaysAllowReadOnly: alwaysAllowReadOnly ?? false, - alwaysAllowWrite: alwaysAllowWrite ?? false, - alwaysAllowExecute: alwaysAllowExecute ?? false, - alwaysAllowBrowser: alwaysAllowBrowser ?? false, - alwaysAllowMcp: alwaysAllowMcp ?? false, - alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false, - taskHistory, - allowedCommands, - soundEnabled: soundEnabled ?? false, - diffEnabled: diffEnabled ?? true, - enableCheckpoints: enableCheckpoints ?? true, - soundVolume, - browserViewportSize: browserViewportSize ?? "900x600", - screenshotQuality: screenshotQuality ?? 75, - fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0, - writeDelayMs: writeDelayMs ?? 1000, - terminalOutputLineLimit: terminalOutputLineLimit ?? 500, - mode: mode ?? defaultModeSlug, + apiConfiguration, + lastShownAnnouncementId: stateValues.lastShownAnnouncementId, + customInstructions: stateValues.customInstructions, + alwaysAllowReadOnly: stateValues.alwaysAllowReadOnly ?? false, + alwaysAllowWrite: stateValues.alwaysAllowWrite ?? false, + alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false, + alwaysAllowBrowser: stateValues.alwaysAllowBrowser ?? false, + alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false, + alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false, + taskHistory: stateValues.taskHistory, + allowedCommands: stateValues.allowedCommands, + soundEnabled: stateValues.soundEnabled ?? false, + diffEnabled: stateValues.diffEnabled ?? true, + enableCheckpoints: stateValues.enableCheckpoints ?? false, + soundVolume: stateValues.soundVolume, + browserViewportSize: stateValues.browserViewportSize ?? "900x600", + screenshotQuality: stateValues.screenshotQuality ?? 75, + fuzzyMatchThreshold: stateValues.fuzzyMatchThreshold ?? 1.0, + writeDelayMs: stateValues.writeDelayMs ?? 1000, + terminalOutputLineLimit: stateValues.terminalOutputLineLimit ?? 500, + mode: stateValues.mode ?? defaultModeSlug, preferredLanguage: - preferredLanguage ?? + stateValues.preferredLanguage ?? (() => { // Get VSCode's locale setting const vscodeLang = vscode.env.language @@ -2433,23 +2209,23 @@ export class ClineProvider implements vscode.WebviewViewProvider { // Return mapped language or default to English return langMap[vscodeLang] ?? langMap[vscodeLang.split("-")[0]] ?? "English" })(), - mcpEnabled: mcpEnabled ?? true, - enableMcpServerCreation: enableMcpServerCreation ?? true, - alwaysApproveResubmit: alwaysApproveResubmit ?? false, - requestDelaySeconds: Math.max(5, requestDelaySeconds ?? 10), - rateLimitSeconds: rateLimitSeconds ?? 0, - currentApiConfigName: currentApiConfigName ?? "default", - listApiConfigMeta: listApiConfigMeta ?? [], - modeApiConfigs: modeApiConfigs ?? ({} as Record), - customModePrompts: customModePrompts ?? {}, - customSupportPrompts: customSupportPrompts ?? {}, - enhancementApiConfigId, - experiments: experiments ?? experimentDefault, - autoApprovalEnabled: autoApprovalEnabled ?? false, + mcpEnabled: stateValues.mcpEnabled ?? true, + enableMcpServerCreation: stateValues.enableMcpServerCreation ?? true, + alwaysApproveResubmit: stateValues.alwaysApproveResubmit ?? false, + requestDelaySeconds: Math.max(5, stateValues.requestDelaySeconds ?? 10), + rateLimitSeconds: stateValues.rateLimitSeconds ?? 0, + currentApiConfigName: stateValues.currentApiConfigName ?? "default", + listApiConfigMeta: stateValues.listApiConfigMeta ?? [], + modeApiConfigs: stateValues.modeApiConfigs ?? ({} as Record), + customModePrompts: stateValues.customModePrompts ?? {}, + customSupportPrompts: stateValues.customSupportPrompts ?? {}, + enhancementApiConfigId: stateValues.enhancementApiConfigId, + experiments: stateValues.experiments ?? experimentDefault, + autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false, customModes, - maxOpenTabsContext: maxOpenTabsContext ?? 20, - openRouterUseMiddleOutTransform: openRouterUseMiddleOutTransform ?? true, - browserToolEnabled: browserToolEnabled ?? true, + maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20, + openRouterUseMiddleOutTransform: stateValues.openRouterUseMiddleOutTransform ?? true, + browserToolEnabled: stateValues.browserToolEnabled ?? true, } } @@ -2469,25 +2245,29 @@ export class ClineProvider implements vscode.WebviewViewProvider { // global async updateGlobalState(key: GlobalStateKey, value: any) { - await this.context.globalState.update(key, value) + this.outputChannel.appendLine(`Updating global state: ${key}`) + await this.contextProxy.updateGlobalState(key, value) + + // // If we have a lot of pending changes, consider saving them periodically + // if (this.contextProxy.hasPendingChanges() && Math.random() < 0.1) { // 10% chance to save changes + // this.outputChannel.appendLine("Periodically flushing context state changes") + // await this.contextProxy.saveChanges() + // } } async getGlobalState(key: GlobalStateKey) { - return await this.context.globalState.get(key) + return await this.contextProxy.getGlobalState(key) } // secrets public async storeSecret(key: SecretKey, value?: string) { - if (value) { - await this.context.secrets.store(key, value) - } else { - await this.context.secrets.delete(key) - } + this.outputChannel.appendLine(`Storing secret: ${key}`) + await this.contextProxy.storeSecret(key, value) } private async getSecret(key: SecretKey) { - return await this.context.secrets.get(key) + return await this.contextProxy.getSecret(key) } // dev @@ -2504,24 +2284,11 @@ export class ClineProvider implements vscode.WebviewViewProvider { } for (const key of this.context.globalState.keys()) { - await this.context.globalState.update(key, undefined) + // Still using original context for listing keys + await this.contextProxy.updateGlobalState(key, undefined) } - const secretKeys: SecretKey[] = [ - "apiKey", - "glamaApiKey", - "openRouterApiKey", - "awsAccessKey", - "awsSecretKey", - "awsSessionToken", - "openAiApiKey", - "geminiApiKey", - "openAiNativeApiKey", - "deepSeekApiKey", - "mistralApiKey", - "unboundApiKey", - "requestyApiKey", - ] - for (const key of secretKeys) { + + for (const key of SECRET_KEYS) { await this.storeSecret(key, undefined) } await this.configManager.resetAllConfigs() diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 154c24bc27..20778b8802 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -5,6 +5,7 @@ import axios from "axios" import { ClineProvider } from "../ClineProvider" import { ExtensionMessage, ExtensionState } from "../../../shared/ExtensionMessage" +import { GlobalStateKey, SecretKey } from "../../../shared/globalState" import { setSoundEnabled } from "../../../utils/sound" import { defaultModeSlug } from "../../../shared/modes" import { experimentDefault } from "../../../shared/experiments" @@ -12,6 +13,34 @@ import { experimentDefault } from "../../../shared/experiments" // Mock setup must come before imports jest.mock("../../prompts/sections/custom-instructions") +// Mock ContextProxy +jest.mock("../../contextProxy", () => { + return { + ContextProxy: jest.fn().mockImplementation((context) => ({ + originalContext: context, + extensionUri: context.extensionUri, + extensionPath: context.extensionPath, + globalStorageUri: context.globalStorageUri, + logUri: context.logUri, + extension: context.extension, + extensionMode: context.extensionMode, + getGlobalState: jest + .fn() + .mockImplementation((key, defaultValue) => context.globalState.get(key, defaultValue)), + updateGlobalState: jest.fn().mockImplementation((key, value) => context.globalState.update(key, value)), + getSecret: jest.fn().mockImplementation((key) => context.secrets.get(key)), + storeSecret: jest + .fn() + .mockImplementation((key, value) => + value ? context.secrets.store(key, value) : context.secrets.delete(key), + ), + saveChanges: jest.fn().mockResolvedValue(undefined), + dispose: jest.fn().mockResolvedValue(undefined), + hasPendingChanges: jest.fn().mockReturnValue(false), + })), + } +}) + // Mock dependencies jest.mock("vscode") jest.mock("delay") @@ -153,6 +182,16 @@ jest.mock("../../../utils/sound", () => ({ setSoundEnabled: jest.fn(), })) +// Mock logger +jest.mock("../../../utils/logging", () => ({ + logger: { + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }, +})) + // Mock ESM modules jest.mock("p-wait-for", () => ({ __esModule: true, @@ -235,6 +274,12 @@ describe("ClineProvider", () => { let mockOutputChannel: vscode.OutputChannel let mockWebviewView: vscode.WebviewView let mockPostMessage: jest.Mock + let mockContextProxy: { + updateGlobalState: jest.Mock + getGlobalState: jest.Mock + storeSecret: jest.Mock + dispose: jest.Mock + } beforeEach(() => { // Reset mocks @@ -307,6 +352,8 @@ describe("ClineProvider", () => { } as unknown as vscode.WebviewView provider = new ClineProvider(mockContext, mockOutputChannel) + // @ts-ignore - Access private property for testing + mockContextProxy = provider.contextProxy // @ts-ignore - Accessing private property for testing. provider.customModesManager = mockCustomModesManager @@ -478,6 +525,7 @@ describe("ClineProvider", () => { await messageHandler({ type: "writeDelayMs", value: 2000 }) + expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith("writeDelayMs", 2000) expect(mockContext.globalState.update).toHaveBeenCalledWith("writeDelayMs", 2000) expect(mockPostMessage).toHaveBeenCalled() }) @@ -491,6 +539,7 @@ describe("ClineProvider", () => { // Simulate setting sound to enabled await messageHandler({ type: "soundEnabled", bool: true }) expect(setSoundEnabled).toHaveBeenCalledWith(true) + expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith("soundEnabled", true) expect(mockContext.globalState.update).toHaveBeenCalledWith("soundEnabled", true) expect(mockPostMessage).toHaveBeenCalled() @@ -613,6 +662,7 @@ describe("ClineProvider", () => { // Test alwaysApproveResubmit await messageHandler({ type: "alwaysApproveResubmit", bool: true }) + expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith("alwaysApproveResubmit", true) expect(mockContext.globalState.update).toHaveBeenCalledWith("alwaysApproveResubmit", true) expect(mockPostMessage).toHaveBeenCalled() @@ -1253,6 +1303,17 @@ describe("ClineProvider", () => { // Verify state was posted to webview expect(mockPostMessage).toHaveBeenCalledWith(expect.objectContaining({ type: "state" })) }) + + test("disposes the contextProxy when provider is disposed", async () => { + // Setup mock Cline instance + const mockCline = { + abortTask: jest.fn(), + } + // @ts-ignore - accessing private property for testing + provider.cline = mockCline + await provider.dispose() + expect(mockContextProxy.dispose).toHaveBeenCalled() + }) }) describe("updateCustomMode", () => { @@ -1474,6 +1535,7 @@ describe("ClineProvider", () => { apiConfiguration: testApiConfig, }) + // Reset jest.mock calls tracking // Verify config was saved expect(provider.configManager.saveConfig).toHaveBeenCalledWith("test-config", testApiConfig) @@ -1481,6 +1543,74 @@ describe("ClineProvider", () => { expect(mockContext.globalState.update).toHaveBeenCalledWith("listApiConfigMeta", [ { name: "test-config", id: "test-id", apiProvider: "anthropic" }, ]) + expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith("listApiConfigMeta", [ + { name: "test-config", id: "test-id", apiProvider: "anthropic" }, + ]) + + // Reset jest.mock calls tracking for subsequent tests + jest.clearAllMocks() }) }) }) + +describe("ContextProxy integration", () => { + let provider: ClineProvider + let mockContext: vscode.ExtensionContext + let mockOutputChannel: vscode.OutputChannel + let mockContextProxy: any + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks() + + // Setup basic mocks + mockContext = { + globalState: { get: jest.fn(), update: jest.fn(), keys: jest.fn().mockReturnValue([]) }, + secrets: { get: jest.fn(), store: jest.fn(), delete: jest.fn() }, + extensionUri: {} as vscode.Uri, + globalStorageUri: { fsPath: "/test/path" }, + extension: { packageJSON: { version: "1.0.0" } }, + } as unknown as vscode.ExtensionContext + + mockOutputChannel = { appendLine: jest.fn() } as unknown as vscode.OutputChannel + provider = new ClineProvider(mockContext, mockOutputChannel) + + // @ts-ignore - accessing private property for testing + mockContextProxy = provider.contextProxy + }) + + test("updateGlobalState uses contextProxy", async () => { + await provider.updateGlobalState("currentApiConfigName" as GlobalStateKey, "testValue") + expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith("currentApiConfigName", "testValue") + }) + + test("getGlobalState uses contextProxy", async () => { + mockContextProxy.getGlobalState.mockResolvedValueOnce("testValue") + const result = await provider.getGlobalState("currentApiConfigName" as GlobalStateKey) + expect(mockContextProxy.getGlobalState).toHaveBeenCalledWith("currentApiConfigName") + expect(result).toBe("testValue") + }) + + test("storeSecret uses contextProxy", async () => { + await provider.storeSecret("apiKey" as SecretKey, "test-secret") + expect(mockContextProxy.storeSecret).toHaveBeenCalledWith("apiKey", "test-secret") + }) + + test("contextProxy methods are available", () => { + // Verify the contextProxy has all the required methods + expect(mockContextProxy.getGlobalState).toBeDefined() + expect(mockContextProxy.updateGlobalState).toBeDefined() + expect(mockContextProxy.storeSecret).toBeDefined() + }) + + test("contextProxy is properly disposed", async () => { + // Setup mock Cline instance + const mockCline = { + abortTask: jest.fn(), + } + // @ts-ignore - accessing private property for testing + provider.cline = mockCline + await provider.dispose() + expect(mockContextProxy.dispose).toHaveBeenCalled() + }) +}) diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index 88f9824151..1f36732466 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -13,6 +13,22 @@ export type SecretKey = | "unboundApiKey" | "requestyApiKey" +export const SECRET_KEYS: SecretKey[] = [ + "apiKey", + "glamaApiKey", + "openRouterApiKey", + "awsAccessKey", + "awsSecretKey", + "awsSessionToken", + "openAiApiKey", + "geminiApiKey", + "openAiNativeApiKey", + "deepSeekApiKey", + "mistralApiKey", + "unboundApiKey", + "requestyApiKey", +] + export type GlobalStateKey = | "apiProvider" | "apiModelId" @@ -83,7 +99,80 @@ export type GlobalStateKey = | "unboundModelInfo" | "modelTemperature" | "modelMaxTokens" - | "anthropicThinking" // TODO: Rename to `modelMaxThinkingTokens`. + | "modelMaxThinkingTokens" | "mistralCodestralUrl" | "maxOpenTabsContext" | "browserToolEnabled" // Setting to enable/disable the browser tool + +export const GLOBAL_STATE_KEYS: GlobalStateKey[] = [ + "apiProvider", + "apiModelId", + "glamaModelId", + "glamaModelInfo", + "awsRegion", + "awsUseCrossRegionInference", + "awsProfile", + "awsUseProfile", + "vertexProjectId", + "vertexRegion", + "lastShownAnnouncementId", + "customInstructions", + "alwaysAllowReadOnly", + "alwaysAllowWrite", + "alwaysAllowExecute", + "alwaysAllowBrowser", + "alwaysAllowMcp", + "alwaysAllowModeSwitch", + "taskHistory", + "openAiBaseUrl", + "openAiModelId", + "openAiCustomModelInfo", + "openAiUseAzure", + "ollamaModelId", + "ollamaBaseUrl", + "lmStudioModelId", + "lmStudioBaseUrl", + "anthropicBaseUrl", + "modelMaxThinkingTokens", + "azureApiVersion", + "openAiStreamingEnabled", + "openRouterModelId", + "openRouterModelInfo", + "openRouterBaseUrl", + "openRouterUseMiddleOutTransform", + "allowedCommands", + "soundEnabled", + "soundVolume", + "diffEnabled", + "enableCheckpoints", + "browserViewportSize", + "screenshotQuality", + "fuzzyMatchThreshold", + "preferredLanguage", // Language setting for Cline's communication + "writeDelayMs", + "terminalOutputLineLimit", + "mcpEnabled", + "enableMcpServerCreation", + "alwaysApproveResubmit", + "requestDelaySeconds", + "rateLimitSeconds", + "currentApiConfigName", + "listApiConfigMeta", + "vsCodeLmModelSelector", + "mode", + "modeApiConfigs", + "customModePrompts", + "customSupportPrompts", + "enhancementApiConfigId", + "experiments", // Map of experiment IDs to their enabled state + "autoApprovalEnabled", + "customModes", // Array of custom modes + "unboundModelId", + "requestyModelId", + "requestyModelInfo", + "unboundModelInfo", + "modelTemperature", + "modelMaxTokens", + "mistralCodestralUrl", + "maxOpenTabsContext", +] From 167229fa7365e3f1a0eff638209902d394ae84e6 Mon Sep 17 00:00:00 2001 From: sam hoang Date: Thu, 27 Feb 2025 15:47:24 +0700 Subject: [PATCH 03/14] Refactor checkExistKey to use centralized SECRET_KEYS array --- src/shared/checkExistApiConfig.ts | 35 ++++++++++++++----------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/src/shared/checkExistApiConfig.ts b/src/shared/checkExistApiConfig.ts index 0570f6118a..c141a153d2 100644 --- a/src/shared/checkExistApiConfig.ts +++ b/src/shared/checkExistApiConfig.ts @@ -1,23 +1,20 @@ import { ApiConfiguration } from "../shared/api" +import { SECRET_KEYS } from "./globalState" export function checkExistKey(config: ApiConfiguration | undefined) { - return config - ? [ - config.apiKey, - config.glamaApiKey, - config.openRouterApiKey, - config.awsRegion, - config.vertexProjectId, - config.openAiApiKey, - config.ollamaModelId, - config.lmStudioModelId, - config.geminiApiKey, - config.openAiNativeApiKey, - config.deepSeekApiKey, - config.mistralApiKey, - config.vsCodeLmModelSelector, - config.requestyApiKey, - config.unboundApiKey, - ].some((key) => key !== undefined) - : false + if (!config) return false + + // Check all secret keys from the centralized SECRET_KEYS array + const hasSecretKey = SECRET_KEYS.some((key) => config[key as keyof ApiConfiguration] !== undefined) + + // Check additional non-secret configuration properties + const hasOtherConfig = [ + config.awsRegion, + config.vertexProjectId, + config.ollamaModelId, + config.lmStudioModelId, + config.vsCodeLmModelSelector, + ].some((value) => value !== undefined) + + return hasSecretKey || hasOtherConfig } From b4094a628168682df58a185021b139c50293b4a1 Mon Sep 17 00:00:00 2001 From: sam hoang Date: Tue, 4 Mar 2025 20:26:22 +0700 Subject: [PATCH 04/14] refactor by pr comment --- src/core/webview/ClineProvider.ts | 46 ++++++------------------------- src/shared/api.ts | 43 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 37 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index d9a1525730..ae53e58df9 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -8,7 +8,7 @@ import * as path from "path" import * as vscode from "vscode" import simpleGit from "simple-git" -import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api" +import { ApiConfiguration, ApiProvider, ModelInfo, API_CONFIG_KEYS } from "../../shared/api" import { findLast } from "../../shared/array" import { CustomSupportPrompts, supportPrompt } from "../../shared/support-prompt" import { GlobalFileNames } from "../../shared/globalFileNames" @@ -2114,47 +2114,19 @@ export class ClineProvider implements vscode.WebviewViewProvider { } // Build the apiConfiguration object combining state values and secrets + // Using the dynamic approach with API_CONFIG_KEYS const apiConfiguration: ApiConfiguration = { - apiProvider, - apiModelId: stateValues.apiModelId, - glamaModelId: stateValues.glamaModelId, - glamaModelInfo: stateValues.glamaModelInfo, - awsRegion: stateValues.awsRegion, - awsUseCrossRegionInference: stateValues.awsUseCrossRegionInference, - awsProfile: stateValues.awsProfile, - awsUseProfile: stateValues.awsUseProfile, - vertexProjectId: stateValues.vertexProjectId, - vertexRegion: stateValues.vertexRegion, - openAiBaseUrl: stateValues.openAiBaseUrl, - openAiModelId: stateValues.openAiModelId, - openAiCustomModelInfo: stateValues.openAiCustomModelInfo, - openAiUseAzure: stateValues.openAiUseAzure, - ollamaModelId: stateValues.ollamaModelId, - ollamaBaseUrl: stateValues.ollamaBaseUrl, - lmStudioModelId: stateValues.lmStudioModelId, - lmStudioBaseUrl: stateValues.lmStudioBaseUrl, - anthropicBaseUrl: stateValues.anthropicBaseUrl, - modelMaxThinkingTokens: stateValues.modelMaxThinkingTokens, - mistralCodestralUrl: stateValues.mistralCodestralUrl, - azureApiVersion: stateValues.azureApiVersion, - openAiStreamingEnabled: stateValues.openAiStreamingEnabled, - openRouterModelId: stateValues.openRouterModelId, - openRouterModelInfo: stateValues.openRouterModelInfo, - openRouterBaseUrl: stateValues.openRouterBaseUrl, - openRouterUseMiddleOutTransform: stateValues.openRouterUseMiddleOutTransform, - vsCodeLmModelSelector: stateValues.vsCodeLmModelSelector, - unboundModelId: stateValues.unboundModelId, - unboundModelInfo: stateValues.unboundModelInfo, - requestyModelId: stateValues.requestyModelId, - requestyModelInfo: stateValues.requestyModelInfo, - modelTemperature: stateValues.modelTemperature, - modelMaxTokens: stateValues.modelMaxTokens, - lmStudioSpeculativeDecodingEnabled: stateValues.lmStudioSpeculativeDecodingEnabled, - lmStudioDraftModelId: stateValues.lmStudioDraftModelId, + // Dynamically add all API-related keys from stateValues + ...Object.fromEntries(API_CONFIG_KEYS.map((key) => [key, stateValues[key]])), // Add all secrets ...secretValues, } + // Ensure apiProvider is set properly if not already in state + if (!apiConfiguration.apiProvider) { + apiConfiguration.apiProvider = apiProvider + } + // Return the same structure as before return { apiConfiguration, diff --git a/src/shared/api.ts b/src/shared/api.ts index 2ce7162640..9709ba79fc 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -78,6 +78,49 @@ export type ApiConfiguration = ApiHandlerOptions & { id?: string // stable unique identifier } +// Import GlobalStateKey type from globalState.ts +import { GlobalStateKey } from "./globalState" + +// Define API configuration keys for dynamic object building +export const API_CONFIG_KEYS: GlobalStateKey[] = [ + "apiProvider", + "apiModelId", + "glamaModelId", + "glamaModelInfo", + "awsRegion", + "awsUseCrossRegionInference", + "awsProfile", + "awsUseProfile", + "vertexProjectId", + "vertexRegion", + "openAiBaseUrl", + "openAiModelId", + "openAiCustomModelInfo", + "openAiUseAzure", + "ollamaModelId", + "ollamaBaseUrl", + "lmStudioModelId", + "lmStudioBaseUrl", + "anthropicBaseUrl", + "modelMaxThinkingTokens", + "mistralCodestralUrl", + "azureApiVersion", + "openAiStreamingEnabled", + "openRouterModelId", + "openRouterModelInfo", + "openRouterBaseUrl", + "openRouterUseMiddleOutTransform", + "vsCodeLmModelSelector", + "unboundModelId", + "unboundModelInfo", + "requestyModelId", + "requestyModelInfo", + "modelTemperature", + "modelMaxTokens", + "lmStudioSpeculativeDecodingEnabled", + "lmStudioDraftModelId" +] + // Models export interface ModelInfo { From 0a0634488a6e688efe5a9d2ff3c9bdeb65428c5e Mon Sep 17 00:00:00 2001 From: sam hoang Date: Tue, 4 Mar 2025 22:34:13 +0700 Subject: [PATCH 05/14] update api config key list to match with api key and global state key --- src/shared/api.ts | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/shared/api.ts b/src/shared/api.ts index 9709ba79fc..7c5c65fe90 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -58,7 +58,6 @@ export interface ApiHandlerOptions { azureApiVersion?: string openRouterUseMiddleOutTransform?: boolean openAiStreamingEnabled?: boolean - setAzureApiVersion?: boolean deepSeekBaseUrl?: string deepSeekApiKey?: string includeMaxTokens?: boolean @@ -83,12 +82,18 @@ import { GlobalStateKey } from "./globalState" // Define API configuration keys for dynamic object building export const API_CONFIG_KEYS: GlobalStateKey[] = [ - "apiProvider", "apiModelId", + "anthropicBaseUrl", + "vsCodeLmModelSelector", "glamaModelId", "glamaModelInfo", + "openRouterModelId", + "openRouterModelInfo", + "openRouterBaseUrl", "awsRegion", "awsUseCrossRegionInference", + // "awsUsePromptCache", // NOT exist on GlobalStateKey + // "awspromptCacheId", // NOT exist on GlobalStateKey "awsProfile", "awsUseProfile", "vertexProjectId", @@ -101,24 +106,21 @@ export const API_CONFIG_KEYS: GlobalStateKey[] = [ "ollamaBaseUrl", "lmStudioModelId", "lmStudioBaseUrl", - "anthropicBaseUrl", - "modelMaxThinkingTokens", - "mistralCodestralUrl", + "lmStudioDraftModelId", + "lmStudioSpeculativeDecodingEnabled", + "mistralCodestralUrl", // New option for Codestral URL "azureApiVersion", - "openAiStreamingEnabled", - "openRouterModelId", - "openRouterModelInfo", - "openRouterBaseUrl", "openRouterUseMiddleOutTransform", - "vsCodeLmModelSelector", + "openAiStreamingEnabled", + // "deepSeekBaseUrl", // not exist on GlobalStateKey + // "includeMaxTokens", // not exist on GlobalStateKey "unboundModelId", "unboundModelInfo", "requestyModelId", "requestyModelInfo", "modelTemperature", "modelMaxTokens", - "lmStudioSpeculativeDecodingEnabled", - "lmStudioDraftModelId" + "modelMaxThinkingTokens", ] // Models From 9bbd902d5d52e078b390085240f3fa2b932ca070 Mon Sep 17 00:00:00 2001 From: sam hoang Date: Wed, 5 Mar 2025 00:06:30 +0700 Subject: [PATCH 06/14] update new way to manage state --- src/core/__tests__/contextProxy.test.ts | 217 +++++------------- src/core/contextProxy.ts | 141 +++++------- src/core/webview/ClineProvider.ts | 30 ++- .../webview/__tests__/ClineProvider.test.ts | 22 -- src/shared/api.ts | 2 +- 5 files changed, 125 insertions(+), 287 deletions(-) diff --git a/src/core/__tests__/contextProxy.test.ts b/src/core/__tests__/contextProxy.test.ts index 794cd91497..e6f1bfc9ca 100644 --- a/src/core/__tests__/contextProxy.test.ts +++ b/src/core/__tests__/contextProxy.test.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode" import { ContextProxy } from "../contextProxy" import { logger } from "../../utils/logging" +import { GLOBAL_STATE_KEYS, SECRET_KEYS } from "../../shared/globalState" // Mock the logger jest.mock("../../utils/logging", () => ({ @@ -12,6 +13,12 @@ jest.mock("../../utils/logging", () => ({ }, })) +// Mock shared/globalState +jest.mock("../../shared/globalState", () => ({ + GLOBAL_STATE_KEYS: ["apiProvider", "apiModelId", "mode"], + SECRET_KEYS: ["apiKey", "openAiApiKey"], +})) + // Mock VSCode API jest.mock("vscode", () => ({ Uri: { @@ -42,7 +49,7 @@ describe("ContextProxy", () => { // Mock secrets mockSecrets = { - get: jest.fn(), + get: jest.fn().mockResolvedValue("test-secret"), store: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), } @@ -74,98 +81,80 @@ describe("ContextProxy", () => { }) }) - describe("getGlobalState", () => { - it("should return pending change when it exists", async () => { - // Set up a pending change - await proxy.updateGlobalState("test-key", "new-value") - - // Should return the pending value - const result = await proxy.getGlobalState("test-key") - expect(result).toBe("new-value") - - // Original context should not be called - expect(mockGlobalState.get).not.toHaveBeenCalled() + describe("constructor", () => { + it("should initialize state cache with all global state keys", () => { + expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length) + for (const key of GLOBAL_STATE_KEYS) { + expect(mockGlobalState.get).toHaveBeenCalledWith(key) + } }) - it("should fall back to original context when no pending change exists", async () => { - // Set up original context value - mockGlobalState.get.mockReturnValue("original-value") + it("should initialize secret cache with all secret keys", () => { + expect(mockSecrets.get).toHaveBeenCalledTimes(SECRET_KEYS.length) + for (const key of SECRET_KEYS) { + expect(mockSecrets.get).toHaveBeenCalledWith(key) + } + }) + }) - // Should get from original context - const result = await proxy.getGlobalState("test-key") - expect(result).toBe("original-value") - expect(mockGlobalState.get).toHaveBeenCalledWith("test-key", undefined) + describe("getGlobalState", () => { + it("should return value from cache when it exists", async () => { + // Manually set a value in the cache + await proxy.updateGlobalState("test-key", "cached-value") + + // Should return the cached value + const result = proxy.getGlobalState("test-key") + expect(result).toBe("cached-value") + + // Original context should be called once during updateGlobalState + expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length) // Only from initialization }) it("should handle default values correctly", async () => { - // No value in either pending or original - mockGlobalState.get.mockImplementation((key: string, defaultValue: any) => defaultValue) - - // Should return the default value - const result = await proxy.getGlobalState("test-key", "default-value") + // No value in cache + const result = proxy.getGlobalState("unknown-key", "default-value") expect(result).toBe("default-value") }) }) describe("updateGlobalState", () => { - it("should buffer changes without calling original context", async () => { + it("should update state directly in original context", async () => { await proxy.updateGlobalState("test-key", "new-value") // Should have called logger.debug - expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("buffering state update")) + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("updating state for key")) - // Should not have called original context - expect(mockGlobalState.update).not.toHaveBeenCalled() + // Should have called original context + expect(mockGlobalState.update).toHaveBeenCalledWith("test-key", "new-value") - // Should have stored the value in pendingStateChanges + // Should have stored the value in cache const storedValue = await proxy.getGlobalState("test-key") expect(storedValue).toBe("new-value") }) - - it("should throw an error when context is disposed", async () => { - await proxy.dispose() - - await expect(proxy.updateGlobalState("test-key", "new-value")).rejects.toThrow( - "Cannot update state on disposed context", - ) - }) }) describe("getSecret", () => { - it("should return pending secret when it exists", async () => { - // Set up a pending secret - await proxy.storeSecret("api-key", "secret123") + it("should return value from cache when it exists", async () => { + // Manually set a value in the cache + await proxy.storeSecret("api-key", "cached-secret") - // Should return the pending value - const result = await proxy.getSecret("api-key") - expect(result).toBe("secret123") - - // Original context should not be called - expect(mockSecrets.get).not.toHaveBeenCalled() - }) - - it("should fall back to original context when no pending secret exists", async () => { - // Set up original context value - mockSecrets.get.mockResolvedValue("original-secret") - - // Should get from original context - const result = await proxy.getSecret("api-key") - expect(result).toBe("original-secret") - expect(mockSecrets.get).toHaveBeenCalledWith("api-key") + // Should return the cached value + const result = proxy.getSecret("api-key") + expect(result).toBe("cached-secret") }) }) describe("storeSecret", () => { - it("should buffer secret changes without calling original context", async () => { + it("should store secret directly in original context", async () => { await proxy.storeSecret("api-key", "new-secret") // Should have called logger.debug - expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("buffering secret update")) + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("storing secret for key")) - // Should not have called original context - expect(mockSecrets.store).not.toHaveBeenCalled() + // Should have called original context + expect(mockSecrets.store).toHaveBeenCalledWith("api-key", "new-secret") - // Should have stored the value in pendingSecretChanges + // Should have stored the value in cache const storedValue = await proxy.getSecret("api-key") expect(storedValue).toBe("new-secret") }) @@ -173,110 +162,12 @@ describe("ContextProxy", () => { it("should handle undefined value for secret deletion", async () => { await proxy.storeSecret("api-key", undefined) - // Should have stored undefined in pendingSecretChanges + // Should have called delete on original context + expect(mockSecrets.delete).toHaveBeenCalledWith("api-key") + + // Should have stored undefined in cache const storedValue = await proxy.getSecret("api-key") expect(storedValue).toBeUndefined() }) - - it("should throw an error when context is disposed", async () => { - await proxy.dispose() - - await expect(proxy.storeSecret("api-key", "new-secret")).rejects.toThrow( - "Cannot store secret on disposed context", - ) - }) - }) - - describe("saveChanges", () => { - it("should apply state changes to original context", async () => { - // Set up pending changes - await proxy.updateGlobalState("key1", "value1") - await proxy.updateGlobalState("key2", "value2") - - // Save changes - await proxy.saveChanges() - - // Should have called update on original context - expect(mockGlobalState.update).toHaveBeenCalledTimes(2) - expect(mockGlobalState.update).toHaveBeenCalledWith("key1", "value1") - expect(mockGlobalState.update).toHaveBeenCalledWith("key2", "value2") - - // Should have cleared pending changes - expect(proxy.hasPendingChanges()).toBe(false) - }) - - it("should apply secret changes to original context", async () => { - // Set up pending changes - await proxy.storeSecret("secret1", "value1") - await proxy.storeSecret("secret2", undefined) - - // Save changes - await proxy.saveChanges() - - // Should have called store and delete on original context - expect(mockSecrets.store).toHaveBeenCalledTimes(1) - expect(mockSecrets.store).toHaveBeenCalledWith("secret1", "value1") - expect(mockSecrets.delete).toHaveBeenCalledTimes(1) - expect(mockSecrets.delete).toHaveBeenCalledWith("secret2") - - // Should have cleared pending changes - expect(proxy.hasPendingChanges()).toBe(false) - }) - - it("should do nothing when there are no pending changes", async () => { - await proxy.saveChanges() - - expect(mockGlobalState.update).not.toHaveBeenCalled() - expect(mockSecrets.store).not.toHaveBeenCalled() - expect(mockSecrets.delete).not.toHaveBeenCalled() - }) - - it("should throw an error when context is disposed", async () => { - await proxy.dispose() - - await expect(proxy.saveChanges()).rejects.toThrow("Cannot save changes on disposed context") - }) - }) - - describe("dispose", () => { - it("should save pending changes to original context", async () => { - // Set up pending changes - await proxy.updateGlobalState("key1", "value1") - await proxy.storeSecret("secret1", "value1") - - // Dispose - await proxy.dispose() - - // Should have saved changes - expect(mockGlobalState.update).toHaveBeenCalledWith("key1", "value1") - expect(mockSecrets.store).toHaveBeenCalledWith("secret1", "value1") - - // Should be marked as disposed - expect(proxy.hasPendingChanges()).toBe(false) - }) - }) - - describe("hasPendingChanges", () => { - it("should return false when no changes are pending", () => { - expect(proxy.hasPendingChanges()).toBe(false) - }) - - it("should return true when state changes are pending", async () => { - await proxy.updateGlobalState("key", "value") - expect(proxy.hasPendingChanges()).toBe(true) - }) - - it("should return true when secret changes are pending", async () => { - await proxy.storeSecret("key", "value") - expect(proxy.hasPendingChanges()).toBe(true) - }) - - it("should return false after changes are saved", async () => { - await proxy.updateGlobalState("key", "value") - expect(proxy.hasPendingChanges()).toBe(true) - - await proxy.saveChanges() - expect(proxy.hasPendingChanges()).toBe(false) - }) }) }) diff --git a/src/core/contextProxy.ts b/src/core/contextProxy.ts index e4672ae225..7c429c86cf 100644 --- a/src/core/contextProxy.ts +++ b/src/core/contextProxy.ts @@ -1,25 +1,53 @@ import * as vscode from "vscode" import { logger } from "../utils/logging" +import { GLOBAL_STATE_KEYS, SECRET_KEYS } from "../shared/globalState" -/** - * A proxy class for vscode.ExtensionContext that buffers state changes - * and only commits them when explicitly requested or during disposal. - */ export class ContextProxy { private readonly originalContext: vscode.ExtensionContext - private pendingStateChanges: Map - private pendingSecretChanges: Map - private disposed: boolean + private stateCache: Map + private secretCache: Map constructor(context: vscode.ExtensionContext) { + // Initialize properties first this.originalContext = context - this.pendingStateChanges = new Map() - this.pendingSecretChanges = new Map() - this.disposed = false + this.stateCache = new Map() + this.secretCache = new Map() + + // Initialize state cache with all defined global state keys + this.initializeStateCache() + + // Initialize secret cache with all defined secret keys + this.initializeSecretCache() + logger.debug("ContextProxy created") } - // Read-only pass-through properties + // Helper method to initialize state cache + private initializeStateCache(): void { + for (const key of GLOBAL_STATE_KEYS) { + try { + const value = this.originalContext.globalState.get(key) + this.stateCache.set(key, value) + } catch (error) { + logger.error(`Error loading global ${key}: ${error instanceof Error ? error.message : String(error)}`) + } + } + } + + // Helper method to initialize secret cache + private initializeSecretCache(): void { + for (const key of SECRET_KEYS) { + // Get actual value and update cache when promise resolves + ;(this.originalContext.secrets.get(key) as Promise) + .then((value) => { + this.secretCache.set(key, value) + }) + .catch((error: Error) => { + logger.error(`Error loading secret ${key}: ${error.message}`) + }) + } + } + get extensionUri(): vscode.Uri { return this.originalContext.extensionUri } @@ -39,85 +67,30 @@ export class ContextProxy { return this.originalContext.extensionMode } - // State management methods - async getGlobalState(key: string): Promise - async getGlobalState(key: string, defaultValue: T): Promise - async getGlobalState(key: string, defaultValue?: T): Promise { - // Check pending changes first - if (this.pendingStateChanges.has(key)) { - const value = this.pendingStateChanges.get(key) as T | undefined - return value !== undefined ? value : (defaultValue as T | undefined) - } - // Fall back to original context - return this.originalContext.globalState.get(key, defaultValue as T) + getGlobalState(key: string): T | undefined + getGlobalState(key: string, defaultValue: T): T + getGlobalState(key: string, defaultValue?: T): T | undefined { + const value = this.stateCache.get(key) as T | undefined + return value !== undefined ? value : (defaultValue as T | undefined) } - async updateGlobalState(key: string, value: T): Promise { - if (this.disposed) { - throw new Error("Cannot update state on disposed context") - } - logger.debug(`ContextProxy: buffering state update for key "${key}"`) - this.pendingStateChanges.set(key, value) + updateGlobalState(key: string, value: T): Thenable { + this.stateCache.set(key, value) + return this.originalContext.globalState.update(key, value) } - // Secret storage methods - async getSecret(key: string): Promise { - // Check pending changes first - if (this.pendingSecretChanges.has(key)) { - return this.pendingSecretChanges.get(key) - } - // Fall back to original context - return this.originalContext.secrets.get(key) + getSecret(key: string): string | undefined { + return this.secretCache.get(key) } - async storeSecret(key: string, value?: string): Promise { - if (this.disposed) { - throw new Error("Cannot store secret on disposed context") + storeSecret(key: string, value?: string): Thenable { + // Update cache + this.secretCache.set(key, value) + // Write directly to context + if (value === undefined) { + return this.originalContext.secrets.delete(key) + } else { + return this.originalContext.secrets.store(key, value) } - logger.debug(`ContextProxy: buffering secret update for key "${key}"`) - this.pendingSecretChanges.set(key, value) - } - - // Save pending changes to actual context - async saveChanges(): Promise { - if (this.disposed) { - throw new Error("Cannot save changes on disposed context") - } - - // Apply state changes - if (this.pendingStateChanges.size > 0) { - logger.debug(`ContextProxy: applying ${this.pendingStateChanges.size} buffered state changes`) - for (const [key, value] of this.pendingStateChanges.entries()) { - await this.originalContext.globalState.update(key, value) - } - this.pendingStateChanges.clear() - } - - // Apply secret changes - if (this.pendingSecretChanges.size > 0) { - logger.debug(`ContextProxy: applying ${this.pendingSecretChanges.size} buffered secret changes`) - for (const [key, value] of this.pendingSecretChanges.entries()) { - if (value === undefined) { - await this.originalContext.secrets.delete(key) - } else { - await this.originalContext.secrets.store(key, value) - } - } - this.pendingSecretChanges.clear() - } - } - - // Called when the provider is disposing - async dispose(): Promise { - if (!this.disposed) { - logger.debug("ContextProxy: disposing and saving pending changes") - await this.saveChanges() - this.disposed = true - } - } - - // Method to check if there are pending changes - hasPendingChanges(): boolean { - return this.pendingStateChanges.size > 0 || this.pendingSecretChanges.size > 0 } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index ae53e58df9..748ba2525b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -16,7 +16,7 @@ import { SecretKey, GlobalStateKey, SECRET_KEYS, GLOBAL_STATE_KEYS } from "../.. import { HistoryItem } from "../../shared/HistoryItem" import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage" import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage" -import { Mode, CustomModePrompts, PromptComponent, defaultModeSlug } from "../../shared/modes" +import { Mode, CustomModePrompts, PromptComponent, defaultModeSlug, ModeConfig } from "../../shared/modes" import { checkExistKey } from "../../shared/checkExistApiConfig" import { EXPERIMENT_IDS, experiments as Experiments, experimentDefault, ExperimentId } from "../../shared/experiments" import { downloadTask } from "../../integrations/misc/export-markdown" @@ -119,8 +119,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.customModesManager?.dispose() this.outputChannel.appendLine("Disposed all disposables") // Dispose the context proxy to commit any pending changes - await this.contextProxy.dispose() - this.outputChannel.appendLine("Disposed context proxy") ClineProvider.activeInstances.delete(this) // Unregister from McpServerManager @@ -2082,22 +2080,26 @@ export class ClineProvider implements vscode.WebviewViewProvider { // Add promise for custom modes which is handled separately const customModesPromise = this.customModesManager.getCustomModes() - // Wait for all promises to resolve - const [stateResults, secretResults, customModes] = await Promise.all([ - Promise.all(statePromises), - Promise.all(secretPromises), + let idx = 0 + const secretValuesArray = await Promise.all([ + ...statePromises, + ...secretPromises, customModesPromise, ]) // Populate stateValues and secretValues - GLOBAL_STATE_KEYS.forEach((key, index) => { - stateValues[key] = stateResults[index] + GLOBAL_STATE_KEYS.forEach((key, _) => { + stateValues[key] = secretValuesArray[idx] + idx = idx + 1 }) SECRET_KEYS.forEach((key, index) => { - secretValues[key] = secretResults[index] + secretValues[key] = secretValuesArray[idx] + idx = idx + 1 }) + let customModes = secretValuesArray[idx] as ModeConfig[] | undefined + // Determine apiProvider with the same logic as before let apiProvider: ApiProvider if (stateValues.apiProvider) { @@ -2219,12 +2221,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { async updateGlobalState(key: GlobalStateKey, value: any) { this.outputChannel.appendLine(`Updating global state: ${key}`) await this.contextProxy.updateGlobalState(key, value) - - // // If we have a lot of pending changes, consider saving them periodically - // if (this.contextProxy.hasPendingChanges() && Math.random() < 0.1) { // 10% chance to save changes - // this.outputChannel.appendLine("Periodically flushing context state changes") - // await this.contextProxy.saveChanges() - // } } async getGlobalState(key: GlobalStateKey) { @@ -2256,13 +2252,13 @@ export class ClineProvider implements vscode.WebviewViewProvider { } for (const key of this.context.globalState.keys()) { - // Still using original context for listing keys await this.contextProxy.updateGlobalState(key, undefined) } for (const key of SECRET_KEYS) { await this.storeSecret(key, undefined) } + await this.configManager.resetAllConfigs() await this.customModesManager.resetCustomModes() if (this.cline) { diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 20778b8802..9463be25b7 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -1303,17 +1303,6 @@ describe("ClineProvider", () => { // Verify state was posted to webview expect(mockPostMessage).toHaveBeenCalledWith(expect.objectContaining({ type: "state" })) }) - - test("disposes the contextProxy when provider is disposed", async () => { - // Setup mock Cline instance - const mockCline = { - abortTask: jest.fn(), - } - // @ts-ignore - accessing private property for testing - provider.cline = mockCline - await provider.dispose() - expect(mockContextProxy.dispose).toHaveBeenCalled() - }) }) describe("updateCustomMode", () => { @@ -1602,15 +1591,4 @@ describe("ContextProxy integration", () => { expect(mockContextProxy.updateGlobalState).toBeDefined() expect(mockContextProxy.storeSecret).toBeDefined() }) - - test("contextProxy is properly disposed", async () => { - // Setup mock Cline instance - const mockCline = { - abortTask: jest.fn(), - } - // @ts-ignore - accessing private property for testing - provider.cline = mockCline - await provider.dispose() - expect(mockContextProxy.dispose).toHaveBeenCalled() - }) }) diff --git a/src/shared/api.ts b/src/shared/api.ts index 7c5c65fe90..981fcf8d76 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -108,7 +108,7 @@ export const API_CONFIG_KEYS: GlobalStateKey[] = [ "lmStudioBaseUrl", "lmStudioDraftModelId", "lmStudioSpeculativeDecodingEnabled", - "mistralCodestralUrl", // New option for Codestral URL + "mistralCodestralUrl", "azureApiVersion", "openRouterUseMiddleOutTransform", "openAiStreamingEnabled", From 74d52aafcb0dca322a1d781a115ebd11d634ac10 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 4 Mar 2025 17:31:41 -0500 Subject: [PATCH 07/14] Fix issues with select dropdown --- .../ui/__tests__/select-dropdown.test.tsx | 29 ++++++++++--------- .../src/components/ui/select-dropdown.tsx | 23 +++++++++++++-- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/webview-ui/src/components/ui/__tests__/select-dropdown.test.tsx b/webview-ui/src/components/ui/__tests__/select-dropdown.test.tsx index 5d65eaae98..328933852f 100644 --- a/webview-ui/src/components/ui/__tests__/select-dropdown.test.tsx +++ b/webview-ui/src/components/ui/__tests__/select-dropdown.test.tsx @@ -115,6 +115,21 @@ describe("SelectDropdown", () => { expect(trigger.classList.toString()).toContain("custom-trigger-class") }) + it("ensures open state is controlled via props", () => { + // Test that the component accepts and uses the open state controlled prop + render() + + // The component should render the dropdown root with correct props + const dropdown = screen.getByTestId("dropdown-root") + expect(dropdown).toBeInTheDocument() + + // Verify trigger and content are rendered + const trigger = screen.getByTestId("dropdown-trigger") + const content = screen.getByTestId("dropdown-content") + expect(trigger).toBeInTheDocument() + expect(content).toBeInTheDocument() + }) + // Tests for the new functionality describe("Option types", () => { it("renders separator options correctly", () => { @@ -131,20 +146,6 @@ describe("SelectDropdown", () => { expect(separators.length).toBe(1) }) - it("renders string separator (backward compatibility) correctly", () => { - const optionsWithStringSeparator = [ - { value: "option1", label: "Option 1" }, - { value: "sep-1", label: "────", disabled: true }, - { value: "option2", label: "Option 2" }, - ] - - render() - - // Check for separator - const separators = screen.getAllByTestId("dropdown-separator") - expect(separators.length).toBe(1) - }) - it("renders shortcut options correctly", () => { const shortcutText = "Ctrl+K" const optionsWithShortcut = [ diff --git a/webview-ui/src/components/ui/select-dropdown.tsx b/webview-ui/src/components/ui/select-dropdown.tsx index b134894d25..775066732d 100644 --- a/webview-ui/src/components/ui/select-dropdown.tsx +++ b/webview-ui/src/components/ui/select-dropdown.tsx @@ -7,6 +7,7 @@ import { DropdownMenuSeparator, } from "./dropdown-menu" import { cn } from "@/lib/utils" +import { useEffect, useState } from "react" // Constants for option types export enum DropdownOptionType { @@ -57,6 +58,19 @@ export const SelectDropdown = React.forwardRef { + // Track open state + const [open, setOpen] = React.useState(false) + const [portalContainer, setPortalContainer] = useState() + + useEffect(() => { + // The dropdown menu uses a portal from @shadcn/ui which by default renders + // at the document root. This causes the menu to remain visible even when + // the parent ChatView component is hidden (during settings/history view). + // By moving the portal inside ChatView, the menu will properly hide when + // its parent is hidden. + setPortalContainer(document.getElementById("chat-view-portal") || undefined) + }, []) + // Find the selected option label const selectedOption = options.find((option) => option.value === value) const displayText = selectedOption?.label || placeholder || "" @@ -69,13 +83,15 @@ export const SelectDropdown = React.forwardRef + setOpen(false)} + onInteractOutside={() => setOpen(false)} + container={portalContainer} className={cn( "bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border z-50", contentClassName, )}> {options.map((option, index) => { // Handle separator type - if (option.type === DropdownOptionType.SEPARATOR || option.label.includes("────")) { + if (option.type === DropdownOptionType.SEPARATOR) { return } From e1ccb702695214b4436a27334a01b9332033c426 Mon Sep 17 00:00:00 2001 From: cte Date: Tue, 4 Mar 2025 15:18:29 -0800 Subject: [PATCH 08/14] Checkpoint storage setting + settings view redesign --- src/core/Cline.ts | 6 +- src/core/webview/ClineProvider.ts | 46 +- .../webview/__tests__/ClineProvider.test.ts | 3 + src/shared/ExtensionMessage.ts | 2 + src/shared/WebviewMessage.ts | 1 + src/shared/checkpoints.ts | 5 + src/shared/globalState.ts | 1 + .../src/components/common/MermaidBlock.tsx | 1 - .../src/components/history/HistoryPreview.tsx | 147 +-- .../src/components/history/HistoryView.tsx | 115 +-- .../src/components/history/useTaskSearch.ts | 78 ++ webview-ui/src/components/mcp/McpView.tsx | 27 +- .../src/components/prompts/PromptsView.tsx | 6 +- .../components/settings/AdvancedSettings.tsx | 176 ++++ .../components/settings/ApiConfigManager.tsx | 351 ++++--- .../src/components/settings/ApiOptions.tsx | 2 - .../settings/AutoApproveSettings.tsx | 252 +++++ .../components/settings/BrowserSettings.tsx | 105 ++ .../settings/CheckpointSettings.tsx | 82 ++ .../settings/ExperimentalFeature.tsx | 31 +- .../settings/ExperimentalSettings.tsx | 53 + .../settings/NotificationSettings.tsx | 69 ++ .../src/components/settings/Section.tsx | 9 + .../src/components/settings/SectionHeader.tsx | 15 + .../components/settings/SettingsFooter.tsx | 36 + .../src/components/settings/SettingsView.tsx | 963 +++++------------- .../settings/TemperatureControl.tsx | 6 +- .../settings/__tests__/SettingsView.test.tsx | 18 + webview-ui/src/components/settings/styles.ts | 9 +- webview-ui/src/components/settings/types.ts | 10 + .../src/context/ExtensionStateContext.tsx | 1 + .../__tests__/ExtensionStateContext.test.tsx | 1 + webview-ui/src/index.css | 4 + 33 files changed, 1467 insertions(+), 1164 deletions(-) create mode 100644 src/shared/checkpoints.ts create mode 100644 webview-ui/src/components/history/useTaskSearch.ts create mode 100644 webview-ui/src/components/settings/AdvancedSettings.tsx create mode 100644 webview-ui/src/components/settings/AutoApproveSettings.tsx create mode 100644 webview-ui/src/components/settings/BrowserSettings.tsx create mode 100644 webview-ui/src/components/settings/CheckpointSettings.tsx create mode 100644 webview-ui/src/components/settings/ExperimentalSettings.tsx create mode 100644 webview-ui/src/components/settings/NotificationSettings.tsx create mode 100644 webview-ui/src/components/settings/Section.tsx create mode 100644 webview-ui/src/components/settings/SectionHeader.tsx create mode 100644 webview-ui/src/components/settings/SettingsFooter.tsx create mode 100644 webview-ui/src/components/settings/types.ts diff --git a/src/core/Cline.ts b/src/core/Cline.ts index bccac0575f..773303c246 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -10,6 +10,7 @@ import getFolderSize from "get-folder-size" import * as path from "path" import { serializeError } from "serialize-error" import * as vscode from "vscode" + import { ApiHandler, buildApiHandler } from "../api" import { ApiStream } from "../api/transform/stream" import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider" @@ -31,6 +32,7 @@ import { UrlContentFetcher } from "../services/browser/UrlContentFetcher" import { listFiles } from "../services/glob/list-files" import { regexSearchFiles } from "../services/ripgrep" import { parseSourceCodeForDefinitionsTopLevel } from "../services/tree-sitter" +import { CheckpointStorage } from "../shared/checkpoints" import { ApiConfiguration } from "../shared/api" import { findLastIndex } from "../shared/array" import { combineApiRequests } from "../shared/combineApiRequests" @@ -81,7 +83,7 @@ export type ClineOptions = { customInstructions?: string enableDiff?: boolean enableCheckpoints?: boolean - checkpointStorage?: "task" | "workspace" + checkpointStorage?: CheckpointStorage fuzzyMatchThreshold?: number task?: string images?: string[] @@ -121,7 +123,7 @@ export class Cline { // checkpoints private enableCheckpoints: boolean - private checkpointStorage: "task" | "workspace" + private checkpointStorage: CheckpointStorage private checkpointService?: RepoPerTaskCheckpointService | RepoPerWorkspaceCheckpointService // streaming diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index d0e68420b5..ccb50fe440 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -9,6 +9,7 @@ import * as vscode from "vscode" import simpleGit from "simple-git" import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api" +import { CheckpointStorage } from "../../shared/checkpoints" import { findLast } from "../../shared/array" import { CustomSupportPrompts, supportPrompt } from "../../shared/support-prompt" import { GlobalFileNames } from "../../shared/globalFileNames" @@ -313,11 +314,13 @@ export class ClineProvider implements vscode.WebviewViewProvider { public async initClineWithTask(task?: string, images?: string[]) { await this.clearTask() + const { apiConfiguration, customModePrompts, - diffEnabled, + diffEnabled: enableDiff, enableCheckpoints, + checkpointStorage, fuzzyMatchThreshold, mode, customInstructions: globalInstructions, @@ -331,8 +334,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { provider: this, apiConfiguration, customInstructions: effectiveInstructions, - enableDiff: diffEnabled, + enableDiff, enableCheckpoints, + checkpointStorage, fuzzyMatchThreshold, task, images, @@ -346,8 +350,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { const { apiConfiguration, customModePrompts, - diffEnabled, + diffEnabled: enableDiff, enableCheckpoints, + checkpointStorage, fuzzyMatchThreshold, mode, customInstructions: globalInstructions, @@ -357,12 +362,17 @@ export class ClineProvider implements vscode.WebviewViewProvider { const modePrompt = customModePrompts?.[mode] as PromptComponent const effectiveInstructions = [globalInstructions, modePrompt?.customInstructions].filter(Boolean).join("\n\n") + // TODO: The `checkpointStorage` value should be derived from the + // task data on disk; the current setting could be different than + // the setting at the time the task was created. + this.cline = new Cline({ provider: this, apiConfiguration, customInstructions: effectiveInstructions, - enableDiff: diffEnabled, + enableDiff, enableCheckpoints, + checkpointStorage, fuzzyMatchThreshold, historyItem, experiments, @@ -1022,6 +1032,12 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("enableCheckpoints", enableCheckpoints) await this.postStateToWebview() break + case "checkpointStorage": + console.log(`[ClineProvider] checkpointStorage: ${message.text}`) + const checkpointStorage = message.text ?? "task" + await this.updateGlobalState("checkpointStorage", checkpointStorage) + await this.postStateToWebview() + break case "browserViewportSize": const browserViewportSize = message.text ?? "900x600" await this.updateGlobalState("browserViewportSize", browserViewportSize) @@ -1947,21 +1963,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { await fs.unlink(legacyMessagesFilePath) } - const { enableCheckpoints } = await this.getState() - const baseDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) - - // Delete checkpoints branch. - if (enableCheckpoints && baseDir) { - const branchSummary = await simpleGit(baseDir) - .branch(["-D", `roo-code-checkpoints-${id}`]) - .catch(() => undefined) - - if (branchSummary) { - console.log(`[deleteTaskWithId${id}] deleted checkpoints branch`) - } - } - - // Delete checkpoints directory + // Delete checkpoints directory. + // TODO: Also delete the workspace branch if it exists. const checkpointsDir = path.join(taskDirPath, "checkpoints") if (await fileExistsAtPath(checkpointsDir)) { @@ -2008,6 +2011,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { soundEnabled, diffEnabled, enableCheckpoints, + checkpointStorage, taskHistory, soundVolume, browserViewportSize, @@ -2058,6 +2062,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { soundEnabled: soundEnabled ?? false, diffEnabled: diffEnabled ?? true, enableCheckpoints: enableCheckpoints ?? true, + checkpointStorage: checkpointStorage ?? "task", shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId, allowedCommands, soundVolume: soundVolume ?? 0.5, @@ -2191,6 +2196,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { soundEnabled, diffEnabled, enableCheckpoints, + checkpointStorage, soundVolume, browserViewportSize, fuzzyMatchThreshold, @@ -2278,6 +2284,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("soundEnabled") as Promise, this.getGlobalState("diffEnabled") as Promise, this.getGlobalState("enableCheckpoints") as Promise, + this.getGlobalState("checkpointStorage") as Promise, this.getGlobalState("soundVolume") as Promise, this.getGlobalState("browserViewportSize") as Promise, this.getGlobalState("fuzzyMatchThreshold") as Promise, @@ -2395,6 +2402,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { soundEnabled: soundEnabled ?? false, diffEnabled: diffEnabled ?? true, enableCheckpoints: enableCheckpoints ?? true, + checkpointStorage: checkpointStorage ?? "task", soundVolume, browserViewportSize: browserViewportSize ?? "900x600", screenshotQuality: screenshotQuality ?? 75, diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 154c24bc27..7bda572386 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -370,6 +370,7 @@ describe("ClineProvider", () => { soundEnabled: false, diffEnabled: false, enableCheckpoints: false, + checkpointStorage: "task", writeDelayMs: 1000, browserViewportSize: "900x600", fuzzyMatchThreshold: 1.0, @@ -694,6 +695,7 @@ describe("ClineProvider", () => { mode: "code", diffEnabled: true, enableCheckpoints: false, + checkpointStorage: "task", fuzzyMatchThreshold: 1.0, experiments: experimentDefault, } as any) @@ -712,6 +714,7 @@ describe("ClineProvider", () => { customInstructions: modeCustomInstructions, enableDiff: true, enableCheckpoints: false, + checkpointStorage: "task", fuzzyMatchThreshold: 1.0, task: "Test task", experiments: experimentDefault, diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 858b20ca7f..ff9e2a24df 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -7,6 +7,7 @@ import { GitCommit } from "../utils/git" import { Mode, CustomModePrompts, ModeConfig } from "./modes" import { CustomSupportPrompts } from "./support-prompt" import { ExperimentId } from "./experiments" +import { CheckpointStorage } from "./checkpoints" export interface LanguageModelChatSelector { vendor?: string @@ -114,6 +115,7 @@ export interface ExtensionState { soundVolume?: number diffEnabled?: boolean enableCheckpoints: boolean + checkpointStorage: CheckpointStorage browserViewportSize?: string screenshotQuality?: number fuzzyMatchThreshold?: number diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 141915fdb8..19e3d3bac3 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -53,6 +53,7 @@ export interface WebviewMessage { | "soundVolume" | "diffEnabled" | "enableCheckpoints" + | "checkpointStorage" | "browserViewportSize" | "screenshotQuality" | "openMcpSettings" diff --git a/src/shared/checkpoints.ts b/src/shared/checkpoints.ts new file mode 100644 index 0000000000..7cd1818c12 --- /dev/null +++ b/src/shared/checkpoints.ts @@ -0,0 +1,5 @@ +export type CheckpointStorage = "task" | "workspace" + +export const isCheckpointStorage = (value: string): value is CheckpointStorage => { + return value === "task" || value === "workspace" +} diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index 88f9824151..ccb87bd315 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -55,6 +55,7 @@ export type GlobalStateKey = | "soundVolume" | "diffEnabled" | "enableCheckpoints" + | "checkpointStorage" | "browserViewportSize" | "screenshotQuality" | "fuzzyMatchThreshold" diff --git a/webview-ui/src/components/common/MermaidBlock.tsx b/webview-ui/src/components/common/MermaidBlock.tsx index 619188179e..6153570cf2 100644 --- a/webview-ui/src/components/common/MermaidBlock.tsx +++ b/webview-ui/src/components/common/MermaidBlock.tsx @@ -150,7 +150,6 @@ export default function MermaidBlock({ code }: MermaidBlockProps) { } async function svgToPng(svgEl: SVGElement): Promise { - console.log("svgToPng function called") // Clone the SVG to avoid modifying the original const svgClone = svgEl.cloneNode(true) as SVGElement diff --git a/webview-ui/src/components/history/HistoryPreview.tsx b/webview-ui/src/components/history/HistoryPreview.tsx index bf53845da7..f81d8ddacf 100644 --- a/webview-ui/src/components/history/HistoryPreview.tsx +++ b/webview-ui/src/components/history/HistoryPreview.tsx @@ -14,101 +14,64 @@ type HistoryPreviewProps = { const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => { const { taskHistory } = useExtensionState() - const handleHistorySelect = (id: string) => { - vscode.postMessage({ type: "showTaskWithId", text: id }) - } - return ( -

- -
- - Recent Tasks -
-
- {taskHistory - .filter((item) => item.ts && item.task) - .slice(0, 3) - .map((item) => ( -
handleHistorySelect(item.id)}> -
-
- - {formatDate(item.ts)} - - -
-
- {item.task} -
-
- - Tokens: ↑{formatLargeNumber(item.tokensIn || 0)} ↓ - {formatLargeNumber(item.tokensOut || 0)} - - {!!item.cacheWrites && ( - <> - {" • "} - - Cache: +{formatLargeNumber(item.cacheWrites || 0)} →{" "} - {formatLargeNumber(item.cacheReads || 0)} - - - )} - {!!item.totalCost && ( - <> - {" • "} - API Cost: ${item.totalCost?.toFixed(4)} - - )} -
-
-
- ))} -
- +
+
+
+ + Recent Tasks
+
+ {taskHistory.slice(0, 3).map((item) => ( +
vscode.postMessage({ type: "showTaskWithId", text: item.id })}> +
+
+ + {formatDate(item.ts)} + + +
+
+ {item.task} +
+
+ + Tokens: ↑{formatLargeNumber(item.tokensIn || 0)} ↓ + {formatLargeNumber(item.tokensOut || 0)} + + {!!item.cacheWrites && ( + <> + {" • "} + + Cache: +{formatLargeNumber(item.cacheWrites || 0)} →{" "} + {formatLargeNumber(item.cacheReads || 0)} + + + )} + {!!item.totalCost && ( + <> + {" • "} + API Cost: ${item.totalCost?.toFixed(4)} + + )} +
+
+
+ ))}
) } diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index c621da5a03..e65a11a3ec 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -1,16 +1,15 @@ -import React, { memo, useMemo, useState, useEffect } from "react" +import React, { memo, useState } from "react" import { DeleteTaskDialog } from "./DeleteTaskDialog" -import { Fzf } from "fzf" import prettyBytes from "pretty-bytes" import { Virtuoso } from "react-virtuoso" import { VSCodeButton, VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@vscode/webview-ui-toolkit/react" import { vscode } from "@/utils/vscode" import { formatLargeNumber, formatDate } from "@/utils/format" -import { highlightFzfMatch } from "@/utils/highlight" +import { cn } from "@/lib/utils" import { Button } from "@/components/ui" -import { useExtensionState } from "../../context/ExtensionStateContext" +import { useTaskSearch } from "./useTaskSearch" import { ExportButton } from "./ExportButton" import { CopyButton } from "./CopyButton" @@ -21,95 +20,18 @@ type HistoryViewProps = { type SortOption = "newest" | "oldest" | "mostExpensive" | "mostTokens" | "mostRelevant" const HistoryView = ({ onDone }: HistoryViewProps) => { - const { taskHistory } = useExtensionState() - const [searchQuery, setSearchQuery] = useState("") - const [sortOption, setSortOption] = useState("newest") - const [lastNonRelevantSort, setLastNonRelevantSort] = useState("newest") - - useEffect(() => { - if (searchQuery && sortOption !== "mostRelevant" && !lastNonRelevantSort) { - setLastNonRelevantSort(sortOption) - setSortOption("mostRelevant") - } else if (!searchQuery && sortOption === "mostRelevant" && lastNonRelevantSort) { - setSortOption(lastNonRelevantSort) - setLastNonRelevantSort(null) - } - }, [searchQuery, sortOption, lastNonRelevantSort]) - - const handleHistorySelect = (id: string) => { - vscode.postMessage({ type: "showTaskWithId", text: id }) - } + const { tasks, searchQuery, setSearchQuery, sortOption, setSortOption, setLastNonRelevantSort } = useTaskSearch() const [deleteTaskId, setDeleteTaskId] = useState(null) - const presentableTasks = useMemo(() => { - return taskHistory.filter((item) => item.ts && item.task) - }, [taskHistory]) - - const fzf = useMemo(() => { - return new Fzf(presentableTasks, { - selector: (item) => item.task, - }) - }, [presentableTasks]) - - const taskHistorySearchResults = useMemo(() => { - let results = presentableTasks - if (searchQuery) { - const searchResults = fzf.find(searchQuery) - results = searchResults.map((result) => ({ - ...result.item, - task: highlightFzfMatch(result.item.task, Array.from(result.positions)), - })) - } - - // First apply search if needed - const searchResults = searchQuery ? results : presentableTasks - - // Then sort the results - return [...searchResults].sort((a, b) => { - switch (sortOption) { - case "oldest": - return (a.ts || 0) - (b.ts || 0) - case "mostExpensive": - return (b.totalCost || 0) - (a.totalCost || 0) - case "mostTokens": - const aTokens = (a.tokensIn || 0) + (a.tokensOut || 0) + (a.cacheWrites || 0) + (a.cacheReads || 0) - const bTokens = (b.tokensIn || 0) + (b.tokensOut || 0) + (b.cacheWrites || 0) + (b.cacheReads || 0) - return bTokens - aTokens - case "mostRelevant": - // Keep fuse order if searching, otherwise sort by newest - return searchQuery ? 0 : (b.ts || 0) - (a.ts || 0) - case "newest": - default: - return (b.ts || 0) - (a.ts || 0) - } - }) - }, [presentableTasks, searchQuery, fzf, sortOption]) - return ( -
-
-

History

- Done -
-
-
+
+
+
+

History

+ Done +
+
{ flexGrow: 1, overflowY: "scroll", }} - data={taskHistorySearchResults} + data={tasks} data-testid="virtuoso-container" components={{ List: React.forwardRef((props, ref) => ( @@ -175,15 +97,12 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { }} itemContent={(index, item) => (
handleHistorySelect(item.id)}> + key={item.id} + className={cn("cursor-pointer", { + "border-b border-vscode-panel-border": index < tasks.length - 1, + })} + onClick={() => vscode.postMessage({ type: "showTaskWithId", text: item.id })}>
{ + const { taskHistory } = useExtensionState() + const [searchQuery, setSearchQuery] = useState("") + const [sortOption, setSortOption] = useState("newest") + const [lastNonRelevantSort, setLastNonRelevantSort] = useState("newest") + + useEffect(() => { + if (searchQuery && sortOption !== "mostRelevant" && !lastNonRelevantSort) { + setLastNonRelevantSort(sortOption) + setSortOption("mostRelevant") + } else if (!searchQuery && sortOption === "mostRelevant" && lastNonRelevantSort) { + setSortOption(lastNonRelevantSort) + setLastNonRelevantSort(null) + } + }, [searchQuery, sortOption, lastNonRelevantSort]) + + const presentableTasks = useMemo(() => { + return taskHistory.filter((item) => item.ts && item.task) + }, [taskHistory]) + + const fzf = useMemo(() => { + return new Fzf(presentableTasks, { + selector: (item) => item.task, + }) + }, [presentableTasks]) + + const tasks = useMemo(() => { + let results = presentableTasks + if (searchQuery) { + const searchResults = fzf.find(searchQuery) + results = searchResults.map((result) => ({ + ...result.item, + task: highlightFzfMatch(result.item.task, Array.from(result.positions)), + })) + } + + // First apply search if needed + const searchResults = searchQuery ? results : presentableTasks + + // Then sort the results + return [...searchResults].sort((a, b) => { + switch (sortOption) { + case "oldest": + return (a.ts || 0) - (b.ts || 0) + case "mostExpensive": + return (b.totalCost || 0) - (a.totalCost || 0) + case "mostTokens": + const aTokens = (a.tokensIn || 0) + (a.tokensOut || 0) + (a.cacheWrites || 0) + (a.cacheReads || 0) + const bTokens = (b.tokensIn || 0) + (b.tokensOut || 0) + (b.cacheWrites || 0) + (b.cacheReads || 0) + return bTokens - aTokens + case "mostRelevant": + // Keep fuse order if searching, otherwise sort by newest + return searchQuery ? 0 : (b.ts || 0) - (a.ts || 0) + case "newest": + default: + return (b.ts || 0) - (a.ts || 0) + } + }) + }, [presentableTasks, searchQuery, fzf, sortOption]) + + return { + tasks, + searchQuery, + setSearchQuery, + sortOption, + setSortOption, + lastNonRelevantSort, + setLastNonRelevantSort, + } +} diff --git a/webview-ui/src/components/mcp/McpView.tsx b/webview-ui/src/components/mcp/McpView.tsx index adb6b47343..7a24922d88 100644 --- a/webview-ui/src/components/mcp/McpView.tsx +++ b/webview-ui/src/components/mcp/McpView.tsx @@ -29,28 +29,12 @@ const McpView = ({ onDone }: McpViewProps) => { } = useExtensionState() return ( -
-
-

MCP Servers

+
+
+

MCP Servers

Done
- -
+
{
)} - - {/* Bottom padding */} -
) diff --git a/webview-ui/src/components/prompts/PromptsView.tsx b/webview-ui/src/components/prompts/PromptsView.tsx index 2bfafeff5c..ccf1e6d700 100644 --- a/webview-ui/src/components/prompts/PromptsView.tsx +++ b/webview-ui/src/components/prompts/PromptsView.tsx @@ -407,12 +407,11 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { return (
-
+

Prompts

Done
- -
+
Preferred Language
@@ -1174,7 +1173,6 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
- {isCreateModeDialogOpen && (
& { + rateLimitSeconds: number + terminalOutputLineLimit?: number + maxOpenTabsContext: number + diffEnabled?: boolean + fuzzyMatchThreshold?: number + setCachedStateField: SetCachedStateField< + "rateLimitSeconds" | "terminalOutputLineLimit" | "maxOpenTabsContext" | "diffEnabled" | "fuzzyMatchThreshold" + > + experiments: Record + setExperimentEnabled: SetExperimentEnabled +} + +export const AdvancedSettings = ({ + rateLimitSeconds, + terminalOutputLineLimit, + maxOpenTabsContext, + diffEnabled, + fuzzyMatchThreshold, + setCachedStateField, + experiments, + setExperimentEnabled, + className, + ...props +}: AdvancedSettingsProps) => { + return ( +
+ +
+ +
Advanced
+
+
+ +
+
+
+ Rate limit +
+ setCachedStateField("rateLimitSeconds", parseInt(e.target.value))} + className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" + /> + {rateLimitSeconds}s +
+
+

Minimum time between API requests.

+
+ +
+
+ Terminal output limit +
+ + setCachedStateField("terminalOutputLineLimit", parseInt(e.target.value)) + } + className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" + /> + {terminalOutputLineLimit ?? 500} +
+
+

+ Maximum number of lines to include in terminal output when executing commands. When exceeded + lines will be removed from the middle, saving tokens. +

+
+ +
+
+ Open tabs context limit +
+ setCachedStateField("maxOpenTabsContext", parseInt(e.target.value))} + className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" + /> + {maxOpenTabsContext ?? 20} +
+
+

+ Maximum number of VSCode open tabs to include in context. Higher values provide more context but + increase token usage. +

+
+ +
+ { + setCachedStateField("diffEnabled", e.target.checked) + if (!e.target.checked) { + // Reset experimental strategy when diffs are disabled. + setExperimentEnabled(EXPERIMENT_IDS.DIFF_STRATEGY, false) + } + }}> + Enable editing through diffs + +

+ When enabled, Roo will be able to edit files more quickly and will automatically reject + truncated full-file writes. Works best with the latest Claude 3.7 Sonnet model. +

+ {diffEnabled && ( +
+ Match precision +
+ { + setCachedStateField("fuzzyMatchThreshold", parseFloat(e.target.value)) + }} + className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" + /> + + {Math.round((fuzzyMatchThreshold || 1) * 100)}% + +
+

+ This slider controls how precisely code sections must match when applying diffs. Lower + values allow more flexible matching but increase the risk of incorrect replacements. Use + values below 100% with extreme caution. +

+ setExperimentEnabled(EXPERIMENT_IDS.DIFF_STRATEGY, enabled)} + /> +
+ )} +
+
+
+ ) +} diff --git a/webview-ui/src/components/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx index 7e2c63c530..b548785dc5 100644 --- a/webview-ui/src/components/settings/ApiConfigManager.tsx +++ b/webview-ui/src/components/settings/ApiConfigManager.tsx @@ -142,199 +142,188 @@ const ApiConfigManager = ({ const isOnlyProfile = listApiConfigMeta?.length === 1 return ( -
-
- +
+ - {isRenaming ? ( -
-
- { - const target = e as { target: { value: string } } - setInputValue(target.target.value) - setError(null) - }} - placeholder="Enter new name" - style={{ flexGrow: 1 }} - onKeyDown={(e: unknown) => { - const event = e as { key: string } - if (event.key === "Enter" && inputValue.trim()) { - handleSave() - } else if (event.key === "Escape") { - handleCancel() - } - }} - /> - - - - - - -
- {error && ( -

- {error} -

- )} -
- ) : ( - <> -
- { - onSelectConfig((value as DropdownOption).value) - }} - style={{ - minWidth: 130, - zIndex: 1002, - }} - role="combobox" - options={listApiConfigMeta.map((config) => ({ - value: config.name, - label: config.name, - }))} - /> - - - - {currentApiConfigName && ( - <> - - - - - - - - )} -
-

- Save different API configurations to quickly switch between providers and settings -

- - )} - - { - if (open) { - setIsCreating(true) - setNewProfileName("") - setError(null) - } else { - resetCreateState() - } - }} - aria-labelledby="new-profile-title"> - - New Configuration Profile - +
+ { const target = e as { target: { value: string } } - setNewProfileName(target.target.value) + setInputValue(target.target.value) setError(null) }} - placeholder="Enter profile name" - style={{ width: "100%" }} + placeholder="Enter new name" + style={{ flexGrow: 1 }} onKeyDown={(e: unknown) => { const event = e as { key: string } - if (event.key === "Enter" && newProfileName.trim()) { - handleNewProfileSave() + if (event.key === "Enter" && inputValue.trim()) { + handleSave() } else if (event.key === "Escape") { - resetCreateState() + handleCancel() } }} /> - {error && ( -

- {error} -

+ + + + + + +
+ {error && ( +

+ {error} +

+ )} +
+ ) : ( + <> +
+ { + onSelectConfig((value as DropdownOption).value) + }} + role="combobox" + options={listApiConfigMeta.map((config) => ({ + value: config.name, + label: config.name, + }))} + /> + + + + {currentApiConfigName && ( + <> + + + + + + + )} -
- - -
- - -
+
+

+ Save different API configurations to quickly switch between providers and settings. +

+ + )} + + { + if (open) { + setIsCreating(true) + setNewProfileName("") + setError(null) + } else { + resetCreateState() + } + }} + aria-labelledby="new-profile-title"> + + New Configuration Profile + { + const target = e as { target: { value: string } } + setNewProfileName(target.target.value) + setError(null) + }} + placeholder="Enter profile name" + style={{ width: "100%" }} + onKeyDown={(e: unknown) => { + const event = e as { key: string } + if (event.key === "Enter" && newProfileName.trim()) { + handleNewProfileSave() + } else if (event.key === "Escape") { + resetCreateState() + } + }} + /> + {error && ( +

+ {error} +

+ )} +
+ + +
+
+
) } diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index b9050a999f..4466d12aaa 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -37,7 +37,6 @@ import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage" import { vscode } from "../../utils/vscode" import VSCodeButtonLink from "../common/VSCodeButtonLink" import { ModelInfoView } from "./ModelInfoView" -import { DROPDOWN_Z_INDEX } from "./styles" import { ModelPicker } from "./ModelPicker" import { TemperatureControl } from "./TemperatureControl" import { validateApiConfiguration, validateModelId } from "@/utils/validate" @@ -242,7 +241,6 @@ const ApiOptions = ({ id="api-provider" value={selectedProvider} onChange={handleInputChange("apiProvider", dropdownEventTransform)} - style={{ minWidth: 130, position: "relative", zIndex: DROPDOWN_Z_INDEX + 1 }} options={[ { value: "openrouter", label: "OpenRouter" }, { value: "anthropic", label: "Anthropic" }, diff --git a/webview-ui/src/components/settings/AutoApproveSettings.tsx b/webview-ui/src/components/settings/AutoApproveSettings.tsx new file mode 100644 index 0000000000..b2da2cab75 --- /dev/null +++ b/webview-ui/src/components/settings/AutoApproveSettings.tsx @@ -0,0 +1,252 @@ +import { HTMLAttributes, useState } from "react" +import { VSCodeButton, VSCodeCheckbox, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { CheckCheck } from "lucide-react" + +import { vscode } from "@/utils/vscode" +import { ExtensionStateContextType } from "@/context/ExtensionStateContext" + +import { SetCachedStateField } from "./types" +import { SectionHeader } from "./SectionHeader" +import { Section } from "./Section" + +type AutoApproveSettingsProps = HTMLAttributes & { + alwaysAllowReadOnly?: boolean + alwaysAllowWrite?: boolean + writeDelayMs: number + alwaysAllowBrowser?: boolean + alwaysApproveResubmit?: boolean + requestDelaySeconds: number + alwaysAllowMcp?: boolean + alwaysAllowModeSwitch?: boolean + alwaysAllowExecute?: boolean + allowedCommands?: string[] + setCachedStateField: SetCachedStateField +} + +export const AutoApproveSettings = ({ + alwaysAllowReadOnly, + alwaysAllowWrite, + writeDelayMs, + alwaysAllowBrowser, + alwaysApproveResubmit, + requestDelaySeconds, + alwaysAllowMcp, + alwaysAllowModeSwitch, + alwaysAllowExecute, + allowedCommands, + setCachedStateField, + className, + ...props +}: AutoApproveSettingsProps) => { + const [commandInput, setCommandInput] = useState("") + + const handleAddCommand = () => { + const currentCommands = allowedCommands ?? [] + if (commandInput && !currentCommands.includes(commandInput)) { + const newCommands = [...currentCommands, commandInput] + setCachedStateField("allowedCommands", newCommands) + setCommandInput("") + vscode.postMessage({ type: "allowedCommands", commands: newCommands }) + } + } + + return ( +
+ +
+ +
Auto-Approve
+
+
+ +
+
+ setCachedStateField("alwaysAllowReadOnly", e.target.checked)}> + Always approve read-only operations + +

+ When enabled, Roo will automatically view directory contents and read files without requiring + you to click the Approve button. +

+
+ +
+ setCachedStateField("alwaysAllowWrite", e.target.checked)}> + Always approve write operations + +

+ Automatically create and edit files without requiring approval +

+ {alwaysAllowWrite && ( +
+
+ setCachedStateField("writeDelayMs", parseInt(e.target.value))} + className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" + /> + {writeDelayMs}ms +
+

+ Delay after writes to allow diagnostics to detect potential problems +

+
+ )} +
+ +
+ setCachedStateField("alwaysAllowBrowser", e.target.checked)}> + Always approve browser actions + +

+ Automatically perform browser actions without requiring approval +
+ Note: Only applies when the model supports computer use +

+
+ +
+ setCachedStateField("alwaysApproveResubmit", e.target.checked)}> + Always retry failed API requests + +

+ Automatically retry failed API requests when server returns an error response +

+ {alwaysApproveResubmit && ( +
+
+ + setCachedStateField("requestDelaySeconds", parseInt(e.target.value)) + } + className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" + /> + {requestDelaySeconds}s +
+

+ Delay before retrying the request +

+
+ )} +
+ +
+ setCachedStateField("alwaysAllowMcp", e.target.checked)}> + Always approve MCP tools + +

+ Enable auto-approval of individual MCP tools in the MCP Servers view (requires both this setting + and the tool's individual "Always allow" checkbox) +

+
+ +
+ setCachedStateField("alwaysAllowModeSwitch", e.target.checked)}> + Always approve mode switching & task creation + +

+ Automatically switch between different AI modes and create new tasks without requiring approval +

+
+ +
+ setCachedStateField("alwaysAllowExecute", e.target.checked)}> + Always approve allowed execute operations + +

+ Automatically execute allowed terminal commands without requiring approval +

+ {alwaysAllowExecute && ( +
+ Allowed Auto-Execute Commands +

+ Command prefixes that can be auto-executed when "Always approve execute operations" is + enabled. Add * to allow all commands (use with caution). +

+
+ setCommandInput(e.target.value)} + onKeyDown={(e: any) => { + if (e.key === "Enter") { + e.preventDefault() + handleAddCommand() + } + }} + placeholder="Enter command prefix (e.g., 'git ')" + style={{ flexGrow: 1 }} + /> + Add +
+
+ {(allowedCommands ?? []).map((cmd, index) => ( +
+ {cmd} + { + const newCommands = (allowedCommands ?? []).filter( + (_, i) => i !== index, + ) + setCachedStateField("allowedCommands", newCommands) + vscode.postMessage({ type: "allowedCommands", commands: newCommands }) + }}> + + +
+ ))} +
+
+ )} +
+
+
+ ) +} diff --git a/webview-ui/src/components/settings/BrowserSettings.tsx b/webview-ui/src/components/settings/BrowserSettings.tsx new file mode 100644 index 0000000000..1ff0a36b9c --- /dev/null +++ b/webview-ui/src/components/settings/BrowserSettings.tsx @@ -0,0 +1,105 @@ +import { HTMLAttributes } from "react" +import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" +import { Dropdown, type DropdownOption } from "vscrui" +import { SquareMousePointer } from "lucide-react" + +import { SetCachedStateField } from "./types" +import { sliderLabelStyle } from "./styles" +import { SectionHeader } from "./SectionHeader" +import { Section } from "./Section" + +type BrowserSettingsProps = HTMLAttributes & { + browserToolEnabled?: boolean + browserViewportSize?: string + screenshotQuality?: number + setCachedStateField: SetCachedStateField<"browserToolEnabled" | "browserViewportSize" | "screenshotQuality"> +} + +export const BrowserSettings = ({ + browserToolEnabled, + browserViewportSize, + screenshotQuality, + setCachedStateField, + ...props +}: BrowserSettingsProps) => { + return ( +
+ +
+ +
Browser / Computer Use
+
+
+ +
+
+ setCachedStateField("browserToolEnabled", e.target.checked)}> + Enable browser tool + +

+ When enabled, Roo can use a browser to interact with websites when using models that support + computer use. +

+ {browserToolEnabled && ( +
+
+ +
+ { + setCachedStateField("browserViewportSize", (value as DropdownOption).value) + }} + style={{ width: "100%" }} + options={[ + { value: "1280x800", label: "Large Desktop (1280x800)" }, + { value: "900x600", label: "Small Desktop (900x600)" }, + { value: "768x1024", label: "Tablet (768x1024)" }, + { value: "360x640", label: "Mobile (360x640)" }, + ]} + /> +
+

+ Select the viewport size for browser interactions. This affects how websites are + displayed and interacted with. +

+
+
+
+ Screenshot quality +
+ + setCachedStateField("screenshotQuality", parseInt(e.target.value)) + } + /> + {screenshotQuality ?? 75}% +
+
+

+ Adjust the WebP quality of browser screenshots. Higher values provide clearer + screenshots but increase token usage. +

+
+
+ )} +
+
+
+ ) +} diff --git a/webview-ui/src/components/settings/CheckpointSettings.tsx b/webview-ui/src/components/settings/CheckpointSettings.tsx new file mode 100644 index 0000000000..56d5377b18 --- /dev/null +++ b/webview-ui/src/components/settings/CheckpointSettings.tsx @@ -0,0 +1,82 @@ +import { HTMLAttributes } from "react" +import { VSCodeCheckbox, VSCodeRadio, VSCodeRadioGroup } from "@vscode/webview-ui-toolkit/react" +import { GitBranch } from "lucide-react" + +import { CheckpointStorage, isCheckpointStorage } from "../../../../src/shared/checkpoints" + +import { SetCachedStateField } from "./types" +import { SectionHeader } from "./SectionHeader" +import { Section } from "./Section" + +type CheckpointSettingsProps = HTMLAttributes & { + enableCheckpoints?: boolean + checkpointStorage?: CheckpointStorage + setCachedStateField: SetCachedStateField<"enableCheckpoints" | "checkpointStorage"> +} + +export const CheckpointSettings = ({ + enableCheckpoints, + checkpointStorage = "task", + setCachedStateField, + ...props +}: CheckpointSettingsProps) => { + return ( +
+ +
+ +
Checkpoints
+
+
+ +
+
+ { + setCachedStateField("enableCheckpoints", e.target.checked) + }}> + Enable automatic checkpoints + +

+ When enabled, Roo will automatically create checkpoints during task execution, making it easy to + review changes or revert to earlier states. +

+ {enableCheckpoints && ( +
+
Storage
+ { + if ("target" in e) { + const { value } = e.target as HTMLInputElement + + if (isCheckpointStorage(value)) { + setCachedStateField("checkpointStorage", value) + } + } + }}> + Task + Workspace + + {checkpointStorage === "task" && ( +

+ Each task will have it's own dedicated git repository for storing checkpoints. This + provides the best isolation between tasks but uses more disk space. +

+ )} + {checkpointStorage === "workspace" && ( +

+ Each VSCode workspace will have it's own dedicated git repository for storing + checkpoints and tasks within a workspace will share this repository. This option + provides better performance and disk space efficiency. +

+ )} +
+ )} +
+
+
+ ) +} diff --git a/webview-ui/src/components/settings/ExperimentalFeature.tsx b/webview-ui/src/components/settings/ExperimentalFeature.tsx index ee813dd17b..e06bfc513a 100644 --- a/webview-ui/src/components/settings/ExperimentalFeature.tsx +++ b/webview-ui/src/components/settings/ExperimentalFeature.tsx @@ -7,25 +7,14 @@ interface ExperimentalFeatureProps { onChange: (value: boolean) => void } -const ExperimentalFeature = ({ name, description, enabled, onChange }: ExperimentalFeatureProps) => { - return ( -
-
- ⚠️ - onChange(e.target.checked)}> - {name} - -
-

- {description} -

+export const ExperimentalFeature = ({ name, description, enabled, onChange }: ExperimentalFeatureProps) => ( +
+
+ ⚠️ + onChange(e.target.checked)}> + {name} +
- ) -} - -export default ExperimentalFeature +

{description}

+
+) diff --git a/webview-ui/src/components/settings/ExperimentalSettings.tsx b/webview-ui/src/components/settings/ExperimentalSettings.tsx new file mode 100644 index 0000000000..bbf473dbd5 --- /dev/null +++ b/webview-ui/src/components/settings/ExperimentalSettings.tsx @@ -0,0 +1,53 @@ +import { HTMLAttributes } from "react" +import { FlaskConical } from "lucide-react" + +import { EXPERIMENT_IDS, experimentConfigsMap, ExperimentId } from "../../../../src/shared/experiments" + +import { cn } from "@/lib/utils" + +import { SetCachedStateField, SetExperimentEnabled } from "./types" +import { SectionHeader } from "./SectionHeader" +import { Section } from "./Section" +import { ExperimentalFeature } from "./ExperimentalFeature" + +type ExperimentalSettingsProps = HTMLAttributes & { + setCachedStateField: SetCachedStateField< + "rateLimitSeconds" | "terminalOutputLineLimit" | "maxOpenTabsContext" | "diffEnabled" | "fuzzyMatchThreshold" + > + experiments: Record + setExperimentEnabled: SetExperimentEnabled +} + +export const ExperimentalSettings = ({ + setCachedStateField, + experiments, + setExperimentEnabled, + className, + ...props +}: ExperimentalSettingsProps) => { + return ( +
+ +
+ +
Experimental Features
+
+
+ +
+ {Object.entries(experimentConfigsMap) + .filter((config) => config[0] !== "DIFF_STRATEGY") + .map((config) => ( + + setExperimentEnabled(EXPERIMENT_IDS[config[0] as keyof typeof EXPERIMENT_IDS], enabled) + } + /> + ))} +
+
+ ) +} diff --git a/webview-ui/src/components/settings/NotificationSettings.tsx b/webview-ui/src/components/settings/NotificationSettings.tsx new file mode 100644 index 0000000000..1fba9dd412 --- /dev/null +++ b/webview-ui/src/components/settings/NotificationSettings.tsx @@ -0,0 +1,69 @@ +import { HTMLAttributes } from "react" +import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" +import { Bell } from "lucide-react" + +import { SetCachedStateField } from "./types" +import { SectionHeader } from "./SectionHeader" +import { Section } from "./Section" + +type NotificationSettingsProps = HTMLAttributes & { + soundEnabled?: boolean + soundVolume?: number + setCachedStateField: SetCachedStateField<"soundEnabled" | "soundVolume"> +} + +export const NotificationSettings = ({ + soundEnabled, + soundVolume, + setCachedStateField, + ...props +}: NotificationSettingsProps) => { + return ( +
+ +
+ +
Notifications
+
+
+ +
+
+ setCachedStateField("soundEnabled", e.target.checked)}> + Enable sound effects + +

+ When enabled, Roo will play sound effects for notifications and events. +

+ {soundEnabled && ( +
+
+ setCachedStateField("soundVolume", parseFloat(e.target.value))} + className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" + aria-label="Volume" + /> + + {((soundVolume ?? 0.5) * 100).toFixed(0)}% + +
+

Volume

+
+ )} +
+
+
+ ) +} diff --git a/webview-ui/src/components/settings/Section.tsx b/webview-ui/src/components/settings/Section.tsx new file mode 100644 index 0000000000..9c05daf298 --- /dev/null +++ b/webview-ui/src/components/settings/Section.tsx @@ -0,0 +1,9 @@ +import { HTMLAttributes } from "react" + +import { cn } from "@/lib/utils" + +type SectionProps = HTMLAttributes + +export const Section = ({ className, ...props }: SectionProps) => ( +
+) diff --git a/webview-ui/src/components/settings/SectionHeader.tsx b/webview-ui/src/components/settings/SectionHeader.tsx new file mode 100644 index 0000000000..1c7bc8553f --- /dev/null +++ b/webview-ui/src/components/settings/SectionHeader.tsx @@ -0,0 +1,15 @@ +import { HTMLAttributes } from "react" + +import { cn } from "@/lib/utils" + +type SectionHeaderProps = HTMLAttributes & { + children: React.ReactNode + description?: string +} + +export const SectionHeader = ({ description, children, className, ...props }: SectionHeaderProps) => ( +
+

{children}

+ {description &&

{description}

} +
+) diff --git a/webview-ui/src/components/settings/SettingsFooter.tsx b/webview-ui/src/components/settings/SettingsFooter.tsx new file mode 100644 index 0000000000..f7419c9973 --- /dev/null +++ b/webview-ui/src/components/settings/SettingsFooter.tsx @@ -0,0 +1,36 @@ +import { HTMLAttributes } from "react" + +import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react" + +import { vscode } from "@/utils/vscode" +import { cn } from "@/lib/utils" + +type SettingsFooterProps = HTMLAttributes & { + version: string +} + +export const SettingsFooter = ({ version, className, ...props }: SettingsFooterProps) => ( +
+

+ If you have any questions or feedback, feel free to open an issue at{" "} + + github.com/RooVetGit/Roo-Code + {" "} + or join{" "} + + reddit.com/r/RooCode + +

+

Roo Code v{version}

+
+

Reset all global state and secret storage in the extension.

+ vscode.postMessage({ type: "resetState" })} + appearance="secondary" + className="shrink-0"> + + Reset + +
+
+) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 612be3aab3..f03464f0fc 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -1,7 +1,12 @@ import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react" -import { VSCodeButton, VSCodeCheckbox, VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import { Button, Dropdown, type DropdownOption } from "vscrui" +import { Button as VSCodeButton } from "vscrui" +import { CheckCheck, SquareMousePointer, Webhook, GitBranch, Bell, Cog, FlaskConical } from "lucide-react" +import { ExperimentId } from "../../../../src/shared/experiments" +import { ApiConfiguration } from "../../../../src/shared/api" + +import { vscode } from "@/utils/vscode" +import { ExtensionStateContextType, useExtensionState } from "@/context/ExtensionStateContext" import { AlertDialog, AlertDialogContent, @@ -11,37 +16,43 @@ import { AlertDialogAction, AlertDialogHeader, AlertDialogFooter, + Button, } from "@/components/ui" -import { vscode } from "../../utils/vscode" -import { ExtensionStateContextType, useExtensionState } from "../../context/ExtensionStateContext" -import { EXPERIMENT_IDS, experimentConfigsMap, ExperimentId } from "../../../../src/shared/experiments" -import { ApiConfiguration } from "../../../../src/shared/api" - -import ExperimentalFeature from "./ExperimentalFeature" +import { SetCachedStateField, SetExperimentEnabled } from "./types" +import { SectionHeader } from "./SectionHeader" import ApiConfigManager from "./ApiConfigManager" import ApiOptions from "./ApiOptions" - -type SettingsViewProps = { - onDone: () => void -} +import { AutoApproveSettings } from "./AutoApproveSettings" +import { BrowserSettings } from "./BrowserSettings" +import { CheckpointSettings } from "./CheckpointSettings" +import { NotificationSettings } from "./NotificationSettings" +import { AdvancedSettings } from "./AdvancedSettings" +import { SettingsFooter } from "./SettingsFooter" +import { Section } from "./Section" +import { ExperimentalSettings } from "./ExperimentalSettings" export interface SettingsViewRef { checkUnsaveChanges: (then: () => void) => void } +type SettingsViewProps = { + onDone: () => void +} + const SettingsView = forwardRef(({ onDone }, ref) => { const extensionState = useExtensionState() - const [commandInput, setCommandInput] = useState("") + const { currentApiConfigName, listApiConfigMeta, uriScheme, version } = extensionState + const [isDiscardDialogShow, setDiscardDialogShow] = useState(false) - const [cachedState, setCachedState] = useState(extensionState) const [isChangeDetected, setChangeDetected] = useState(false) - const prevApiConfigName = useRef(extensionState.currentApiConfigName) - const confirmDialogHandler = useRef<() => void>() const [errorMessage, setErrorMessage] = useState(undefined) - // TODO: Reduce WebviewMessage/ExtensionState complexity - const { currentApiConfigName } = extensionState + const prevApiConfigName = useRef(currentApiConfigName) + const confirmDialogHandler = useRef<() => void>() + + const [cachedState, setCachedState] = useState(extensionState) + const { alwaysAllowReadOnly, allowedCommands, @@ -54,6 +65,7 @@ const SettingsView = forwardRef(({ onDone }, browserToolEnabled, browserViewportSize, enableCheckpoints, + checkpointStorage, diffEnabled, experiments, fuzzyMatchThreshold, @@ -68,7 +80,7 @@ const SettingsView = forwardRef(({ onDone }, writeDelayMs, } = cachedState - //Make sure apiConfiguration is initialized and managed by SettingsView + // Make sure apiConfiguration is initialized and managed by SettingsView. const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration]) useEffect(() => { @@ -80,24 +92,19 @@ const SettingsView = forwardRef(({ onDone }, setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState })) prevApiConfigName.current = currentApiConfigName - // console.log("useEffect: currentApiConfigName changed, setChangeDetected -> false") setChangeDetected(false) }, [currentApiConfigName, extensionState, isChangeDetected]) - const setCachedStateField = useCallback( - (field: K, value: ExtensionStateContextType[K]) => { - setCachedState((prevState) => { - if (prevState[field] === value) { - return prevState - } + const setCachedStateField: SetCachedStateField = useCallback((field, value) => { + setCachedState((prevState) => { + if (prevState[field] === value) { + return prevState + } - // console.log(`setCachedStateField(${field} -> ${value}): setChangeDetected -> true`) - setChangeDetected(true) - return { ...prevState, [field]: value } - }) - }, - [], - ) + setChangeDetected(true) + return { ...prevState, [field]: value } + }) + }, []) const setApiConfigurationField = useCallback( (field: K, value: ApiConfiguration[K]) => { @@ -106,7 +113,6 @@ const SettingsView = forwardRef(({ onDone }, return prevState } - // console.log(`setApiConfigurationField(${field} -> ${value}): setChangeDetected -> true`) setChangeDetected(true) return { ...prevState, apiConfiguration: { ...prevState.apiConfiguration, [field]: value } } @@ -115,13 +121,12 @@ const SettingsView = forwardRef(({ onDone }, [], ) - const setExperimentEnabled = useCallback((id: ExperimentId, enabled: boolean) => { + const setExperimentEnabled: SetExperimentEnabled = useCallback((id: ExperimentId, enabled: boolean) => { setCachedState((prevState) => { if (prevState.experiments?.[id] === enabled) { return prevState } - // console.log("setExperimentEnabled: setChangeDetected -> true") setChangeDetected(true) return { @@ -146,6 +151,7 @@ const SettingsView = forwardRef(({ onDone }, vscode.postMessage({ type: "soundVolume", value: soundVolume }) vscode.postMessage({ type: "diffEnabled", bool: diffEnabled }) vscode.postMessage({ type: "enableCheckpoints", bool: enableCheckpoints }) + vscode.postMessage({ type: "checkpointStorage", text: checkpointStorage }) vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize }) vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 }) vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs }) @@ -160,7 +166,6 @@ const SettingsView = forwardRef(({ onDone }, vscode.postMessage({ type: "updateExperimental", values: experiments }) vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: alwaysAllowModeSwitch }) vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration }) - // console.log("handleSubmit: setChangeDetected -> false") setChangeDetected(false) } } @@ -185,40 +190,216 @@ const SettingsView = forwardRef(({ onDone }, } }, []) - const handleResetState = () => { - vscode.postMessage({ type: "resetState" }) - } + const providersRef = useRef(null) + const autoApproveRef = useRef(null) + const browserRef = useRef(null) + const checkpointRef = useRef(null) + const notificationsRef = useRef(null) + const advancedRef = useRef(null) + const experimentalRef = useRef(null) - const handleAddCommand = () => { - const currentCommands = allowedCommands ?? [] - if (commandInput && !currentCommands.includes(commandInput)) { - const newCommands = [...currentCommands, commandInput] - setCachedStateField("allowedCommands", newCommands) - setCommandInput("") - vscode.postMessage({ type: "allowedCommands", commands: newCommands }) + const [activeSection, setActiveSection] = useState("providers") + + const sections = useMemo( + () => [ + { id: "providers", icon: Webhook, ref: providersRef }, + { id: "autoApprove", icon: CheckCheck, ref: autoApproveRef }, + { id: "browser", icon: SquareMousePointer, ref: browserRef }, + { id: "checkpoint", icon: GitBranch, ref: checkpointRef }, + { id: "notifications", icon: Bell, ref: notificationsRef }, + { id: "advanced", icon: Cog, ref: advancedRef }, + { id: "experimental", icon: FlaskConical, ref: experimentalRef }, + ], + [providersRef, autoApproveRef, browserRef, checkpointRef, notificationsRef, advancedRef, experimentalRef], + ) + + const handleScroll = useCallback((e: React.UIEvent) => { + const sections = [ + { ref: providersRef, id: "providers" }, + { ref: autoApproveRef, id: "autoApprove" }, + { ref: browserRef, id: "browser" }, + { ref: checkpointRef, id: "checkpoint" }, + { ref: notificationsRef, id: "notifications" }, + { ref: advancedRef, id: "advanced" }, + { ref: experimentalRef, id: "experimental" }, + ] + + for (const section of sections) { + const element = section.ref.current + + if (element) { + const { top } = element.getBoundingClientRect() + + if (top >= 0 && top <= 50) { + setActiveSection(section.id) + break + } + } } - } + }, []) - const sliderLabelStyle = { - minWidth: "45px", - textAlign: "right" as const, - lineHeight: "20px", - paddingBottom: "2px", - } + const scrollToSection = (ref: React.RefObject) => ref.current?.scrollIntoView() return ( -
+
+
+
+
+
+

Settings

+
+ {sections.map(({ id, icon: Icon, ref }) => ( + + ))} +
+
+
+ + Save + + checkUnsaveChanges(onDone)}> + Done + +
+
+
+
+ +
+
+ +
+ +
Providers
+
+
+ +
+ + 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, + }) + } + /> + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ @@ -234,656 +415,6 @@ const SettingsView = forwardRef(({ onDone }, -
-

Settings

-
- - checkUnsaveChanges(onDone)}> - Done - -
-
-
-
-

Provider Settings

-
- { - 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, - }) - }} - /> - -
-
- -
-

Auto-Approve Settings

-

- The following settings allow Roo to automatically perform operations without requiring approval. - Enable these settings only if you fully trust the AI and understand the associated security - risks. -

- -
- setCachedStateField("alwaysAllowReadOnly", e.target.checked)}> - Always approve read-only operations - -

- When enabled, Roo will automatically view directory contents and read files without - requiring you to click the Approve button. -

-
- -
- setCachedStateField("alwaysAllowWrite", e.target.checked)}> - Always approve write operations - -

- Automatically create and edit files without requiring approval -

- {alwaysAllowWrite && ( -
-
- setCachedStateField("writeDelayMs", parseInt(e.target.value))} - className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" - /> - {writeDelayMs}ms -
-

- Delay after writes to allow diagnostics to detect potential problems -

-
- )} -
- -
- setCachedStateField("alwaysAllowBrowser", e.target.checked)}> - Always approve browser actions - -

- Automatically perform browser actions without requiring approval -
- Note: Only applies when the model supports computer use -

-
- -
- setCachedStateField("alwaysApproveResubmit", e.target.checked)}> - Always retry failed API requests - -

- Automatically retry failed API requests when server returns an error response -

- {alwaysApproveResubmit && ( -
-
- - setCachedStateField("requestDelaySeconds", parseInt(e.target.value)) - } - className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" - /> - {requestDelaySeconds}s -
-

- Delay before retrying the request -

-
- )} -
- -
- setCachedStateField("alwaysAllowMcp", e.target.checked)}> - Always approve MCP tools - -

- Enable auto-approval of individual MCP tools in the MCP Servers view (requires both this - setting and the tool's individual "Always allow" checkbox) -

-
- -
- setCachedStateField("alwaysAllowModeSwitch", e.target.checked)}> - Always approve mode switching & task creation - -

- Automatically switch between different AI modes and create new tasks without requiring - approval -

-
- -
- setCachedStateField("alwaysAllowExecute", e.target.checked)}> - Always approve allowed execute operations - -

- Automatically execute allowed terminal commands without requiring approval -

- - {alwaysAllowExecute && ( -
- Allowed Auto-Execute Commands -

- Command prefixes that can be auto-executed when "Always approve execute operations" - is enabled. Add * to allow all commands (use with caution). -

- -
- setCommandInput(e.target.value)} - onKeyDown={(e: any) => { - if (e.key === "Enter") { - e.preventDefault() - handleAddCommand() - } - }} - placeholder="Enter command prefix (e.g., 'git ')" - style={{ flexGrow: 1 }} - /> - Add -
- -
- {(allowedCommands ?? []).map((cmd, index) => ( -
- {cmd} - { - const newCommands = (allowedCommands ?? []).filter( - (_, i) => i !== index, - ) - setCachedStateField("allowedCommands", newCommands) - vscode.postMessage({ - type: "allowedCommands", - commands: newCommands, - }) - }}> - - -
- ))} -
-
- )} -
-
- -
-

Browser Settings

-
- setCachedStateField("browserToolEnabled", e.target.checked)}> - Enable browser tool - -

- When enabled, Roo can use a browser to interact with websites when using models that support - computer use. -

-
- {browserToolEnabled && ( -
-
- -
- { - setCachedStateField("browserViewportSize", (value as DropdownOption).value) - }} - style={{ width: "100%" }} - options={[ - { value: "1280x800", label: "Large Desktop (1280x800)" }, - { value: "900x600", label: "Small Desktop (900x600)" }, - { value: "768x1024", label: "Tablet (768x1024)" }, - { value: "360x640", label: "Mobile (360x640)" }, - ]} - /> -
-

- Select the viewport size for browser interactions. This affects how websites are - displayed and interacted with. -

-
- -
-
- Screenshot quality -
- - setCachedStateField("screenshotQuality", parseInt(e.target.value)) - } - /> - {screenshotQuality ?? 75}% -
-
-

- Adjust the WebP quality of browser screenshots. Higher values provide clearer - screenshots but increase token usage. -

-
-
- )} -
- -
-

Notification Settings

-
- setCachedStateField("soundEnabled", e.target.checked)}> - Enable sound effects - -

- When enabled, Roo will play sound effects for notifications and events. -

-
- {soundEnabled && ( -
-
- Volume - setCachedStateField("soundVolume", parseFloat(e.target.value))} - className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" - aria-label="Volume" - /> - - {((soundVolume ?? 0.5) * 100).toFixed(0)}% - -
-
- )} -
- -
-

Advanced Settings

-
-
- Rate limit -
- setCachedStateField("rateLimitSeconds", parseInt(e.target.value))} - className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" - /> - {rateLimitSeconds}s -
-
-

- Minimum time between API requests. -

-
-
-
- Terminal output limit -
- - setCachedStateField("terminalOutputLineLimit", parseInt(e.target.value)) - } - className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" - /> - {terminalOutputLineLimit ?? 500} -
-
-

- Maximum number of lines to include in terminal output when executing commands. When exceeded - lines will be removed from the middle, saving tokens. -

-
- -
-
- Open tabs context limit -
- - setCachedStateField("maxOpenTabsContext", parseInt(e.target.value)) - } - className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" - /> - {maxOpenTabsContext ?? 20} -
-
-

- Maximum number of VSCode open tabs to include in context. Higher values provide more context - but increase token usage. -

-
- -
- { - setCachedStateField("enableCheckpoints", e.target.checked) - }}> - Enable automatic checkpoints - -

- When enabled, Roo will automatically create checkpoints during task execution, making it - easy to review changes or revert to earlier states. -

-
- -
- { - setCachedStateField("diffEnabled", e.target.checked) - if (!e.target.checked) { - // Reset experimental strategy when diffs are disabled - setExperimentEnabled(EXPERIMENT_IDS.DIFF_STRATEGY, false) - } - }}> - Enable editing through diffs - -

- When enabled, Roo will be able to edit files more quickly and will automatically reject - truncated full-file writes. Works best with the latest Claude 3.7 Sonnet model. -

- - {diffEnabled && ( -
-
- Match precision -
- { - setCachedStateField("fuzzyMatchThreshold", parseFloat(e.target.value)) - }} - className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" - /> - - {Math.round((fuzzyMatchThreshold || 1) * 100)}% - -
-

- This slider controls how precisely code sections must match when applying diffs. - Lower values allow more flexible matching but increase the risk of incorrect - replacements. Use values below 100% with extreme caution. -

- - setExperimentEnabled(EXPERIMENT_IDS.DIFF_STRATEGY, enabled) - } - /> -
-
- )} - - {Object.entries(experimentConfigsMap) - .filter((config) => config[0] !== "DIFF_STRATEGY") - .map((config) => ( - - setExperimentEnabled( - EXPERIMENT_IDS[config[0] as keyof typeof EXPERIMENT_IDS], - enabled, - ) - } - /> - ))} -
-
- -
-

- If you have any questions or feedback, feel free to open an issue at{" "} - - github.com/RooVetGit/Roo-Code - {" "} - or join{" "} - - reddit.com/r/RooCode - -

-

- v{extensionState.version} -

- -

- This will reset all global state and secret storage in the extension. -

- - - Reset State - -
-
) }) diff --git a/webview-ui/src/components/settings/TemperatureControl.tsx b/webview-ui/src/components/settings/TemperatureControl.tsx index cbafcc5520..dd8ccc8e58 100644 --- a/webview-ui/src/components/settings/TemperatureControl.tsx +++ b/webview-ui/src/components/settings/TemperatureControl.tsx @@ -32,10 +32,10 @@ export const TemperatureControl = ({ value, onChange, maxValue = 1 }: Temperatur setInputValue(value ?? 0) // Use the value from apiConfiguration, if set } }}> - Use custom temperature + Use custom temperature -

+

Controls randomness in the model's responses.

@@ -59,7 +59,7 @@ export const TemperatureControl = ({ value, onChange, maxValue = 1 }: Temperatur /> {inputValue}
-

+

Higher values make output more random, lower values make it more deterministic.

diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx index 99bc0a87c2..5e5defec59 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx @@ -1,3 +1,5 @@ +// npx jest src/components/settings/__tests__/SettingsView.test.ts + import { render, screen, fireEvent } from "@testing-library/react" import SettingsView from "../SettingsView" import { ExtensionStateContextProvider } from "../../../context/ExtensionStateContext" @@ -10,6 +12,22 @@ jest.mock("../../../utils/vscode", () => ({ }, })) +// Mock all lucide-react icons with a proxy to handle any icon requested +jest.mock("lucide-react", () => { + return new Proxy( + {}, + { + get: function (obj, prop) { + // Return a component factory for any icon that's requested + if (prop === "__esModule") { + return true + } + return () =>
{String(prop)}
+ }, + }, + ) +}) + // Mock ApiConfigManager component jest.mock("../ApiConfigManager", () => ({ __esModule: true, diff --git a/webview-ui/src/components/settings/styles.ts b/webview-ui/src/components/settings/styles.ts index 85b50579fb..75885c60f4 100644 --- a/webview-ui/src/components/settings/styles.ts +++ b/webview-ui/src/components/settings/styles.ts @@ -1,7 +1,5 @@ import styled from "styled-components" -export const DROPDOWN_Z_INDEX = 1_000 - export const DropdownWrapper = styled.div` position: relative; width: 100%; @@ -78,3 +76,10 @@ export const StyledMarkdown = styled.div` } } ` + +export const sliderLabelStyle = { + minWidth: "45px", + textAlign: "right" as const, + lineHeight: "20px", + paddingBottom: "2px", +} diff --git a/webview-ui/src/components/settings/types.ts b/webview-ui/src/components/settings/types.ts new file mode 100644 index 0000000000..234b382be5 --- /dev/null +++ b/webview-ui/src/components/settings/types.ts @@ -0,0 +1,10 @@ +import { ExperimentId } from "../../../../src/shared/experiments" + +import { ExtensionStateContextType } from "@/context/ExtensionStateContext" + +export type SetCachedStateField = ( + field: K, + value: ExtensionStateContextType[K], +) => void + +export type SetExperimentEnabled = (id: ExperimentId, enabled: boolean) => void diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index ba735f27b2..bee73c1a36 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -107,6 +107,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode soundVolume: 0.5, diffEnabled: false, enableCheckpoints: true, + checkpointStorage: "task", fuzzyMatchThreshold: 1.0, preferredLanguage: "English", writeDelayMs: 1000, diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx index aca1f26bcf..1e7119130f 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx @@ -79,6 +79,7 @@ describe("mergeExtensionState", () => { taskHistory: [], shouldShowAnnouncement: false, enableCheckpoints: true, + checkpointStorage: "task", preferredLanguage: "English", writeDelayMs: 1000, requestDelaySeconds: 5, diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index fd058872a6..5276384b11 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -96,6 +96,10 @@ --color-vscode-list-hoverForeground: var(--vscode-list-hoverForeground); --color-vscode-list-hoverBackground: var(--vscode-list-hoverBackground); --color-vscode-list-focusBackground: var(--vscode-list-focusBackground); + + --color-vscode-toolbar-hoverBackground: var(--vscode-toolbar-hoverBackground); + + --color-vscode-panel-border: var(--vscode-panel-border); } @layer base { From 93a394dd936f8583beffcd325155c9f7dab1a045 Mon Sep 17 00:00:00 2001 From: cte Date: Tue, 4 Mar 2025 15:32:32 -0800 Subject: [PATCH 09/14] Disable terminal actions for now --- src/extension.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/extension.ts b/src/extension.ts index a05afa4651..b85c401117 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -81,7 +81,12 @@ export function activate(context: vscode.ExtensionContext) { ) registerCodeActions(context) - registerTerminalActions(context) + + /** + * Temporary disabled until we have a better way to share the terminal + * manager. + */ + // registerTerminalActions(context) return createClineAPI(outputChannel, sidebarProvider) } From 75dcc2ffcf775f33ca7bb87b46f5397c8357acfc Mon Sep 17 00:00:00 2001 From: cte Date: Tue, 4 Mar 2025 15:38:14 -0800 Subject: [PATCH 10/14] Remove terminal actions --- package.json | 47 -------------- src/activate/index.ts | 1 - src/activate/registerTerminalActions.ts | 81 ------------------------- src/extension.ts | 10 +-- 4 files changed, 2 insertions(+), 137 deletions(-) delete mode 100644 src/activate/registerTerminalActions.ts diff --git a/package.json b/package.json index 68767cbed5..136c91cfa4 100644 --- a/package.json +++ b/package.json @@ -128,31 +128,6 @@ "command": "roo-cline.addToContext", "title": "Roo Code: Add To Context", "category": "Roo Code" - }, - { - "command": "roo-cline.terminalAddToContext", - "title": "Roo Code: Add Terminal Content to Context", - "category": "Terminal" - }, - { - "command": "roo-cline.terminalFixCommand", - "title": "Roo Code: Fix This Command", - "category": "Terminal" - }, - { - "command": "roo-cline.terminalExplainCommand", - "title": "Roo Code: Explain This Command", - "category": "Terminal" - }, - { - "command": "roo-cline.terminalFixCommandInCurrentTask", - "title": "Roo Code: Fix This Command (Current Task)", - "category": "Terminal" - }, - { - "command": "roo-cline.terminalExplainCommandInCurrentTask", - "title": "Roo Code: Explain This Command (Current Task)", - "category": "Terminal" } ], "menus": { @@ -178,28 +153,6 @@ "group": "Roo Code@4" } ], - "terminal/context": [ - { - "command": "roo-cline.terminalAddToContext", - "group": "Roo Code@1" - }, - { - "command": "roo-cline.terminalFixCommand", - "group": "Roo Code@2" - }, - { - "command": "roo-cline.terminalExplainCommand", - "group": "Roo Code@3" - }, - { - "command": "roo-cline.terminalFixCommandInCurrentTask", - "group": "Roo Code@5" - }, - { - "command": "roo-cline.terminalExplainCommandInCurrentTask", - "group": "Roo Code@6" - } - ], "view/title": [ { "command": "roo-cline.plusButtonClicked", diff --git a/src/activate/index.ts b/src/activate/index.ts index 658bf467f7..76eebd185e 100644 --- a/src/activate/index.ts +++ b/src/activate/index.ts @@ -1,4 +1,3 @@ export { handleUri } from "./handleUri" export { registerCommands } from "./registerCommands" export { registerCodeActions } from "./registerCodeActions" -export { registerTerminalActions } from "./registerTerminalActions" diff --git a/src/activate/registerTerminalActions.ts b/src/activate/registerTerminalActions.ts deleted file mode 100644 index fbf2a0510c..0000000000 --- a/src/activate/registerTerminalActions.ts +++ /dev/null @@ -1,81 +0,0 @@ -import * as vscode from "vscode" -import { ClineProvider } from "../core/webview/ClineProvider" -import { TerminalManager } from "../integrations/terminal/TerminalManager" - -const TERMINAL_COMMAND_IDS = { - ADD_TO_CONTEXT: "roo-cline.terminalAddToContext", - FIX: "roo-cline.terminalFixCommand", - FIX_IN_CURRENT_TASK: "roo-cline.terminalFixCommandInCurrentTask", - EXPLAIN: "roo-cline.terminalExplainCommand", - EXPLAIN_IN_CURRENT_TASK: "roo-cline.terminalExplainCommandInCurrentTask", -} as const - -export const registerTerminalActions = (context: vscode.ExtensionContext) => { - const terminalManager = new TerminalManager() - - registerTerminalAction(context, terminalManager, TERMINAL_COMMAND_IDS.ADD_TO_CONTEXT, "TERMINAL_ADD_TO_CONTEXT") - - registerTerminalActionPair( - context, - terminalManager, - TERMINAL_COMMAND_IDS.FIX, - "TERMINAL_FIX", - "What would you like Roo to fix?", - ) - - registerTerminalActionPair( - context, - terminalManager, - TERMINAL_COMMAND_IDS.EXPLAIN, - "TERMINAL_EXPLAIN", - "What would you like Roo to explain?", - ) -} - -const registerTerminalAction = ( - context: vscode.ExtensionContext, - terminalManager: TerminalManager, - command: string, - promptType: "TERMINAL_ADD_TO_CONTEXT" | "TERMINAL_FIX" | "TERMINAL_EXPLAIN", - inputPrompt?: string, -) => { - context.subscriptions.push( - vscode.commands.registerCommand(command, async (args: any) => { - let content = args.selection - if (!content || content === "") { - content = await terminalManager.getTerminalContents(promptType === "TERMINAL_ADD_TO_CONTEXT" ? -1 : 1) - } - - if (!content) { - vscode.window.showWarningMessage("No terminal content selected") - return - } - - const params: Record = { - terminalContent: content, - } - - if (inputPrompt) { - params.userInput = - (await vscode.window.showInputBox({ - prompt: inputPrompt, - })) ?? "" - } - - await ClineProvider.handleTerminalAction(command, promptType, params) - }), - ) -} - -const registerTerminalActionPair = ( - context: vscode.ExtensionContext, - terminalManager: TerminalManager, - baseCommand: string, - promptType: "TERMINAL_ADD_TO_CONTEXT" | "TERMINAL_FIX" | "TERMINAL_EXPLAIN", - inputPrompt?: string, -) => { - // Register new task version - registerTerminalAction(context, terminalManager, baseCommand, promptType, inputPrompt) - // Register current task version - registerTerminalAction(context, terminalManager, `${baseCommand}InCurrentTask`, promptType, inputPrompt) -} diff --git a/src/extension.ts b/src/extension.ts index b85c401117..3a91e990a3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,7 +5,7 @@ import { createClineAPI } from "./exports" import "./utils/path" // Necessary to have access to String.prototype.toPosix. import { CodeActionProvider } from "./core/CodeActionProvider" import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider" -import { handleUri, registerCommands, registerCodeActions, registerTerminalActions } from "./activate" +import { handleUri, registerCommands, registerCodeActions } from "./activate" import { McpServerManager } from "./services/mcp/McpServerManager" /** @@ -82,16 +82,10 @@ export function activate(context: vscode.ExtensionContext) { registerCodeActions(context) - /** - * Temporary disabled until we have a better way to share the terminal - * manager. - */ - // registerTerminalActions(context) - return createClineAPI(outputChannel, sidebarProvider) } -// This method is called when your extension is deactivated +// This method is called when your extension is deactivated. export async function deactivate() { outputChannel.appendLine("Roo-Code extension deactivated") // Clean up MCP server manager From 589387ba65f058575670226ce8e8161f20dde72d Mon Sep 17 00:00:00 2001 From: refactorthis Date: Sun, 2 Mar 2025 18:08:18 +1100 Subject: [PATCH 11/14] feat: add x-title and http-referer header to all openai providers - Provides the ability for Open AI compatible gateways, such as LiteLLM, Open Router, Requesty to determine originating app. - Uses standard set by Open Router. --- .changeset/wise-pears-join.md | 5 +++++ src/api/providers/__tests__/openai.test.ts | 14 ++++++++++++++ src/api/providers/openai.ts | 11 ++++++++--- src/api/providers/openrouter.ts | 6 +----- src/api/providers/requesty.ts | 4 ---- 5 files changed, 28 insertions(+), 12 deletions(-) create mode 100644 .changeset/wise-pears-join.md diff --git a/.changeset/wise-pears-join.md b/.changeset/wise-pears-join.md new file mode 100644 index 0000000000..46c019b92e --- /dev/null +++ b/.changeset/wise-pears-join.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Improved observability of openai compatible APIs, by sending x-title and http-referer headers, as per Open Router standard. diff --git a/src/api/providers/__tests__/openai.test.ts b/src/api/providers/__tests__/openai.test.ts index 5b5da20f51..43634b5862 100644 --- a/src/api/providers/__tests__/openai.test.ts +++ b/src/api/providers/__tests__/openai.test.ts @@ -90,6 +90,20 @@ describe("OpenAiHandler", () => { }) expect(handlerWithCustomUrl).toBeInstanceOf(OpenAiHandler) }) + + it("should set default headers correctly", () => { + // Get the mock constructor from the jest mock system + const openAiMock = jest.requireMock("openai").default + + expect(openAiMock).toHaveBeenCalledWith({ + baseURL: expect.any(String), + apiKey: expect.any(String), + defaultHeaders: { + "HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline", + "X-Title": "Roo Code", + }, + }) + }) }) describe("createMessage", () => { diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 0fa833e82a..9262f3b75a 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -16,10 +16,14 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { BaseProvider } from "./base-provider" const DEEP_SEEK_DEFAULT_TEMPERATURE = 0.6 -export interface OpenAiHandlerOptions extends ApiHandlerOptions { - defaultHeaders?: Record + +export const defaultHeaders = { + "HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline", + "X-Title": "Roo Code", } +export interface OpenAiHandlerOptions extends ApiHandlerOptions {} + export class OpenAiHandler extends BaseProvider implements SingleCompletionHandler { protected options: OpenAiHandlerOptions private client: OpenAI @@ -47,9 +51,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl baseURL, apiKey, apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion, + defaultHeaders, }) } else { - this.client = new OpenAI({ baseURL, apiKey, defaultHeaders: this.options.defaultHeaders }) + this.client = new OpenAI({ baseURL, apiKey, defaultHeaders }) } } diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index db5c094d02..7d3992caa5 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -13,6 +13,7 @@ import { convertToR1Format } from "../transform/r1-format" import { DEEP_SEEK_DEFAULT_TEMPERATURE } from "./constants" import { getModelParams, SingleCompletionHandler } from ".." import { BaseProvider } from "./base-provider" +import { defaultHeaders } from "./openai" // Add custom interface for OpenRouter params. type OpenRouterChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & { @@ -37,11 +38,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH const baseURL = this.options.openRouterBaseUrl || "https://openrouter.ai/api/v1" const apiKey = this.options.openRouterApiKey ?? "not-provided" - const defaultHeaders = { - "HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline", - "X-Title": "Roo Code", - } - this.client = new OpenAI({ baseURL, apiKey, defaultHeaders }) } diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 5e570ca2a2..27187c5d33 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -16,10 +16,6 @@ export class RequestyHandler extends OpenAiHandler { openAiModelId: options.requestyModelId ?? requestyDefaultModelId, openAiBaseUrl: "https://router.requesty.ai/v1", openAiCustomModelInfo: options.requestyModelInfo ?? requestyModelInfoSaneDefaults, - defaultHeaders: { - "HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline", - "X-Title": "Roo Code", - }, }) } From f1de71429f3ca9fefcbf7aaa3351f60399580547 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Wed, 5 Mar 2025 09:22:40 -0500 Subject: [PATCH 12/14] PR feedback --- src/core/__tests__/contextProxy.test.ts | 16 ---------------- src/core/webview/ClineProvider.ts | 15 ++++----------- 2 files changed, 4 insertions(+), 27 deletions(-) diff --git a/src/core/__tests__/contextProxy.test.ts b/src/core/__tests__/contextProxy.test.ts index e6f1bfc9ca..9f0c20b0c4 100644 --- a/src/core/__tests__/contextProxy.test.ts +++ b/src/core/__tests__/contextProxy.test.ts @@ -3,16 +3,6 @@ import { ContextProxy } from "../contextProxy" import { logger } from "../../utils/logging" import { GLOBAL_STATE_KEYS, SECRET_KEYS } from "../../shared/globalState" -// Mock the logger -jest.mock("../../utils/logging", () => ({ - logger: { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, -})) - // Mock shared/globalState jest.mock("../../shared/globalState", () => ({ GLOBAL_STATE_KEYS: ["apiProvider", "apiModelId", "mode"], @@ -121,9 +111,6 @@ describe("ContextProxy", () => { it("should update state directly in original context", async () => { await proxy.updateGlobalState("test-key", "new-value") - // Should have called logger.debug - expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("updating state for key")) - // Should have called original context expect(mockGlobalState.update).toHaveBeenCalledWith("test-key", "new-value") @@ -148,9 +135,6 @@ describe("ContextProxy", () => { it("should store secret directly in original context", async () => { await proxy.storeSecret("api-key", "new-secret") - // Should have called logger.debug - expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("storing secret for key")) - // Should have called original context expect(mockSecrets.store).toHaveBeenCalledWith("api-key", "new-secret") diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 748ba2525b..4c3068eac6 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -118,7 +118,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.mcpHub = undefined this.customModesManager?.dispose() this.outputChannel.appendLine("Disposed all disposables") - // Dispose the context proxy to commit any pending changes ClineProvider.activeInstances.delete(this) // Unregister from McpServerManager @@ -2081,24 +2080,20 @@ export class ClineProvider implements vscode.WebviewViewProvider { const customModesPromise = this.customModesManager.getCustomModes() let idx = 0 - const secretValuesArray = await Promise.all([ - ...statePromises, - ...secretPromises, - customModesPromise, - ]) + const valuePromises = await Promise.all([...statePromises, ...secretPromises, customModesPromise]) // Populate stateValues and secretValues GLOBAL_STATE_KEYS.forEach((key, _) => { - stateValues[key] = secretValuesArray[idx] + stateValues[key] = valuePromises[idx] idx = idx + 1 }) SECRET_KEYS.forEach((key, index) => { - secretValues[key] = secretValuesArray[idx] + secretValues[key] = valuePromises[idx] idx = idx + 1 }) - let customModes = secretValuesArray[idx] as ModeConfig[] | undefined + let customModes = valuePromises[idx] as ModeConfig[] | undefined // Determine apiProvider with the same logic as before let apiProvider: ApiProvider @@ -2219,7 +2214,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { // global async updateGlobalState(key: GlobalStateKey, value: any) { - this.outputChannel.appendLine(`Updating global state: ${key}`) await this.contextProxy.updateGlobalState(key, value) } @@ -2230,7 +2224,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { // secrets public async storeSecret(key: SecretKey, value?: string) { - this.outputChannel.appendLine(`Storing secret: ${key}`) await this.contextProxy.storeSecret(key, value) } From c3da5b0aa380082eb6c64c3ded039ac3e75d3c38 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Wed, 5 Mar 2025 09:43:24 -0500 Subject: [PATCH 13/14] More cleanup --- .../webview/__tests__/ClineProvider.test.ts | 14 --- src/shared/globalState.ts | 108 ++---------------- 2 files changed, 12 insertions(+), 110 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 68739e8f06..3ef024afb3 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -182,16 +182,6 @@ jest.mock("../../../utils/sound", () => ({ setSoundEnabled: jest.fn(), })) -// Mock logger -jest.mock("../../../utils/logging", () => ({ - logger: { - debug: jest.fn(), - error: jest.fn(), - warn: jest.fn(), - info: jest.fn(), - }, -})) - // Mock ESM modules jest.mock("p-wait-for", () => ({ __esModule: true, @@ -1527,7 +1517,6 @@ describe("ClineProvider", () => { apiConfiguration: testApiConfig, }) - // Reset jest.mock calls tracking // Verify config was saved expect(provider.configManager.saveConfig).toHaveBeenCalledWith("test-config", testApiConfig) @@ -1538,9 +1527,6 @@ describe("ClineProvider", () => { expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith("listApiConfigMeta", [ { name: "test-config", id: "test-id", apiProvider: "anthropic" }, ]) - - // Reset jest.mock calls tracking for subsequent tests - jest.clearAllMocks() }) }) }) diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index bdc263735a..fd7bd1adb9 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -1,19 +1,5 @@ -export type SecretKey = - | "apiKey" - | "glamaApiKey" - | "openRouterApiKey" - | "awsAccessKey" - | "awsSecretKey" - | "awsSessionToken" - | "openAiApiKey" - | "geminiApiKey" - | "openAiNativeApiKey" - | "deepSeekApiKey" - | "mistralApiKey" - | "unboundApiKey" - | "requestyApiKey" - -export const SECRET_KEYS: SecretKey[] = [ +// Define the array first with 'as const' to create a readonly tuple type +export const SECRET_KEYS = [ "apiKey", "glamaApiKey", "openRouterApiKey", @@ -27,87 +13,13 @@ export const SECRET_KEYS: SecretKey[] = [ "mistralApiKey", "unboundApiKey", "requestyApiKey", -] +] as const -export type GlobalStateKey = - | "apiProvider" - | "apiModelId" - | "glamaModelId" - | "glamaModelInfo" - | "awsRegion" - | "awsUseCrossRegionInference" - | "awsProfile" - | "awsUseProfile" - | "vertexProjectId" - | "vertexRegion" - | "lastShownAnnouncementId" - | "customInstructions" - | "alwaysAllowReadOnly" - | "alwaysAllowWrite" - | "alwaysAllowExecute" - | "alwaysAllowBrowser" - | "alwaysAllowMcp" - | "alwaysAllowModeSwitch" - | "taskHistory" - | "openAiBaseUrl" - | "openAiModelId" - | "openAiCustomModelInfo" - | "openAiUseAzure" - | "ollamaModelId" - | "ollamaBaseUrl" - | "lmStudioModelId" - | "lmStudioBaseUrl" - | "lmStudioDraftModelId" - | "lmStudioSpeculativeDecodingEnabled" - | "anthropicBaseUrl" - | "azureApiVersion" - | "openAiStreamingEnabled" - | "openRouterModelId" - | "openRouterModelInfo" - | "openRouterBaseUrl" - | "openRouterUseMiddleOutTransform" - | "allowedCommands" - | "soundEnabled" - | "soundVolume" - | "diffEnabled" - | "enableCheckpoints" - | "checkpointStorage" - | "browserViewportSize" - | "screenshotQuality" - | "fuzzyMatchThreshold" - | "preferredLanguage" // Language setting for Cline's communication - | "writeDelayMs" - | "terminalOutputLineLimit" - | "mcpEnabled" - | "enableMcpServerCreation" - | "alwaysApproveResubmit" - | "requestDelaySeconds" - | "rateLimitSeconds" - | "currentApiConfigName" - | "listApiConfigMeta" - | "vsCodeLmModelSelector" - | "mode" - | "modeApiConfigs" - | "customModePrompts" - | "customSupportPrompts" - | "enhancementApiConfigId" - | "experiments" // Map of experiment IDs to their enabled state - | "autoApprovalEnabled" - | "customModes" // Array of custom modes - | "unboundModelId" - | "requestyModelId" - | "requestyModelInfo" - | "unboundModelInfo" - | "modelTemperature" - | "modelMaxTokens" - | "modelMaxThinkingTokens" - | "mistralCodestralUrl" - | "maxOpenTabsContext" - | "browserToolEnabled" - | "lmStudioSpeculativeDecodingEnabled" - | "lmStudioDraftModelId" +// Derive the type from the array - creates a union of string literals +export type SecretKey = (typeof SECRET_KEYS)[number] -export const GLOBAL_STATE_KEYS: GlobalStateKey[] = [ +// Define the array first with 'as const' to create a readonly tuple type +export const GLOBAL_STATE_KEYS = [ "apiProvider", "apiModelId", "glamaModelId", @@ -148,6 +60,7 @@ export const GLOBAL_STATE_KEYS: GlobalStateKey[] = [ "soundVolume", "diffEnabled", "enableCheckpoints", + "checkpointStorage", "browserViewportSize", "screenshotQuality", "fuzzyMatchThreshold", @@ -181,4 +94,7 @@ export const GLOBAL_STATE_KEYS: GlobalStateKey[] = [ "browserToolEnabled", "lmStudioSpeculativeDecodingEnabled", "lmStudioDraftModelId", -] +] as const + +// Derive the type from the array - creates a union of string literals +export type GlobalStateKey = (typeof GLOBAL_STATE_KEYS)[number] From 86401faa37a56c0b6de706c862823601b39350f7 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Wed, 5 Mar 2025 10:04:48 -0500 Subject: [PATCH 14/14] Better encapsulation for API config --- src/core/__tests__/contextProxy.test.ts | 86 +++++++++++++++++++ src/core/contextProxy.ts | 62 ++++++++++++- src/core/webview/ClineProvider.ts | 72 ++++------------ .../webview/__tests__/ClineProvider.test.ts | 61 +++++++++++++ 4 files changed, 222 insertions(+), 59 deletions(-) diff --git a/src/core/__tests__/contextProxy.test.ts b/src/core/__tests__/contextProxy.test.ts index 9f0c20b0c4..ef0c4333e0 100644 --- a/src/core/__tests__/contextProxy.test.ts +++ b/src/core/__tests__/contextProxy.test.ts @@ -2,11 +2,20 @@ import * as vscode from "vscode" import { ContextProxy } from "../contextProxy" import { logger } from "../../utils/logging" import { GLOBAL_STATE_KEYS, SECRET_KEYS } from "../../shared/globalState" +import { ApiConfiguration } from "../../shared/api" // Mock shared/globalState jest.mock("../../shared/globalState", () => ({ GLOBAL_STATE_KEYS: ["apiProvider", "apiModelId", "mode"], SECRET_KEYS: ["apiKey", "openAiApiKey"], + GlobalStateKey: {}, + SecretKey: {}, +})) + +// Mock shared/api +jest.mock("../../shared/api", () => ({ + API_CONFIG_KEYS: ["apiProvider", "apiModelId"], + ApiConfiguration: {}, })) // Mock VSCode API @@ -153,5 +162,82 @@ describe("ContextProxy", () => { const storedValue = await proxy.getSecret("api-key") expect(storedValue).toBeUndefined() }) + + describe("getApiConfiguration", () => { + it("should combine global state and secrets into a single ApiConfiguration object", async () => { + // Mock data in state cache + await proxy.updateGlobalState("apiProvider", "anthropic") + await proxy.updateGlobalState("apiModelId", "test-model") + // Mock data in secrets cache + await proxy.storeSecret("apiKey", "test-api-key") + + const config = proxy.getApiConfiguration() + + // Should contain values from global state + expect(config.apiProvider).toBe("anthropic") + expect(config.apiModelId).toBe("test-model") + // Should contain values from secrets + expect(config.apiKey).toBe("test-api-key") + }) + + it("should handle special case for apiProvider defaulting", async () => { + // Clear apiProvider but set apiKey + await proxy.updateGlobalState("apiProvider", undefined) + await proxy.storeSecret("apiKey", "test-api-key") + + const config = proxy.getApiConfiguration() + + // Should default to anthropic when apiKey exists + expect(config.apiProvider).toBe("anthropic") + + // Clear both apiProvider and apiKey + await proxy.updateGlobalState("apiProvider", undefined) + await proxy.storeSecret("apiKey", undefined) + + const configWithoutKey = proxy.getApiConfiguration() + + // Should default to openrouter when no apiKey exists + expect(configWithoutKey.apiProvider).toBe("openrouter") + }) + }) + + describe("updateApiConfiguration", () => { + it("should update both global state and secrets", async () => { + const apiConfig: ApiConfiguration = { + apiProvider: "anthropic", + apiModelId: "claude-latest", + apiKey: "test-api-key", + } + + await proxy.updateApiConfiguration(apiConfig) + + // Should update global state + expect(mockGlobalState.update).toHaveBeenCalledWith("apiProvider", "anthropic") + expect(mockGlobalState.update).toHaveBeenCalledWith("apiModelId", "claude-latest") + // Should update secrets + expect(mockSecrets.store).toHaveBeenCalledWith("apiKey", "test-api-key") + + // Check that values are in cache + expect(proxy.getGlobalState("apiProvider")).toBe("anthropic") + expect(proxy.getGlobalState("apiModelId")).toBe("claude-latest") + expect(proxy.getSecret("apiKey")).toBe("test-api-key") + }) + + it("should ignore keys that aren't in either GLOBAL_STATE_KEYS or SECRET_KEYS", async () => { + // Use type assertion to add an invalid key + const apiConfig = { + apiProvider: "anthropic", + invalidKey: "should be ignored", + } as ApiConfiguration & { invalidKey: string } + + await proxy.updateApiConfiguration(apiConfig) + + // Should update keys in GLOBAL_STATE_KEYS + expect(mockGlobalState.update).toHaveBeenCalledWith("apiProvider", "anthropic") + // Should not call update/store for invalid keys + expect(mockGlobalState.update).not.toHaveBeenCalledWith("invalidKey", expect.anything()) + expect(mockSecrets.store).not.toHaveBeenCalledWith("invalidKey", expect.anything()) + }) + }) }) }) diff --git a/src/core/contextProxy.ts b/src/core/contextProxy.ts index 7c429c86cf..8d3f9a4b7c 100644 --- a/src/core/contextProxy.ts +++ b/src/core/contextProxy.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode" import { logger } from "../utils/logging" -import { GLOBAL_STATE_KEYS, SECRET_KEYS } from "../shared/globalState" +import { ApiConfiguration, API_CONFIG_KEYS } from "../shared/api" +import { GLOBAL_STATE_KEYS, SECRET_KEYS, GlobalStateKey, SecretKey } from "../shared/globalState" export class ContextProxy { private readonly originalContext: vscode.ExtensionContext @@ -82,7 +83,6 @@ export class ContextProxy { getSecret(key: string): string | undefined { return this.secretCache.get(key) } - storeSecret(key: string, value?: string): Thenable { // Update cache this.secretCache.set(key, value) @@ -93,4 +93,62 @@ export class ContextProxy { return this.originalContext.secrets.store(key, value) } } + + /** + * Gets a complete ApiConfiguration object by fetching values + * from both global state and secrets storage + */ + getApiConfiguration(): ApiConfiguration { + // Create an empty ApiConfiguration object + const config: ApiConfiguration = {} + + // Add all API-related keys from global state + for (const key of API_CONFIG_KEYS) { + const value = this.getGlobalState(key) + if (value !== undefined) { + // Use type assertion to avoid TypeScript error + ;(config as any)[key] = value + } + } + + // Add all secret values + for (const key of SECRET_KEYS) { + const value = this.getSecret(key) + if (value !== undefined) { + // Use type assertion to avoid TypeScript error + ;(config as any)[key] = value + } + } + + // Handle special case for apiProvider if needed (same logic as current implementation) + if (!config.apiProvider) { + if (config.apiKey) { + config.apiProvider = "anthropic" + } else { + config.apiProvider = "openrouter" + } + } + + return config + } + + /** + * Updates an ApiConfiguration by persisting each property + * to the appropriate storage (global state or secrets) + */ + async updateApiConfiguration(apiConfiguration: ApiConfiguration): Promise { + const promises: Array> = [] + + // For each property, update the appropriate storage + Object.entries(apiConfiguration).forEach(([key, value]) => { + if (SECRET_KEYS.includes(key as SecretKey)) { + promises.push(this.storeSecret(key, value)) + } else if (API_CONFIG_KEYS.includes(key as GlobalStateKey)) { + promises.push(this.updateGlobalState(key, value)) + } + // Ignore keys that aren't in either list + }) + + await Promise.all(promises) + } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index a09f18d278..121505672d 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1659,20 +1659,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { } } - // Create an array of promises to update state - const promises: Promise[] = [] - - // For each property in apiConfiguration, update the appropriate state - Object.entries(apiConfiguration).forEach(([key, value]) => { - // Check if this key is a secret - if (SECRET_KEYS.includes(key as SecretKey)) { - promises.push(this.storeSecret(key as SecretKey, value)) - } else { - promises.push(this.updateGlobalState(key as GlobalStateKey, value)) - } - }) - - await Promise.all(promises) + // Update all configuration values through the contextProxy + await this.contextProxy.updateApiConfiguration(apiConfiguration) if (this.cline) { this.cline.api = buildApiHandler(apiConfiguration) @@ -2073,62 +2061,32 @@ export class ClineProvider implements vscode.WebviewViewProvider { */ async getState() { - // Create an object to store all fetched values - const stateValues: Record = {} as Record - const secretValues: Record = {} as Record + // Get ApiConfiguration directly from contextProxy + const apiConfiguration = this.contextProxy.getApiConfiguration() - // Create promise arrays for global state and secrets - const statePromises = GLOBAL_STATE_KEYS.map((key) => this.getGlobalState(key)) - const secretPromises = SECRET_KEYS.map((key) => this.getSecret(key)) + // Create an object to store all fetched values (excluding API config which we already have) + const stateValues: Record = {} as Record + + // Create promise arrays for global state + const statePromises = GLOBAL_STATE_KEYS + // Filter out API config keys since we already have them + .filter((key) => !API_CONFIG_KEYS.includes(key)) + .map((key) => this.getGlobalState(key)) // Add promise for custom modes which is handled separately const customModesPromise = this.customModesManager.getCustomModes() let idx = 0 - const valuePromises = await Promise.all([...statePromises, ...secretPromises, customModesPromise]) + const valuePromises = await Promise.all([...statePromises, customModesPromise]) - // Populate stateValues and secretValues - GLOBAL_STATE_KEYS.forEach((key, _) => { + // Populate stateValues + GLOBAL_STATE_KEYS.filter((key) => !API_CONFIG_KEYS.includes(key)).forEach((key) => { stateValues[key] = valuePromises[idx] idx = idx + 1 }) - SECRET_KEYS.forEach((key, index) => { - secretValues[key] = valuePromises[idx] - idx = idx + 1 - }) - let customModes = valuePromises[idx] as ModeConfig[] | undefined - // Determine apiProvider with the same logic as before - let apiProvider: ApiProvider - if (stateValues.apiProvider) { - apiProvider = stateValues.apiProvider - } else { - // Either new user or legacy user that doesn't have the apiProvider stored in state - // (If they're using OpenRouter or Bedrock, then apiProvider state will exist) - if (secretValues.apiKey) { - apiProvider = "anthropic" - } else { - // New users should default to openrouter - apiProvider = "openrouter" - } - } - - // Build the apiConfiguration object combining state values and secrets - // Using the dynamic approach with API_CONFIG_KEYS - const apiConfiguration: ApiConfiguration = { - // Dynamically add all API-related keys from stateValues - ...Object.fromEntries(API_CONFIG_KEYS.map((key) => [key, stateValues[key]])), - // Add all secrets - ...secretValues, - } - - // Ensure apiProvider is set properly if not already in state - if (!apiConfiguration.apiProvider) { - apiConfiguration.apiProvider = apiProvider - } - // Return the same structure as before return { apiConfiguration, diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 3ef024afb3..ed557c8838 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -34,6 +34,21 @@ jest.mock("../../contextProxy", () => { .mockImplementation((key, value) => value ? context.secrets.store(key, value) : context.secrets.delete(key), ), + getApiConfiguration: jest.fn().mockImplementation(() => ({ + apiProvider: "openrouter", + // Add other common properties + })), + updateApiConfiguration: jest.fn().mockImplementation(async (apiConfiguration) => { + // Mock implementation that simulates updating state and secrets + for (const [key, value] of Object.entries(apiConfiguration)) { + if (key === "apiKey" || key === "openAiApiKey") { + context.secrets.store(key, value) + } else { + context.globalState.update(key, value) + } + } + return Promise.resolve() + }), saveChanges: jest.fn().mockResolvedValue(undefined), dispose: jest.fn().mockResolvedValue(undefined), hasPendingChanges: jest.fn().mockReturnValue(false), @@ -1579,5 +1594,51 @@ describe("ContextProxy integration", () => { expect(mockContextProxy.getGlobalState).toBeDefined() expect(mockContextProxy.updateGlobalState).toBeDefined() expect(mockContextProxy.storeSecret).toBeDefined() + expect(mockContextProxy.getApiConfiguration).toBeDefined() + expect(mockContextProxy.updateApiConfiguration).toBeDefined() + }) + + test("getState uses contextProxy.getApiConfiguration", async () => { + // Setup mock API configuration + const mockApiConfig = { + apiProvider: "anthropic", + apiModelId: "claude-latest", + apiKey: "test-api-key", + } + mockContextProxy.getApiConfiguration.mockReturnValue(mockApiConfig) + + // Get state + const state = await provider.getState() + + // Verify getApiConfiguration was called + expect(mockContextProxy.getApiConfiguration).toHaveBeenCalled() + // Verify state has the API configuration from contextProxy + expect(state.apiConfiguration).toBe(mockApiConfig) + }) + + test("updateApiConfiguration uses contextProxy.updateApiConfiguration", async () => { + // Setup test config + const testApiConfig = { + apiProvider: "anthropic", + apiModelId: "claude-latest", + apiKey: "test-api-key", + } + + // Mock methods needed for the test + provider.configManager = { + listConfig: jest.fn().mockResolvedValue([]), + setModeConfig: jest.fn(), + } as any + + // Mock getState for mode + jest.spyOn(provider, "getState").mockResolvedValue({ + mode: "code", + } as any) + + // Call the private method - need to use any to access it + await (provider as any).updateApiConfiguration(testApiConfig) + + // Verify contextProxy.updateApiConfiguration was called with the right config + expect(mockContextProxy.updateApiConfiguration).toHaveBeenCalledWith(testApiConfig) }) })