From 230c7ed558590a24cc36713faf3fb2c83f1f50eb Mon Sep 17 00:00:00 2001 From: cte Date: Wed, 26 Mar 2025 00:16:00 -0700 Subject: [PATCH 01/10] Strongly type our settings use composition where possible --- src/core/Cline.ts | 16 +- src/core/__tests__/contextProxy.test.ts | 181 +++-- src/core/config/ConfigManager.ts | 2 +- src/core/contextProxy.ts | 268 +++++-- .../prompts/sections/custom-instructions.ts | 5 +- src/core/webview/ClineProvider.ts | 146 ++-- .../webview/__tests__/ClineProvider.test.ts | 8 +- src/exports/api.ts | 5 +- src/exports/roo-code.d.ts | 496 +++++++++--- src/shared/ExtensionMessage.ts | 133 ++-- src/shared/HistoryItem.ts | 15 +- src/shared/WebviewMessage.ts | 2 + src/shared/__tests__/language.test.ts | 7 +- src/shared/__tests__/modes.test.ts | 2 +- src/shared/api.ts | 160 +--- src/shared/checkExistApiConfig.ts | 16 +- src/shared/checkpoints.ts | 4 +- src/shared/experiments.ts | 48 +- src/shared/globalState.ts | 726 ++++++++++++++---- src/shared/language.ts | 16 +- src/shared/modes.ts | 32 +- src/shared/tool-groups.ts | 6 +- src/utils/type-fu.ts | 7 + webview-ui/src/components/settings/About.tsx | 61 +- .../components/settings/LanguageSettings.tsx | 4 +- .../src/components/settings/SettingsView.tsx | 25 +- 26 files changed, 1622 insertions(+), 769 deletions(-) create mode 100644 src/utils/type-fu.ts diff --git a/src/core/Cline.ts b/src/core/Cline.ts index b70f840369..0f4e448e92 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -994,7 +994,7 @@ export class Cline extends EventEmitter { } } - const { terminalOutputLineLimit } = (await this.providerRef.deref()?.getState()) ?? {} + const { terminalOutputLineLimit = 500 } = (await this.providerRef.deref()?.getState()) ?? {} process.on("line", (line) => { if (!didContinue) { @@ -2339,7 +2339,7 @@ export class Cline extends EventEmitter { } // Get the maxReadFileLine setting - const { maxReadFileLine } = (await this.providerRef.deref()?.getState()) ?? {} + const { maxReadFileLine = 500 } = (await this.providerRef.deref()?.getState()) ?? {} // Count total lines in the file let totalLines = 0 @@ -2480,13 +2480,14 @@ export class Cline extends EventEmitter { this.consecutiveMistakeCount = 0 const absolutePath = path.resolve(this.cwd, relDirPath) const [files, didHitLimit] = await listFiles(absolutePath, recursive, 200) - const { showRooIgnoredFiles } = (await this.providerRef.deref()?.getState()) ?? {} + const { showRooIgnoredFiles = true } = + (await this.providerRef.deref()?.getState()) ?? {} const result = formatResponse.formatFilesList( absolutePath, files, didHitLimit, this.rooIgnoreController, - showRooIgnoredFiles ?? true, + showRooIgnoredFiles, ) const completeMessage = JSON.stringify({ ...sharedMessageProps, @@ -3759,7 +3760,8 @@ export class Cline extends EventEmitter { async getEnvironmentDetails(includeFileDetails: boolean = false) { let details = "" - const { terminalOutputLineLimit, maxWorkspaceFiles } = (await this.providerRef.deref()?.getState()) ?? {} + const { terminalOutputLineLimit = 500, maxWorkspaceFiles = 200 } = + (await this.providerRef.deref()?.getState()) ?? {} // It could be useful for cline to know if the user went from one or no file to another between messages, so we always include this context details += "\n\n# VSCode Visible Files" @@ -3767,7 +3769,7 @@ export class Cline extends EventEmitter { ?.map((editor) => editor.document?.uri?.fsPath) .filter(Boolean) .map((absolutePath) => path.relative(this.cwd, absolutePath)) - .slice(0, maxWorkspaceFiles ?? 200) + .slice(0, maxWorkspaceFiles) // Filter paths through rooIgnoreController const allowedVisibleFiles = this.rooIgnoreController @@ -3979,7 +3981,7 @@ export class Cline extends EventEmitter { } else { const maxFiles = maxWorkspaceFiles ?? 200 const [files, didHitLimit] = await listFiles(this.cwd, true, maxFiles) - const { showRooIgnoredFiles } = (await this.providerRef.deref()?.getState()) ?? {} + const { showRooIgnoredFiles = true } = (await this.providerRef.deref()?.getState()) ?? {} const result = formatResponse.formatFilesList( this.cwd, files, diff --git a/src/core/__tests__/contextProxy.test.ts b/src/core/__tests__/contextProxy.test.ts index e2d6c4ad12..94274cbee2 100644 --- a/src/core/__tests__/contextProxy.test.ts +++ b/src/core/__tests__/contextProxy.test.ts @@ -1,10 +1,12 @@ // npx jest src/core/__tests__/contextProxy.test.ts +import fs from "fs/promises" + import * as vscode from "vscode" import { ContextProxy } from "../contextProxy" import { logger } from "../../utils/logging" -import { GLOBAL_STATE_KEYS, SECRET_KEYS, ConfigurationKey, GlobalStateKey } from "../../shared/globalState" +import { GLOBAL_STATE_KEYS, SECRET_STATE_KEYS } from "../../shared/globalState" jest.mock("vscode", () => ({ Uri: { @@ -77,8 +79,8 @@ describe("ContextProxy", () => { }) 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).toHaveBeenCalledTimes(SECRET_STATE_KEYS.length) + for (const key of SECRET_STATE_KEYS) { expect(mockSecrets.get).toHaveBeenCalledWith(key) } }) @@ -87,11 +89,11 @@ describe("ContextProxy", () => { describe("getGlobalState", () => { it("should return value from cache when it exists", async () => { // Manually set a value in the cache - await proxy.updateGlobalState("apiProvider", "cached-value") + await proxy.updateGlobalState("apiProvider", "deepseek") // Should return the cached value const result = proxy.getGlobalState("apiProvider") - expect(result).toBe("cached-value") + expect(result).toBe("deepseek") // Original context should be called once during updateGlobalState expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length) // Only from initialization @@ -99,8 +101,8 @@ describe("ContextProxy", () => { it("should handle default values correctly", async () => { // No value in cache - const result = proxy.getGlobalState("apiProvider", "default-value") - expect(result).toBe("default-value") + const result = proxy.getGlobalState("apiProvider", "deepseek") + expect(result).toBe("deepseek") }) it("should bypass cache for pass-through state keys", async () => { @@ -108,7 +110,7 @@ describe("ContextProxy", () => { mockGlobalState.get.mockReturnValue("pass-through-value") // Use a pass-through key (taskHistory) - const result = proxy.getGlobalState("taskHistory" as GlobalStateKey) + const result = proxy.getGlobalState("taskHistory") // Should get value directly from original context expect(result).toBe("pass-through-value") @@ -120,37 +122,61 @@ describe("ContextProxy", () => { mockGlobalState.get.mockReturnValue(undefined) // Use a pass-through key with default value - const result = proxy.getGlobalState("taskHistory" as GlobalStateKey, "default-value") + const historyItems = [ + { + id: "1", + number: 1, + ts: 1, + task: "test", + tokensIn: 1, + tokensOut: 1, + totalCost: 1, + }, + ] + + const result = proxy.getGlobalState("taskHistory", historyItems) // Should return default value when original context returns undefined - expect(result).toBe("default-value") + expect(result).toBe(historyItems) }) }) describe("updateGlobalState", () => { it("should update state directly in original context", async () => { - await proxy.updateGlobalState("apiProvider", "new-value") + await proxy.updateGlobalState("apiProvider", "deepseek") // Should have called original context - expect(mockGlobalState.update).toHaveBeenCalledWith("apiProvider", "new-value") + expect(mockGlobalState.update).toHaveBeenCalledWith("apiProvider", "deepseek") // Should have stored the value in cache const storedValue = await proxy.getGlobalState("apiProvider") - expect(storedValue).toBe("new-value") + expect(storedValue).toBe("deepseek") }) it("should bypass cache for pass-through state keys", async () => { - await proxy.updateGlobalState("taskHistory" as GlobalStateKey, "new-value") + const historyItems = [ + { + id: "1", + number: 1, + ts: 1, + task: "test", + tokensIn: 1, + tokensOut: 1, + totalCost: 1, + }, + ] + + await proxy.updateGlobalState("taskHistory", historyItems) // Should update original context - expect(mockGlobalState.update).toHaveBeenCalledWith("taskHistory", "new-value") + expect(mockGlobalState.update).toHaveBeenCalledWith("taskHistory", historyItems) // Setup mock for subsequent get - mockGlobalState.get.mockReturnValue("new-value") + mockGlobalState.get.mockReturnValue(historyItems) // Should get fresh value from original context - const storedValue = proxy.getGlobalState("taskHistory" as GlobalStateKey) - expect(storedValue).toBe("new-value") + const storedValue = proxy.getGlobalState("taskHistory") + expect(storedValue).toBe(historyItems) expect(mockGlobalState.get).toHaveBeenCalledWith("taskHistory") }) }) @@ -220,27 +246,6 @@ describe("ContextProxy", () => { const storedValue = proxy.getGlobalState("apiModelId") expect(storedValue).toBe("gpt-4") }) - - it("should handle unknown keys as global state with warning", async () => { - // Spy on the logger - const warnSpy = jest.spyOn(logger, "warn") - - // Spy on updateGlobalState - const updateGlobalStateSpy = jest.spyOn(proxy, "updateGlobalState") - - // Test with an unknown key - await proxy.setValue("unknownKey" as ConfigurationKey, "some-value") - - // Should have logged a warning - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown key: unknownKey")) - - // Should have called updateGlobalState - expect(updateGlobalStateSpy).toHaveBeenCalledWith("unknownKey", "some-value") - - // Should have stored the value in state cache - const storedValue = proxy.getGlobalState("unknownKey" as GlobalStateKey) - expect(storedValue).toBe("some-value") - }) }) describe("setValues", () => { @@ -288,7 +293,7 @@ describe("ContextProxy", () => { }) }) - describe("setApiConfiguration", () => { + describe("setProviderSettings", () => { it("should clear old API configuration values and set new ones", async () => { // Set up initial API configuration values await proxy.updateGlobalState("apiModelId", "old-model") @@ -298,8 +303,8 @@ describe("ContextProxy", () => { // Spy on setValues const setValuesSpy = jest.spyOn(proxy, "setValues") - // Call setApiConfiguration with new configuration - await proxy.setApiConfiguration({ + // Call setProviderSettings with new configuration + await proxy.setProviderSettings({ apiModelId: "new-model", apiProvider: "anthropic", // Note: openAiBaseUrl is not included in the new config @@ -332,8 +337,8 @@ describe("ContextProxy", () => { // Spy on setValues const setValuesSpy = jest.spyOn(proxy, "setValues") - // Call setApiConfiguration with empty configuration - await proxy.setApiConfiguration({}) + // Call setProviderSettings with empty configuration + await proxy.setProviderSettings({}) // Verify setValues was called with undefined for all existing API config keys expect(setValuesSpy).toHaveBeenCalledWith( @@ -397,12 +402,12 @@ describe("ContextProxy", () => { await proxy.resetAllState() // Should have called delete for each key - for (const key of SECRET_KEYS) { + for (const key of SECRET_STATE_KEYS) { expect(mockSecrets.delete).toHaveBeenCalledWith(key) } // Total calls should equal the number of secret keys - expect(mockSecrets.delete).toHaveBeenCalledTimes(SECRET_KEYS.length) + expect(mockSecrets.delete).toHaveBeenCalledTimes(SECRET_STATE_KEYS.length) }) it("should reinitialize caches after reset", async () => { @@ -416,4 +421,88 @@ describe("ContextProxy", () => { expect(initializeSpy).toHaveBeenCalledTimes(1) }) }) + + describe("exportGlobalSettings", () => { + it("should write global settings to a file when filePath is provided", async () => { + await proxy.setValues({ + apiModelId: "gpt-4", + apiProvider: "openai", + openAiApiKey: "test-api-key", + autoApprovalEnabled: true, + }) + + const filePath = `/tmp/roo-global-config-${Date.now()}.json` + const result = await proxy.exportGlobalSettings(filePath) + expect(result).toEqual({ autoApprovalEnabled: true }) + const fileContent = await fs.readFile(filePath, "utf-8") + expect(fileContent).toContain('"autoApprovalEnabled": true') + + await proxy.setValue("autoApprovalEnabled", false) + expect(proxy.getValue("autoApprovalEnabled")).toBe(false) + + const importedConfig = await proxy.importGlobalSettings(filePath) + expect(importedConfig).toEqual({ autoApprovalEnabled: true }) + expect(proxy.getValue("autoApprovalEnabled")).toBe(true) + + await fs.unlink(filePath) + }) + }) + + describe("exportProviderSettings", () => { + it("should write provider settings to a file when filePath is provided", async () => { + await proxy.setValues({ + apiModelId: "gpt-4", + apiProvider: "openai", + openAiApiKey: "test-api-key", + autoApprovalEnabled: true, + }) + + const filePath = `/tmp/roo-api-config-${Date.now()}.json` + const result = await proxy.exportProviderSettings(filePath) + expect(result).toEqual({ + apiModelId: "gpt-4", + apiProvider: "openai", + openAiApiKey: "test-api-key", + apiKey: "test-secret", + awsAccessKey: "test-secret", + awsSecretKey: "test-secret", + awsSessionToken: "test-secret", + deepSeekApiKey: "test-secret", + geminiApiKey: "test-secret", + glamaApiKey: "test-secret", + mistralApiKey: "test-secret", + openAiNativeApiKey: "test-secret", + openRouterApiKey: "test-secret", + requestyApiKey: "test-secret", + unboundApiKey: "test-secret", + }) + const fileContent = await fs.readFile(filePath, "utf-8") + expect(fileContent).toContain('"openAiApiKey": "test-api-key"') + + await proxy.setValue("openAiApiKey", "new-test-api-key") + expect(proxy.getValue("openAiApiKey")).toBe("new-test-api-key") + + const importedConfig = await proxy.importProviderSettings(filePath) + expect(importedConfig).toEqual({ + apiModelId: "gpt-4", + apiProvider: "openai", + openAiApiKey: "test-api-key", + apiKey: "test-secret", + awsAccessKey: "test-secret", + awsSecretKey: "test-secret", + awsSessionToken: "test-secret", + deepSeekApiKey: "test-secret", + geminiApiKey: "test-secret", + glamaApiKey: "test-secret", + mistralApiKey: "test-secret", + openAiNativeApiKey: "test-secret", + openRouterApiKey: "test-secret", + requestyApiKey: "test-secret", + unboundApiKey: "test-secret", + }) + expect(proxy.getValue("openAiApiKey")).toBe("test-api-key") + + await fs.unlink(filePath) + }) + }) }) diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index dd4262ee2e..f0002bf0fb 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -6,7 +6,7 @@ import { ApiConfigMeta } from "../../shared/ExtensionMessage" export interface ApiConfigData { currentApiConfigName: string apiConfigs: { - [key: string]: ApiConfiguration + [key: string]: ApiConfiguration & { id?: string } } modeApiConfigs?: Partial> } diff --git a/src/core/contextProxy.ts b/src/core/contextProxy.ts index 698713179d..e213a6e7de 100644 --- a/src/core/contextProxy.ts +++ b/src/core/contextProxy.ts @@ -1,30 +1,52 @@ import * as vscode from "vscode" +import * as fs from "fs/promises" +import * as path from "path" import { logger } from "../utils/logging" +import type { + ProviderSettings, + RooCodeSettings, + RooCodeSettingsKey, + GlobalStateKey, + GlobalState, + SecretStateKey, + SecretState, + GlobalSettings, +} from "../exports/roo-code" import { + PROVIDER_SETTINGS_KEYS, GLOBAL_STATE_KEYS, - SECRET_KEYS, - GlobalStateKey, - SecretKey, - ConfigurationKey, - ConfigurationValues, - isSecretKey, - isGlobalStateKey, + SECRET_STATE_KEYS, + isSecretStateKey, isPassThroughStateKey, + globalSettingsSchema, + providerSettingsSchema, } from "../shared/globalState" -import { API_CONFIG_KEYS, ApiConfiguration } from "../shared/api" + +const globalSettingsExportSchema = globalSettingsSchema.omit({ + taskHistory: true, + listApiConfigMeta: true, + currentApiConfigName: true, +}) + +const providerSettingsExportSchema = providerSettingsSchema.omit({ + glamaModelInfo: true, + openRouterModelInfo: true, + unboundModelInfo: true, + requestyModelInfo: true, +}) export class ContextProxy { private readonly originalContext: vscode.ExtensionContext - private stateCache: Map - private secretCache: Map + private stateCache: GlobalState + private secretCache: SecretState private _isInitialized = false constructor(context: vscode.ExtensionContext) { this.originalContext = context - this.stateCache = new Map() - this.secretCache = new Map() + this.stateCache = {} + this.secretCache = {} this._isInitialized = false } @@ -35,15 +57,15 @@ export class ContextProxy { public async initialize() { for (const key of GLOBAL_STATE_KEYS) { try { - this.stateCache.set(key, this.originalContext.globalState.get(key)) + this.stateCache[key] = this.originalContext.globalState.get(key) } catch (error) { logger.error(`Error loading global ${key}: ${error instanceof Error ? error.message : String(error)}`) } } - const promises = SECRET_KEYS.map(async (key) => { + const promises = SECRET_STATE_KEYS.map(async (key) => { try { - this.secretCache.set(key, await this.originalContext.secrets.get(key)) + this.secretCache[key] = await this.originalContext.secrets.get(key) } catch (error) { logger.error(`Error loading secret ${key}: ${error instanceof Error ? error.message : String(error)}`) } @@ -54,56 +76,72 @@ export class ContextProxy { this._isInitialized = true } - get extensionUri() { + public get extensionUri() { return this.originalContext.extensionUri } - get extensionPath() { + public get extensionPath() { return this.originalContext.extensionPath } - get globalStorageUri() { + public get globalStorageUri() { return this.originalContext.globalStorageUri } - get logUri() { + public get logUri() { return this.originalContext.logUri } - get extension() { + public get extension() { return this.originalContext.extension } - get extensionMode() { + public get extensionMode() { return this.originalContext.extensionMode } - getGlobalState(key: GlobalStateKey): T | undefined - getGlobalState(key: GlobalStateKey, defaultValue: T): T - getGlobalState(key: GlobalStateKey, defaultValue?: T): T | undefined { + /** + * ExtensionContext.globalState + * https://code.visualstudio.com/api/references/vscode-api#ExtensionContext.globalState + */ + + getGlobalState(key: K): GlobalState[K] + getGlobalState(key: K, defaultValue: GlobalState[K]): GlobalState[K] + getGlobalState(key: K, defaultValue?: GlobalState[K]): GlobalState[K] { if (isPassThroughStateKey(key)) { - const value = this.originalContext.globalState.get(key) - return value === undefined || value === null ? defaultValue : (value as T) + const value = this.originalContext.globalState.get(key) + return value === undefined || value === null ? defaultValue : value } - const value = this.stateCache.get(key) as T | undefined - return value !== undefined ? value : (defaultValue as T | undefined) + + const value = this.stateCache[key] + return value !== undefined ? value : defaultValue } - updateGlobalState(key: GlobalStateKey, value: T) { + updateGlobalState(key: K, value: GlobalState[K]) { if (isPassThroughStateKey(key)) { return this.originalContext.globalState.update(key, value) } - this.stateCache.set(key, value) + + this.stateCache[key] = value return this.originalContext.globalState.update(key, value) } - getSecret(key: SecretKey) { - return this.secretCache.get(key) + private getAllGlobalState(): GlobalState { + return Object.fromEntries(GLOBAL_STATE_KEYS.map((key) => [key, this.getGlobalState(key)])) + } + + /** + * ExtensionContext.secrets + * https://code.visualstudio.com/api/references/vscode-api#ExtensionContext.secrets + */ + + getSecret(key: SecretStateKey) { + return this.secretCache[key] } - storeSecret(key: SecretKey, value?: string) { + storeSecret(key: SecretStateKey, value?: string) { // Update cache. - this.secretCache.set(key, value) + this.secretCache[key] = value // Write directly to context. return value === undefined @@ -111,79 +149,151 @@ export class ContextProxy { : this.originalContext.secrets.store(key, value) } + private getAllSecrets(): SecretState { + return Object.fromEntries(SECRET_STATE_KEYS.map((key) => [key, this.getSecret(key)])) + } + /** - * Set a value in either secrets or global state based on key type. - * If the key is in SECRET_KEYS, it will be stored as a secret. - * If the key is in GLOBAL_STATE_KEYS or unknown, it will be stored in global state. - * @param key The key to set - * @param value The value to set - * @returns A promise that resolves when the operation completes + * GlobalSettings */ - setValue(key: ConfigurationKey, value: any) { - if (isSecretKey(key)) { - return this.storeSecret(key, value) - } - if (isGlobalStateKey(key)) { - return this.updateGlobalState(key, value) + public getGlobalSettings(): GlobalSettings { + return globalSettingsSchema.parse({ ...this.stateCache }) + } + + public async exportGlobalSettings(filePath: string): Promise { + try { + const globalSettings = globalSettingsExportSchema.parse(this.getValues()) + + const sanitized = Object.fromEntries( + Object.entries(globalSettings).filter(([_, value]) => value !== undefined), + ) + + const dirname = path.dirname(filePath) + await fs.mkdir(dirname, { recursive: true }) + await fs.writeFile(filePath, JSON.stringify(sanitized, null, 2), "utf-8") + return sanitized + } catch (error) { + console.log(error.message) + logger.error( + `Error exporting global configuration to ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ) + return undefined } + } - logger.warn(`Unknown key: ${key}. Storing as global state.`) - return this.updateGlobalState(key, value) + public async importGlobalSettings(filePath: string) { + try { + const globalConfiguration = globalSettingsExportSchema.parse( + JSON.parse(await fs.readFile(filePath, "utf-8")), + ) + + await this.setValues(globalConfiguration) + return globalConfiguration + } catch (error) { + logger.error( + `Error importing global configuration from ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ) + return undefined + } } /** - * Set multiple values at once. Each key will be routed to either - * secrets or global state based on its type. - * @param values An object containing key-value pairs to set - * @returns A promise that resolves when all operations complete + * ProviderSettings */ - async setValues(values: Partial) { - const promises: Thenable[] = [] - for (const [key, value] of Object.entries(values)) { - promises.push(this.setValue(key as ConfigurationKey, value)) - } - - await Promise.all(promises) + public getProviderSettings(): ProviderSettings { + return providerSettingsSchema.parse(this.getValues()) } - async setApiConfiguration(apiConfiguration: ApiConfiguration) { + public async setProviderSettings(values: ProviderSettings) { // Explicitly clear out any old API configuration values before that // might not be present in the new configuration. // If a value is not present in the new configuration, then it is assumed // that the setting's value should be `undefined` and therefore we // need to remove it from the state cache if it exists. await this.setValues({ - ...API_CONFIG_KEYS.filter((key) => !!this.stateCache.get(key)).reduce( - (acc, key) => ({ ...acc, [key]: undefined }), - {} as Partial, - ), - ...apiConfiguration, + ...PROVIDER_SETTINGS_KEYS.filter((key) => !isSecretStateKey(key)) + .filter((key) => !!this.stateCache[key]) + .reduce((acc, key) => ({ ...acc, [key]: undefined }), {} as ProviderSettings), + ...values, }) } + public async exportProviderSettings(filePath: string): Promise { + try { + const providerSettings = providerSettingsExportSchema.parse(this.getValues()) + + const sanitized = Object.fromEntries( + Object.entries(providerSettings).filter(([_, value]) => value !== undefined), + ) + + const dirname = path.dirname(filePath) + await fs.mkdir(dirname, { recursive: true }) + await fs.writeFile(filePath, JSON.stringify(sanitized, null, 2), "utf-8") + return sanitized + } catch (error) { + logger.error( + `Error exporting API configuration to ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ) + return undefined + } + } + + public async importProviderSettings(filePath: string): Promise { + try { + const providerSettings = providerSettingsExportSchema.parse( + JSON.parse(await fs.readFile(filePath, "utf-8")), + ) + + await this.setProviderSettings(providerSettings) + return providerSettings + } catch (error) { + logger.error( + `Error importing API configuration from ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ) + return undefined + } + } + + /** + * RooCodeSettings + */ + + public setValue(key: K, value: RooCodeSettings[K]) { + return isSecretStateKey(key) ? this.storeSecret(key, value as string) : this.updateGlobalState(key, value) + } + + public getValue(key: K): RooCodeSettings[K] { + return isSecretStateKey(key) + ? (this.getSecret(key) as RooCodeSettings[K]) + : (this.getGlobalState(key) as RooCodeSettings[K]) + } + + public getValues(): RooCodeSettings { + return { ...this.getAllGlobalState(), ...this.getAllSecrets() } + } + + public async setValues(values: RooCodeSettings) { + const entries = Object.entries(values) as [RooCodeSettingsKey, unknown][] + await Promise.all(entries.map(([key, value]) => this.setValue(key, value))) + } + /** * Resets all global state, secrets, and in-memory caches. * This clears all data from both the in-memory caches and the VSCode storage. * @returns A promise that resolves when all reset operations are complete */ - async resetAllState() { + public async resetAllState() { // Clear in-memory caches - this.stateCache.clear() - this.secretCache.clear() - - // Reset all global state values to undefined. - const stateResetPromises = GLOBAL_STATE_KEYS.map((key) => - this.originalContext.globalState.update(key, undefined), - ) - - // Delete all secrets. - const secretResetPromises = SECRET_KEYS.map((key) => this.originalContext.secrets.delete(key)) + this.stateCache = {} + this.secretCache = {} - // Wait for all reset operations to complete. - await Promise.all([...stateResetPromises, ...secretResetPromises]) + await Promise.all([ + ...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)), + ...SECRET_STATE_KEYS.map((key) => this.originalContext.secrets.delete(key)), + ]) - this.initialize() + await this.initialize() } } diff --git a/src/core/prompts/sections/custom-instructions.ts b/src/core/prompts/sections/custom-instructions.ts index f076777585..8aeb958917 100644 --- a/src/core/prompts/sections/custom-instructions.ts +++ b/src/core/prompts/sections/custom-instructions.ts @@ -1,7 +1,8 @@ import fs from "fs/promises" import path from "path" -import * as vscode from "vscode" + import { LANGUAGES } from "../../../shared/language" +import { isLanguage } from "../../../shared/globalState" async function safeReadFile(filePath: string): Promise { try { @@ -48,7 +49,7 @@ export async function addCustomInstructions( // Add language preference if provided if (options.language) { - const languageName = LANGUAGES[options.language] || options.language + const languageName = isLanguage(options.language) ? LANGUAGES[options.language] : options.language sections.push( `Language Preference:\nYou should always speak and think in the "${languageName}" (${options.language}) language unless the user gives you instructions below to do otherwise.`, ) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index addd020da2..7e3daec3c6 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -8,13 +8,21 @@ import pWaitFor from "p-wait-for" import * as path from "path" import * as vscode from "vscode" +import { + CheckpointStorage, + GlobalState, + Language, + ProviderSettings, + RooCodeSettings, + GlobalStateKey, + SecretStateKey, +} from "../../exports/roo-code" import { changeLanguage, t } from "../../i18n" import { setPanel } from "../../activate/registerCommands" import { ApiConfiguration, ApiProvider, ModelInfo, - API_CONFIG_KEYS, requestyDefaultModelId, requestyDefaultModelInfo, openRouterDefaultModelId, @@ -25,13 +33,6 @@ import { import { findLast } from "../../shared/array" import { supportPrompt } from "../../shared/support-prompt" import { GlobalFileNames } from "../../shared/globalFileNames" -import { - SecretKey, - GlobalStateKey, - SECRET_KEYS, - GLOBAL_STATE_KEYS, - ConfigurationValues, -} from "../../shared/globalState" import { HistoryItem } from "../../shared/HistoryItem" import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage" import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage" @@ -99,12 +100,11 @@ export class ClineProvider extends EventEmitter implements private workspaceTracker?: WorkspaceTracker protected mcpHub?: McpHub // Change from private to protected private latestAnnouncementId = "mar-20-2025-3-10" // update to some unique identifier when we add a new announcement + private settingsImportedAt?: number private contextProxy: ContextProxy configManager: ConfigManager customModesManager: CustomModesManager - get cwd() { - return getWorkspacePath() - } + constructor( readonly context: vscode.ExtensionContext, private readonly outputChannel: vscode.OutputChannel, @@ -1080,6 +1080,41 @@ export class ClineProvider extends EventEmitter implements } case "exportTaskWithId": this.exportTaskWithId(message.text!) + break + case "importSettings": + const uris = await vscode.window.showOpenDialog({ + filters: { JSON: ["json"] }, + canSelectMany: false, + }) + + if (uris) { + if (message.text === "global") { + await this.contextProxy.importGlobalSettings(uris[0].fsPath) + } else { + await this.contextProxy.importGlobalSettings(uris[0].fsPath) + } + + this.settingsImportedAt = Date.now() + await this.postStateToWebview() + await vscode.window.showInformationMessage(t("common:info.settings_imported")) + } + break + case "exportSettings": + const uri = await vscode.window.showSaveDialog({ + filters: { JSON: ["json"] }, + defaultUri: vscode.Uri.file( + path.join(os.homedir(), "Documents", `roo-code-${message.text}.json`), + ), + }) + + if (uri) { + if (message.text === "global") { + await this.contextProxy.exportGlobalSettings(uri.fsPath) + } else { + await this.contextProxy.exportProviderSettings(uri.fsPath) + } + } + break case "resetState": await this.resetState() @@ -1338,7 +1373,7 @@ export class ClineProvider extends EventEmitter implements case "checkpointStorage": console.log(`[ClineProvider] checkpointStorage: ${message.text}`) const checkpointStorage = message.text ?? "task" - await this.updateGlobalState("checkpointStorage", checkpointStorage) + await this.updateGlobalState("checkpointStorage", checkpointStorage as CheckpointStorage) await this.postStateToWebview() break case "browserViewportSize": @@ -1664,7 +1699,7 @@ export class ClineProvider extends EventEmitter implements break case "language": changeLanguage(message.text ?? "en") - await this.updateGlobalState("language", message.text) + await this.updateGlobalState("language", message.text as Language) await this.postStateToWebview() break case "showRooIgnoredFiles": @@ -2149,7 +2184,7 @@ export class ClineProvider extends EventEmitter implements await this.postStateToWebview() } - private async updateApiConfiguration(apiConfiguration: ApiConfiguration) { + private async updateApiConfiguration(providerSettings: ProviderSettings) { // Update mode's default config. const { mode } = await this.getState() @@ -2162,10 +2197,11 @@ export class ClineProvider extends EventEmitter implements await this.configManager.setModeConfig(mode, config.id) } } - await this.contextProxy.setApiConfiguration(apiConfiguration) + + await this.contextProxy.setProviderSettings(providerSettings) if (this.getCurrentCline()) { - this.getCurrentCline()!.api = buildApiHandler(apiConfiguration) + this.getCurrentCline()!.api = buildApiHandler(providerSettings) } } @@ -2610,6 +2646,7 @@ export class ClineProvider extends EventEmitter implements language, renderContext: this.renderContext, maxReadFileLine: maxReadFileLine ?? 500, + settingsImportedAt: this.settingsImportedAt, } } @@ -2660,58 +2697,24 @@ export class ClineProvider extends EventEmitter implements */ async getState() { - // 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() - - let idx = 0 - const valuePromises = await Promise.all([...statePromises, ...secretPromises, customModesPromise]) - - // Populate stateValues and secretValues - GLOBAL_STATE_KEYS.forEach((key, _) => { - stateValues[key] = valuePromises[idx] - idx = idx + 1 - }) - - SECRET_KEYS.forEach((key, index) => { - secretValues[key] = valuePromises[idx] - idx = idx + 1 - }) + const stateValues = this.contextProxy.getValues() - let customModes = valuePromises[idx] as ModeConfig[] | undefined + const customModes = await this.customModesManager.getCustomModes() - // Determine apiProvider with the same logic as before - let apiProvider: ApiProvider - if (stateValues.apiProvider) { - apiProvider = stateValues.apiProvider - } else { - apiProvider = "anthropic" - } + // Determine apiProvider with the same logic as before. + const apiProvider: ApiProvider = stateValues.apiProvider ? stateValues.apiProvider : "anthropic" - // 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, - } + // Build the apiConfiguration object combining state values and secrets. + const providerSettings = this.contextProxy.getProviderSettings() // Ensure apiProvider is set properly if not already in state - if (!apiConfiguration.apiProvider) { - apiConfiguration.apiProvider = apiProvider + if (!providerSettings.apiProvider) { + providerSettings.apiProvider = apiProvider } // Return the same structure as before return { - apiConfiguration, + apiConfiguration: providerSettings, lastShownAnnouncementId: stateValues.lastShownAnnouncementId, customInstructions: stateValues.customInstructions, alwaysAllowReadOnly: stateValues.alwaysAllowReadOnly ?? false, @@ -2776,36 +2779,43 @@ export class ClineProvider extends EventEmitter implements } else { history.push(item) } + await this.updateGlobalState("taskHistory", history) return history } // global - public async updateGlobalState(key: GlobalStateKey, value: any) { - await this.contextProxy.updateGlobalState(key, value) + public async updateGlobalState(key: K, value: GlobalState[K]) { + await this.contextProxy.setValue(key, value) } - public async getGlobalState(key: GlobalStateKey) { - return await this.contextProxy.getGlobalState(key) + public getGlobalState(key: GlobalStateKey) { + return this.contextProxy.getValue(key) } // secrets - public async storeSecret(key: SecretKey, value?: string) { - await this.contextProxy.storeSecret(key, value) + public async storeSecret(key: SecretStateKey, value?: string) { + await this.contextProxy.setValue(key, value) } - private async getSecret(key: SecretKey) { - return await this.contextProxy.getSecret(key) + private getSecret(key: SecretStateKey) { + return this.contextProxy.getValue(key) } // global + secret - public async setValues(values: Partial) { + public async setValues(values: RooCodeSettings) { await this.contextProxy.setValues(values) } + // cwd + + get cwd() { + return getWorkspacePath() + } + // dev async resetState() { diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index c77e677f6f..5c01e51e7c 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -5,12 +5,10 @@ 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 { setTtsEnabled } from "../../../utils/tts" import { defaultModeSlug } from "../../../shared/modes" import { experimentDefault } from "../../../shared/experiments" -import { Cline } from "../../Cline" // Mock setup must come before imports jest.mock("../../prompts/sections/custom-instructions") @@ -2073,19 +2071,19 @@ describe("ContextProxy integration", () => { }) test("updateGlobalState uses contextProxy", async () => { - await provider.updateGlobalState("currentApiConfigName" as GlobalStateKey, "testValue") + await provider.updateGlobalState("currentApiConfigName", "testValue") expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith("currentApiConfigName", "testValue") }) test("getGlobalState uses contextProxy", async () => { mockContextProxy.getGlobalState.mockResolvedValueOnce("testValue") - const result = await provider.getGlobalState("currentApiConfigName" as GlobalStateKey) + const result = await provider.getGlobalState("currentApiConfigName") expect(mockContextProxy.getGlobalState).toHaveBeenCalledWith("currentApiConfigName") expect(result).toBe("testValue") }) test("storeSecret uses contextProxy", async () => { - await provider.storeSecret("apiKey" as SecretKey, "test-secret") + await provider.storeSecret("apiKey", "test-secret") expect(mockContextProxy.storeSecret).toHaveBeenCalledWith("apiKey", "test-secret") }) diff --git a/src/exports/api.ts b/src/exports/api.ts index 0a0505fc75..488f6884a2 100644 --- a/src/exports/api.ts +++ b/src/exports/api.ts @@ -3,7 +3,7 @@ import * as vscode from "vscode" import { ClineProvider } from "../core/webview/ClineProvider" -import { RooCodeAPI, RooCodeEvents, ConfigurationValues, TokenUsage } from "./roo-code" +import { RooCodeAPI, RooCodeEvents, TokenUsage, RooCodeSettings } from "./roo-code" import { MessageHistory } from "./message-history" export class API extends EventEmitter implements RooCodeAPI { @@ -78,8 +78,7 @@ export class API extends EventEmitter implements RooCodeAPI { await this.provider.postMessageToWebview({ type: "invoke", invoke: "secondaryButtonClick" }) } - // TODO: Change this to `setApiConfiguration`. - public async setConfiguration(values: Partial) { + public async setConfiguration(values: RooCodeSettings) { await this.provider.setValues(values) } diff --git a/src/exports/roo-code.d.ts b/src/exports/roo-code.d.ts index ddbd8b2d40..392c999404 100644 --- a/src/exports/roo-code.d.ts +++ b/src/exports/roo-code.d.ts @@ -1,3 +1,5 @@ +import * as vscode from "vscode" + import { EventEmitter } from "events" export interface TokenUsage { @@ -150,7 +152,389 @@ export interface ClineMessage { progressStatus?: ToolProgressStatus } -export type SecretKey = +export interface ModelInfo { + maxTokens?: number + contextWindow: number + supportsImages?: boolean + supportsComputerUse?: boolean + supportsPromptCache: boolean // This value is hardcoded for now. + inputPrice?: number + outputPrice?: number + cacheWritesPrice?: number + cacheReadsPrice?: number + description?: string + reasoningEffort?: "low" | "medium" | "high" + thinking?: boolean +} + +export interface ApiConfigMeta { + id: string + name: string + apiProvider?: ProviderName +} + +export type HistoryItem = { + id: string + number: number + ts: number + task: string + tokensIn: number + tokensOut: number + cacheWrites?: number + cacheReads?: number + totalCost: number + size?: number +} + +export type ExperimentId = + | "experimentalDiffStrategy" + | "search_and_replace" + | "insert_content" + | "powerSteering" + | "multi_search_and_replace" + +export type CheckpointStorage = "task" | "workspace" + +export type GroupOptions = { + fileRegex?: string // Regular expression pattern. + description?: string // Human-readable description of the pattern. +} + +export type ToolGroup = "read" | "edit" | "browser" | "command" | "mcp" | "modes" + +export type GroupEntry = ToolGroup | readonly [ToolGroup, GroupOptions] + +export type ModeConfig = { + slug: string + name: string + roleDefinition: string + customInstructions?: string + groups: readonly GroupEntry[] // Now supports both simple strings and tuples with options + source?: "global" | "project" // Where this mode was loaded from +} + +export type PromptComponent = { + roleDefinition?: string + customInstructions?: string +} + +export type CustomModePrompts = { + [key: string]: PromptComponent | undefined +} + +export type CustomSupportPrompts = { + [key: string]: string | undefined +} + +export type TelemetrySetting = "unset" | "enabled" | "disabled" + +export type Language = + | "ca" + | "de" + | "en" + | "es" + | "fr" + | "hi" + | "it" + | "ja" + | "ko" + | "pl" + | "pt-BR" + | "tr" + | "vi" + | "zh-CN" + | "zh-TW" + +/** + * GlobalSettings + * + * These are settings that apply globally. + * They are all stored in the global state. + */ + +export interface GlobalSettings { + currentApiConfigName?: string + listApiConfigMeta?: ApiConfigMeta[] + lastShownAnnouncementId?: string + customInstructions?: string + taskHistory?: HistoryItem[] + + autoApprovalEnabled?: boolean + alwaysAllowReadOnly?: boolean + alwaysAllowReadOnlyOutsideWorkspace?: boolean + alwaysAllowWrite?: boolean + alwaysAllowWriteOutsideWorkspace?: boolean + writeDelayMs?: number + alwaysAllowBrowser?: boolean + alwaysApproveResubmit?: boolean + requestDelaySeconds?: number + alwaysAllowMcp?: boolean + alwaysAllowModeSwitch?: boolean + alwaysAllowSubtasks?: boolean + alwaysAllowExecute?: boolean + allowedCommands?: string[] + + browserToolEnabled?: boolean + browserViewportSize?: string + screenshotQuality?: number + remoteBrowserEnabled?: boolean + remoteBrowserHost?: string + + enableCheckpoints?: boolean + checkpointStorage?: CheckpointStorage + + ttsEnabled?: boolean + ttsSpeed?: number + soundEnabled?: boolean + soundVolume?: number + + maxOpenTabsContext?: number + maxWorkspaceFiles?: number + showRooIgnoredFiles?: boolean + maxReadFileLine?: number + + terminalOutputLineLimit?: number + terminalShellIntegrationTimeout?: number + + rateLimitSeconds?: number + diffEnabled?: boolean + fuzzyMatchThreshold?: number + experiments?: Record // Map of experiment IDs to their enabled state. + + language?: Language + + telemetrySetting?: TelemetrySetting + + mcpEnabled?: boolean + enableMcpServerCreation?: boolean + + mode?: string + modeApiConfigs?: Record + customModes?: ModeConfig[] + customModePrompts?: CustomModePrompts + customSupportPrompts?: CustomSupportPrompts + enhancementApiConfigId?: string +} + +export type GlobalSettingsKey = keyof GlobalSettings + +/** + * ProviderSettings + * + * These are settings that apply on a per-provider basis. + * Non-sensitive values are stored in the global state. + * Sensitive values are stored in VSCode secrets. + */ + +/** + * DiscriminatedProviderSettings + */ + +export type DiscriminatedProviderSettings = + | { + apiProvider: "anthropic" + apiKey?: string + anthropicBaseUrl?: string + apiModelId?: string + } + | { + apiProvider: "glama" + glamaApiKey?: string + glamaModelId?: string + } + | { + apiProvider: "openrouter" + openRouterApiKey?: string + openRouterModelId?: string + openRouterBaseUrl?: string + openRouterSpecificProvider?: string + openRouterUseMiddleOutTransform?: boolean + } + | { + apiProvider: "bedrock" + awsAccessKey?: string + awsSecretKey?: string + awsSessionToken?: string + awsRegion?: string + awsUseCrossRegionInference?: boolean + awsUsePromptCache?: boolean + awspromptCacheId?: string + awsProfile?: string + awsUseProfile?: boolean + awsCustomArn?: string + } + | { + apiProvider: "vertex" + vertexKeyFile?: string + vertexJsonCredentials?: string + vertexProjectId?: string + vertexRegion?: string + } + | { + apiProvider: "openai" + openAiApiKey?: string + openAiBaseUrl?: string + openAiR1FormatEnabled?: boolean + openAiModelId?: string + openAiUseAzure?: boolean + azureApiVersion?: string + openAiStreamingEnabled?: boolean + } + | { + apiProvider: "ollama" + ollamaModelId?: string + ollamaBaseUrl?: string + } + | { + apiProvider: "vscode-lm" + vsCodeLmModelSelector?: vscode.LanguageModelChatSelector + } + | { + apiProvider: "lmstudio" + lmStudioModelId?: string + lmStudioBaseUrl?: string + lmStudioDraftModelId?: string + lmStudioSpeculativeDecodingEnabled?: boolean + } + | { + apiProvider: "gemini" + googleGeminiBaseUrl?: string + } + | { + apiProvider: "openai-native" + openAiNativeApiKey?: string + } + | { + apiProvider: "mistral" + mistralApiKey?: string + mistralCodestralUrl?: string + } + | { + apiProvider: "deepseek" + deepSeekApiKey?: string + deepSeekBaseUrl?: string + } + | { + apiProvider: "unbound" + unboundApiKey?: string + unboundModelId?: string + } + | { + apiProvider: "requesty" + requestyApiKey?: string + requestyModelId?: string + } + | { + apiProvider: "human-relay" + } + | { + apiProvider: "fake-ai" + fakeAi?: unknown + } + +export type ProviderName = DiscriminatedProviderSettings["apiProvider"] + +export interface ProviderSettings { + apiProvider?: ProviderName + apiModelId?: string + // Anthropic + apiKey?: string // secret + anthropicBaseUrl?: string + // Glama + glamaApiKey?: string // secret + glamaModelId?: string + glamaModelInfo?: ModelInfo + // OpenRouter + openRouterApiKey?: string // secret + openRouterModelId?: string + openRouterModelInfo?: ModelInfo + openRouterBaseUrl?: string + openRouterSpecificProvider?: string + openRouterUseMiddleOutTransform?: boolean + // AWS Bedrock + awsAccessKey?: string // secret + awsSecretKey?: string // secret + awsSessionToken?: string // secret + awsRegion?: string + awsUseCrossRegionInference?: boolean + awsUsePromptCache?: boolean + awspromptCacheId?: string + awsProfile?: string + awsUseProfile?: boolean + awsCustomArn?: string + // Google Vertex + vertexKeyFile?: string + vertexJsonCredentials?: string + vertexProjectId?: string + vertexRegion?: string + // OpenAI + openAiApiKey?: string // secret + openAiBaseUrl?: string + openAiR1FormatEnabled?: boolean + openAiModelId?: string + openAiCustomModelInfo?: ModelInfo + openAiUseAzure?: boolean + azureApiVersion?: string + openAiStreamingEnabled?: boolean + // Ollama + ollamaModelId?: string + ollamaBaseUrl?: string + // VS Code LM + vsCodeLmModelSelector?: vscode.LanguageModelChatSelector + // LM Studio + lmStudioModelId?: string + lmStudioBaseUrl?: string + lmStudioDraftModelId?: string + lmStudioSpeculativeDecodingEnabled?: boolean + // Gemini + geminiApiKey?: string // secret + googleGeminiBaseUrl?: string + // OpenAI Native + openAiNativeApiKey?: string // secret + // Mistral + mistralApiKey?: string // secret + mistralCodestralUrl?: string // New option for Codestral URL. + // DeepSeek + deepSeekApiKey?: string // secret + deepSeekBaseUrl?: string + // Unbound + unboundApiKey?: string // secret + unboundModelId?: string + unboundModelInfo?: ModelInfo + // Requesty + requestyApiKey?: string + requestyModelId?: string + requestyModelInfo?: ModelInfo + // Claude 3.7 Sonnet Thinking + modelTemperature?: number | null + modelMaxTokens?: number + modelMaxThinkingTokens?: number + // Generic (For now though, OpenAI, DeekSeek, Mistral, and Requesty make reference to it.) + includeMaxTokens?: boolean + // Fake AI + fakeAi?: unknown +} + +export type ProviderSettingsKey = keyof ProviderSettings + +/** + * RooCodeSettings + * + * All settings, irrespective of scope and storage. + */ + +export type RooCodeSettings = GlobalSettings & ProviderSettings + +export type RooCodeSettingsKey = keyof RooCodeSettings + +/** + * SecretState + * + * All settings that are stored in VSCode secrets. + */ + +export type SecretState = Pick< + RooCodeSettings, | "apiKey" | "glamaApiKey" | "openRouterApiKey" @@ -164,102 +548,16 @@ export type SecretKey = | "mistralApiKey" | "unboundApiKey" | "requestyApiKey" +> + +export type SecretStateKey = keyof SecretState + +/** + * GlobalState + * + * All settings that are stored in the global state. + */ + +export type GlobalState = Omit -export type GlobalStateKey = - | "apiProvider" - | "apiModelId" - | "glamaModelId" - | "glamaModelInfo" - | "awsRegion" - | "awsUseCrossRegionInference" - | "awsProfile" - | "awsUseProfile" - | "awsCustomArn" - | "vertexKeyFile" - | "vertexJsonCredentials" - | "vertexProjectId" - | "vertexRegion" - | "lastShownAnnouncementId" - | "customInstructions" - | "alwaysAllowReadOnly" - | "alwaysAllowReadOnlyOutsideWorkspace" - | "alwaysAllowWrite" - | "alwaysAllowWriteOutsideWorkspace" - | "alwaysAllowExecute" - | "alwaysAllowBrowser" - | "alwaysAllowMcp" - | "alwaysAllowModeSwitch" - | "alwaysAllowSubtasks" - | "taskHistory" - | "openAiBaseUrl" - | "openAiModelId" - | "openAiCustomModelInfo" - | "openAiUseAzure" - | "ollamaModelId" - | "ollamaBaseUrl" - | "lmStudioModelId" - | "lmStudioBaseUrl" - | "anthropicBaseUrl" - | "modelMaxThinkingTokens" - | "azureApiVersion" - | "openAiStreamingEnabled" - | "openAiR1FormatEnabled" - | "openRouterModelId" - | "openRouterModelInfo" - | "openRouterBaseUrl" - | "openRouterSpecificProvider" - | "openRouterUseMiddleOutTransform" - | "googleGeminiBaseUrl" - | "allowedCommands" - | "ttsEnabled" - | "ttsSpeed" - | "soundEnabled" - | "soundVolume" - | "diffEnabled" - | "enableCheckpoints" - | "checkpointStorage" - | "browserViewportSize" - | "screenshotQuality" - | "remoteBrowserHost" - | "fuzzyMatchThreshold" - | "writeDelayMs" - | "terminalOutputLineLimit" - | "terminalShellIntegrationTimeout" - | "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" - | "maxWorkspaceFiles" - | "browserToolEnabled" - | "lmStudioSpeculativeDecodingEnabled" - | "lmStudioDraftModelId" - | "telemetrySetting" - | "showRooIgnoredFiles" - | "remoteBrowserEnabled" - | "language" - | "maxReadFileLine" - | "fakeAi" - -export type ConfigurationKey = GlobalStateKey | SecretKey - -export type ConfigurationValues = Record +export type GlobalStateKey = keyof GlobalState diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index f42381701a..0e3af3f668 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -1,13 +1,14 @@ -import { ApiConfiguration, ApiProvider, ModelInfo } from "./api" +import { ApiConfiguration, ModelInfo } from "./api" import { HistoryItem } from "./HistoryItem" import { McpServer } from "./mcp" import { GitCommit } from "../utils/git" -import { Mode, CustomModePrompts, ModeConfig } from "./modes" -import { CustomSupportPrompts } from "./support-prompt" +import { Mode, ModeConfig } from "./modes" import { ExperimentId } from "./experiments" import { CheckpointStorage } from "./checkpoints" import { TelemetrySetting } from "./TelemetrySetting" -import type { ClineMessage, ClineAsk, ClineSay } from "../exports/roo-code" +import type { GlobalSettings, ApiConfigMeta, ClineMessage, ClineAsk, ClineSay } from "../exports/roo-code" + +export type { ApiConfigMeta } export interface LanguageModelChatSelector { vendor?: string @@ -102,73 +103,95 @@ export interface ExtensionMessage { error?: string } -export interface ApiConfigMeta { - id: string - name: string - apiProvider?: ApiProvider -} - -export interface ExtensionState { +export type ExtensionState = Pick< + GlobalSettings, + | "currentApiConfigName" + | "listApiConfigMeta" + // | "lastShownAnnouncementId" + | "customInstructions" + // | "taskHistory" // Optional in GlobalSettings, required here. + | "autoApprovalEnabled" + | "alwaysAllowReadOnly" + | "alwaysAllowReadOnlyOutsideWorkspace" + | "alwaysAllowWrite" + | "alwaysAllowWriteOutsideWorkspace" + // | "writeDelayMs" // Optional in GlobalSettings, required here. + | "alwaysAllowBrowser" + | "alwaysApproveResubmit" + // | "requestDelaySeconds" // Optional in GlobalSettings, required here. + | "alwaysAllowMcp" + | "alwaysAllowModeSwitch" + | "alwaysAllowSubtasks" + | "alwaysAllowExecute" + | "allowedCommands" + | "browserToolEnabled" + | "browserViewportSize" + | "screenshotQuality" + | "remoteBrowserEnabled" + | "remoteBrowserHost" + // | "enableCheckpoints" // Optional in GlobalSettings, required here. + // | "checkpointStorage" // Optional in GlobalSettings, required here. + | "ttsEnabled" + | "ttsSpeed" + | "soundEnabled" + | "soundVolume" + // | "maxOpenTabsContext" // Optional in GlobalSettings, required here. + // | "maxWorkspaceFiles" // Optional in GlobalSettings, required here. + // | "showRooIgnoredFiles" // Optional in GlobalSettings, required here. + // | "maxReadFileLine" // Optional in GlobalSettings, required here. + | "terminalOutputLineLimit" + | "terminalShellIntegrationTimeout" + // | "rateLimitSeconds" // Optional in GlobalSettings, required here. + | "diffEnabled" + | "fuzzyMatchThreshold" + // | "experiments" // Optional in GlobalSettings, required here. + | "language" + // | "telemetrySetting" // Optional in GlobalSettings, required here. + // | "mcpEnabled" // Optional in GlobalSettings, required here. + // | "enableMcpServerCreation" // Optional in GlobalSettings, required here. + // | "mode" // Optional in GlobalSettings, required here. + | "modeApiConfigs" + // | "customModes" // Optional in GlobalSettings, required here. + | "customModePrompts" + | "customSupportPrompts" + | "enhancementApiConfigId" +> & { version: string clineMessages: ClineMessage[] - taskHistory: HistoryItem[] - shouldShowAnnouncement: boolean + currentTaskItem?: HistoryItem apiConfiguration?: ApiConfiguration - currentApiConfigName?: string - listApiConfigMeta?: ApiConfigMeta[] - customInstructions?: string - customModePrompts?: CustomModePrompts - customSupportPrompts?: CustomSupportPrompts - alwaysAllowReadOnly?: boolean - alwaysAllowReadOnlyOutsideWorkspace?: boolean - alwaysAllowWrite?: boolean - alwaysAllowWriteOutsideWorkspace?: boolean - alwaysAllowExecute?: boolean - alwaysAllowBrowser?: boolean - alwaysAllowMcp?: boolean - alwaysApproveResubmit?: boolean - alwaysAllowModeSwitch?: boolean - alwaysAllowSubtasks?: boolean - browserToolEnabled?: boolean - requestDelaySeconds: number - rateLimitSeconds: number // Minimum time between successive requests (0 = disabled) uriScheme?: string - currentTaskItem?: HistoryItem - allowedCommands?: string[] - soundEnabled?: boolean - ttsEnabled?: boolean - ttsSpeed?: number - soundVolume?: number - diffEnabled?: boolean + shouldShowAnnouncement: boolean + + taskHistory: HistoryItem[] + + writeDelayMs: number + requestDelaySeconds: number + enableCheckpoints: boolean checkpointStorage: CheckpointStorage - browserViewportSize?: string - screenshotQuality?: number - remoteBrowserHost?: string - remoteBrowserEnabled?: boolean - fuzzyMatchThreshold?: number - language?: string - writeDelayMs: number - terminalOutputLineLimit?: number - terminalShellIntegrationTimeout?: number + maxOpenTabsContext: number // Maximum number of VSCode open tabs to include in context (0-500) + maxWorkspaceFiles: number // Maximum number of files to include in current working directory details (0-500) + showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings + maxReadFileLine: number // Maximum number of lines to read from a file before truncating + + rateLimitSeconds: number // Minimum time between successive requests (0 = disabled). + experiments: Record // Map of experiment IDs to their enabled state + mcpEnabled: boolean enableMcpServerCreation: boolean + mode: Mode - modeApiConfigs?: Record - enhancementApiConfigId?: string - experiments: Record // Map of experiment IDs to their enabled state - autoApprovalEnabled?: boolean customModes: ModeConfig[] toolRequirements?: Record // Map of tool names to their requirements (e.g. {"apply_diff": true} if diffEnabled) - maxOpenTabsContext: number // Maximum number of VSCode open tabs to include in context (0-500) - maxWorkspaceFiles: number // Maximum number of files to include in current working directory details (0-500) + cwd?: string // Current working directory telemetrySetting: TelemetrySetting telemetryKey?: string machineId?: string - showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings + renderContext: "sidebar" | "editor" - maxReadFileLine: number // Maximum number of lines to read from a file before truncating + settingsImportedAt?: number } export type { ClineMessage, ClineAsk, ClineSay } diff --git a/src/shared/HistoryItem.ts b/src/shared/HistoryItem.ts index e6e2c09ed2..8a72ee3906 100644 --- a/src/shared/HistoryItem.ts +++ b/src/shared/HistoryItem.ts @@ -1,12 +1,3 @@ -export type HistoryItem = { - id: string - number: number - ts: number - task: string - tokensIn: number - tokensOut: number - cacheWrites?: number - cacheReads?: number - totalCost: number - size?: number -} +import type { HistoryItem } from "../exports/roo-code" + +export type { HistoryItem } diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 7a4cb38c67..84740b8fd1 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -36,6 +36,8 @@ export interface WebviewMessage { | "showTaskWithId" | "deleteTaskWithId" | "exportTaskWithId" + | "importSettings" + | "exportSettings" | "resetState" | "requestOllamaModels" | "requestLmStudioModels" diff --git a/src/shared/__tests__/language.test.ts b/src/shared/__tests__/language.test.ts index 5536d0a1f5..9a99634f61 100644 --- a/src/shared/__tests__/language.test.ts +++ b/src/shared/__tests__/language.test.ts @@ -1,10 +1,11 @@ +// npx jest src/shared/__tests__/language.test.ts + import { formatLanguage } from "../language" describe("formatLanguage", () => { it("should uppercase region code in locale string", () => { - expect(formatLanguage("en-us")).toBe("en-US") - expect(formatLanguage("fr-ca")).toBe("fr-CA") - expect(formatLanguage("de-de")).toBe("de-DE") + expect(formatLanguage("pt-br")).toBe("pt-BR") + expect(formatLanguage("zh-cn")).toBe("zh-CN") }) it("should return original string if no region code present", () => { diff --git a/src/shared/__tests__/modes.test.ts b/src/shared/__tests__/modes.test.ts index 5abcb8a8b3..53b17c0385 100644 --- a/src/shared/__tests__/modes.test.ts +++ b/src/shared/__tests__/modes.test.ts @@ -366,7 +366,7 @@ describe("FileRestrictionError", () => { }) it("applies custom mode overrides", async () => { - const customModes = [ + const customModes: ModeConfig[] = [ { slug: "debug", name: "Custom Debug", diff --git a/src/shared/api.ts b/src/shared/api.ts index 069b39884b..b254cdd92d 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -1,162 +1,10 @@ -import * as vscode from "vscode" +import { ModelInfo, ProviderName, ProviderSettings } from "../exports/roo-code" -export type ApiProvider = - | "anthropic" - | "glama" - | "openrouter" - | "bedrock" - | "vertex" - | "openai" - | "ollama" - | "lmstudio" - | "gemini" - | "openai-native" - | "deepseek" - | "vscode-lm" - | "mistral" - | "unbound" - | "requesty" - | "human-relay" - | "fake-ai" +export type { ModelInfo, ProviderName as ApiProvider } -export interface ApiHandlerOptions { - apiModelId?: string - apiKey?: string // anthropic - anthropicBaseUrl?: string - vsCodeLmModelSelector?: vscode.LanguageModelChatSelector - glamaModelId?: string - glamaModelInfo?: ModelInfo - glamaApiKey?: string - openRouterApiKey?: string - openRouterModelId?: string - openRouterModelInfo?: ModelInfo - openRouterBaseUrl?: string - openRouterSpecificProvider?: string - awsAccessKey?: string - awsSecretKey?: string - awsSessionToken?: string - awsRegion?: string - awsUseCrossRegionInference?: boolean - awsUsePromptCache?: boolean - awspromptCacheId?: string - awsProfile?: string - awsUseProfile?: boolean - awsCustomArn?: string - vertexKeyFile?: string - vertexJsonCredentials?: string - vertexProjectId?: string - vertexRegion?: string - openAiBaseUrl?: string - openAiApiKey?: string - openAiR1FormatEnabled?: boolean - openAiModelId?: string - openAiCustomModelInfo?: ModelInfo - openAiUseAzure?: boolean - ollamaModelId?: string - ollamaBaseUrl?: string - lmStudioModelId?: string - lmStudioBaseUrl?: string - lmStudioDraftModelId?: string - lmStudioSpeculativeDecodingEnabled?: boolean - geminiApiKey?: string - googleGeminiBaseUrl?: string - openAiNativeApiKey?: string - mistralApiKey?: string - mistralCodestralUrl?: string // New option for Codestral URL - azureApiVersion?: string - openRouterUseMiddleOutTransform?: boolean - openAiStreamingEnabled?: boolean - deepSeekBaseUrl?: string - deepSeekApiKey?: string - includeMaxTokens?: boolean - unboundApiKey?: string - unboundModelId?: string - unboundModelInfo?: ModelInfo - requestyApiKey?: string - requestyModelId?: string - requestyModelInfo?: ModelInfo - modelTemperature?: number | null - modelMaxTokens?: number - modelMaxThinkingTokens?: number - fakeAi?: unknown -} - -export type ApiConfiguration = ApiHandlerOptions & { - apiProvider?: ApiProvider - id?: string // stable unique identifier -} - -// Import GlobalStateKey type from globalState.ts -import { GlobalStateKey } from "./globalState" +export type ApiHandlerOptions = Omit -// Define API configuration keys for dynamic object building. -// TODO: This needs actual type safety; a type error should be thrown if -// this is not an exhaustive list of all `GlobalStateKey` values. -export const API_CONFIG_KEYS: GlobalStateKey[] = [ - "apiModelId", - "anthropicBaseUrl", - "vsCodeLmModelSelector", - "glamaModelId", - "glamaModelInfo", - "openRouterModelId", - "openRouterModelInfo", - "openRouterBaseUrl", - "openRouterSpecificProvider", - "awsRegion", - "awsUseCrossRegionInference", - // "awsUsePromptCache", // NOT exist on GlobalStateKey - // "awspromptCacheId", // NOT exist on GlobalStateKey - "awsProfile", - "awsUseProfile", - "awsCustomArn", - "vertexKeyFile", - "vertexJsonCredentials", - "vertexProjectId", - "vertexRegion", - "openAiBaseUrl", - "openAiModelId", - "openAiCustomModelInfo", - "openAiUseAzure", - "ollamaModelId", - "ollamaBaseUrl", - "lmStudioModelId", - "lmStudioBaseUrl", - "lmStudioDraftModelId", - "lmStudioSpeculativeDecodingEnabled", - "googleGeminiBaseUrl", - "mistralCodestralUrl", - "azureApiVersion", - "openRouterUseMiddleOutTransform", - "openAiStreamingEnabled", - "openAiR1FormatEnabled", - // "deepSeekBaseUrl", // not exist on GlobalStateKey - // "includeMaxTokens", // not exist on GlobalStateKey - "unboundModelId", - "unboundModelInfo", - "requestyModelId", - "requestyModelInfo", - "modelTemperature", - "modelMaxTokens", - "modelMaxThinkingTokens", - "fakeAi", -] - -// Models - -export interface ModelInfo { - maxTokens?: number - contextWindow: number - supportsImages?: boolean - supportsComputerUse?: boolean - supportsPromptCache: boolean // this value is hardcoded for now - inputPrice?: number - outputPrice?: number - cacheWritesPrice?: number - cacheReadsPrice?: number - description?: string - reasoningEffort?: "low" | "medium" | "high" - thinking?: boolean -} +export type ApiConfiguration = ProviderSettings // Anthropic // https://docs.anthropic.com/en/docs/about-claude/models diff --git a/src/shared/checkExistApiConfig.ts b/src/shared/checkExistApiConfig.ts index 5246e954ab..6d928e6d35 100644 --- a/src/shared/checkExistApiConfig.ts +++ b/src/shared/checkExistApiConfig.ts @@ -1,16 +1,18 @@ -import { ApiConfiguration } from "../shared/api" -import { SECRET_KEYS } from "./globalState" +import { ProviderSettings } from "../exports/roo-code" +import { SECRET_STATE_KEYS } from "./globalState" -export function checkExistKey(config: ApiConfiguration | undefined) { - if (!config) return false +export function checkExistKey(config: ProviderSettings | undefined) { + if (!config) { + return false + } - // Special case for human-relay and fake-ai providers which don't need any configuration + // Special case for human-relay and fake-ai providers which don't need any configuration. if (config.apiProvider === "human-relay" || config.apiProvider === "fake-ai") { return true } - // Check all secret keys from the centralized SECRET_KEYS array - const hasSecretKey = SECRET_KEYS.some((key) => config[key as keyof ApiConfiguration] !== undefined) + // Check all secret keys from the centralized SECRET_STATE_KEYS array. + const hasSecretKey = SECRET_STATE_KEYS.some((key) => config[key] !== undefined) // Check additional non-secret configuration properties const hasOtherConfig = [ diff --git a/src/shared/checkpoints.ts b/src/shared/checkpoints.ts index 7cd1818c12..04f909fca8 100644 --- a/src/shared/checkpoints.ts +++ b/src/shared/checkpoints.ts @@ -1,4 +1,6 @@ -export type CheckpointStorage = "task" | "workspace" +import { CheckpointStorage } from "../exports/roo-code" + +export type { CheckpointStorage } export const isCheckpointStorage = (value: string): value is CheckpointStorage => { return value === "task" || value === "workspace" diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index f4decc324c..b563b1372a 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -1,36 +1,31 @@ +import { ExperimentId } from "../exports/roo-code" + +import { AssertEqual, Equals, Keys, Values } from "../utils/type-fu" + +export type { ExperimentId } + export const EXPERIMENT_IDS = { DIFF_STRATEGY: "experimentalDiffStrategy", SEARCH_AND_REPLACE: "search_and_replace", INSERT_BLOCK: "insert_content", POWER_STEERING: "powerSteering", MULTI_SEARCH_AND_REPLACE: "multi_search_and_replace", -} as const +} as const satisfies Record -export type ExperimentKey = keyof typeof EXPERIMENT_IDS -export type ExperimentId = valueof +type _AssertExperimentIds = AssertEqual>> -export interface ExperimentConfig { +type ExperimentKey = Keys + +interface ExperimentConfig { enabled: boolean } -type valueof = X[keyof X] - export const experimentConfigsMap: Record = { - DIFF_STRATEGY: { - enabled: false, - }, - SEARCH_AND_REPLACE: { - enabled: false, - }, - INSERT_BLOCK: { - enabled: false, - }, - POWER_STEERING: { - enabled: false, - }, - MULTI_SEARCH_AND_REPLACE: { - enabled: false, - }, + DIFF_STRATEGY: { enabled: false }, + SEARCH_AND_REPLACE: { enabled: false }, + INSERT_BLOCK: { enabled: false }, + POWER_STEERING: { enabled: false }, + MULTI_SEARCH_AND_REPLACE: { enabled: false }, } export const experimentDefault = Object.fromEntries( @@ -41,12 +36,7 @@ export const experimentDefault = Object.fromEntries( ) as Record export const experiments = { - get: (id: ExperimentKey): ExperimentConfig | undefined => { - return experimentConfigsMap[id] - }, - isEnabled: (experimentsConfig: Record, id: ExperimentId): boolean => { - return experimentsConfig[id] ?? experimentDefault[id] - }, + get: (id: ExperimentKey): ExperimentConfig | undefined => experimentConfigsMap[id], + isEnabled: (experimentsConfig: Record, id: ExperimentId) => + experimentsConfig[id] ?? experimentDefault[id], } as const - -// No longer needed as we use translation keys directly in the UI diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index 71d990906a..fa5c692174 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -1,144 +1,612 @@ -import type { SecretKey, GlobalStateKey, ConfigurationKey, ConfigurationValues } from "../exports/roo-code" +import { z } from "zod" -export type { SecretKey, GlobalStateKey, ConfigurationKey, ConfigurationValues } +import type { + ProviderName, + ModelInfo, + ExperimentId, + CheckpointStorage, + ToolGroup, + Language, + TelemetrySetting, + SecretStateKey, + GlobalStateKey, + GlobalSettings, + ProviderSettings, + ProviderSettingsKey, +} from "../exports/roo-code" + +import { Keys, AssertEqual, Equals } from "../utils/type-fu" /** - * For convenience we'd like the `RooCodeAPI` to define `SecretKey` and `GlobalStateKey`, - * but since it is a type definition file we can't export constants without some - * annoyances. In order to achieve proper type safety without using constants as - * in the type definition we use this clever Check<>Exhaustiveness pattern. - * If you extend the `SecretKey` or `GlobalStateKey` types, you will need to - * update the `SECRET_KEYS` and `GLOBAL_STATE_KEYS` arrays to include the new - * keys or a type error will be thrown. + * ProviderName */ -export const SECRET_KEYS = [ - "apiKey", - "glamaApiKey", - "openRouterApiKey", - "awsAccessKey", - "awsSecretKey", - "awsSessionToken", - "openAiApiKey", - "geminiApiKey", - "openAiNativeApiKey", - "deepSeekApiKey", - "mistralApiKey", - "unboundApiKey", - "requestyApiKey", -] as const - -type CheckSecretKeysExhaustiveness = Exclude extends never ? true : false - -const _checkSecretKeysExhaustiveness: CheckSecretKeysExhaustiveness = true - -export const GLOBAL_STATE_KEYS = [ - "apiProvider", - "apiModelId", - "glamaModelId", - "glamaModelInfo", - "awsRegion", - "awsUseCrossRegionInference", - "awsProfile", - "awsUseProfile", - "awsCustomArn", - "vertexKeyFile", - "vertexJsonCredentials", - "vertexProjectId", - "vertexRegion", - "lastShownAnnouncementId", - "customInstructions", - "alwaysAllowReadOnly", - "alwaysAllowReadOnlyOutsideWorkspace", - "alwaysAllowWrite", - "alwaysAllowWriteOutsideWorkspace", - "alwaysAllowExecute", - "alwaysAllowBrowser", - "alwaysAllowMcp", - "alwaysAllowModeSwitch", - "alwaysAllowSubtasks", - "taskHistory", - "openAiBaseUrl", - "openAiModelId", - "openAiCustomModelInfo", - "openAiUseAzure", - "ollamaModelId", - "ollamaBaseUrl", - "lmStudioModelId", - "lmStudioBaseUrl", - "anthropicBaseUrl", - "modelMaxThinkingTokens", - "azureApiVersion", - "openAiStreamingEnabled", - "openAiR1FormatEnabled", - "openRouterModelId", - "openRouterModelInfo", - "openRouterBaseUrl", - "openRouterSpecificProvider", - "openRouterUseMiddleOutTransform", - "googleGeminiBaseUrl", - "allowedCommands", - "soundEnabled", - "ttsEnabled", - "ttsSpeed", - "soundVolume", - "diffEnabled", - "enableCheckpoints", - "checkpointStorage", - "browserViewportSize", - "screenshotQuality", - "remoteBrowserHost", - "fuzzyMatchThreshold", - "writeDelayMs", - "terminalOutputLineLimit", - "terminalShellIntegrationTimeout", - "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", - "browserToolEnabled", - "lmStudioSpeculativeDecodingEnabled", - "lmStudioDraftModelId", - "telemetrySetting", - "showRooIgnoredFiles", - "remoteBrowserEnabled", - "language", - "maxWorkspaceFiles", - "maxReadFileLine", - "fakeAi", -] as const +const providerNames: Record = { + anthropic: true, + glama: true, + openrouter: true, + bedrock: true, + vertex: true, + openai: true, + ollama: true, + lmstudio: true, + gemini: true, + "openai-native": true, + deepseek: true, + "vscode-lm": true, + mistral: true, + unbound: true, + requesty: true, + "human-relay": true, + "fake-ai": true, +} -export const PASS_THROUGH_STATE_KEYS = ["taskHistory"] as const +const PROVIDER_NAMES = Object.keys(providerNames) as ProviderName[] + +const providerNamesEnum: [ProviderName, ...ProviderName[]] = [ + PROVIDER_NAMES[0], + ...PROVIDER_NAMES.slice(1).map((p) => p), +] + +/** + * CheckpointStorage + */ + +const checkpointStorages: Record = { + task: true, + workspace: true, +} + +const CHECKPOINT_STORAGES = Object.keys(checkpointStorages) as CheckpointStorage[] + +const checkpointStoragesEnum: [CheckpointStorage, ...CheckpointStorage[]] = [ + CHECKPOINT_STORAGES[0], + ...CHECKPOINT_STORAGES.slice(1).map((p) => p), +] + +/** + * ToolGroup + */ + +const toolGroups: Record = { + read: true, + edit: true, + browser: true, + command: true, + mcp: true, + modes: true, +} + +const toolGroupKeys = Object.keys(toolGroups) as ToolGroup[] + +const TOOL_GROUPS: [ToolGroup, ...ToolGroup[]] = [toolGroupKeys[0], ...toolGroupKeys.slice(1).map((p) => p)] + +/** + * Language + */ + +const languages: Record = { + ca: true, + de: true, + en: true, + es: true, + fr: true, + hi: true, + it: true, + ja: true, + ko: true, + pl: true, + "pt-BR": true, + tr: true, + vi: true, + "zh-CN": true, + "zh-TW": true, +} -type CheckGlobalStateKeysExhaustiveness = - Exclude extends never ? true : false +export const LANGUAGES = Object.keys(languages) as Language[] -const _checkGlobalStateKeysExhaustiveness: CheckGlobalStateKeysExhaustiveness = true +const languagesEnum: [Language, ...Language[]] = [LANGUAGES[0], ...LANGUAGES.slice(1).map((p) => p)] -export const isSecretKey = (key: string): key is SecretKey => SECRET_KEYS.includes(key as SecretKey) +export const isLanguage = (key: string): key is Language => LANGUAGES.includes(key as Language) + +/** + * TelemetrySetting + */ + +const telemetrySettings: Record = { + unset: true, + enabled: true, + disabled: true, +} + +export const TELEMETRY_SETTINGS = Object.keys(telemetrySettings) as TelemetrySetting[] + +const telemetrySettingsEnum: [TelemetrySetting, ...TelemetrySetting[]] = [ + TELEMETRY_SETTINGS[0], + ...TELEMETRY_SETTINGS.slice(1).map((p) => p), +] + +/** + * ProviderSettingsKey + */ + +const providerSettingsKeys: Record = { + apiProvider: true, + apiModelId: true, + // Anthropic + apiKey: true, + anthropicBaseUrl: true, + // Glama + glamaApiKey: true, + glamaModelId: true, + glamaModelInfo: true, + // OpenRouter + openRouterApiKey: true, + openRouterModelId: true, + openRouterModelInfo: true, + openRouterBaseUrl: true, + openRouterSpecificProvider: true, + openRouterUseMiddleOutTransform: true, + // AWS Bedrock + awsAccessKey: true, + awsSecretKey: true, + awsSessionToken: true, + awsRegion: true, + awsUseCrossRegionInference: true, + awsUsePromptCache: true, + awspromptCacheId: true, + awsProfile: true, + awsUseProfile: true, + awsCustomArn: true, + // Google Vertex + vertexKeyFile: true, + vertexJsonCredentials: true, + vertexProjectId: true, + vertexRegion: true, + // OpenAI + openAiApiKey: true, + openAiBaseUrl: true, + openAiR1FormatEnabled: true, + openAiModelId: true, + openAiCustomModelInfo: true, + openAiUseAzure: true, + openAiStreamingEnabled: true, + // Ollama + ollamaModelId: true, + ollamaBaseUrl: true, + // VS Code LM + vsCodeLmModelSelector: true, + // LM Studio + lmStudioModelId: true, + lmStudioBaseUrl: true, + lmStudioDraftModelId: true, + lmStudioSpeculativeDecodingEnabled: true, + // Gemini + geminiApiKey: true, + googleGeminiBaseUrl: true, + // OpenAI Native + openAiNativeApiKey: true, + // Mistral + mistralApiKey: true, + mistralCodestralUrl: true, + // Azure + azureApiVersion: true, + // DeepSeek + deepSeekApiKey: true, + deepSeekBaseUrl: true, + includeMaxTokens: true, + // Unbound + unboundApiKey: true, + unboundModelId: true, + unboundModelInfo: true, + // Requesty + requestyApiKey: true, + requestyModelId: true, + requestyModelInfo: true, + // Claude 3.7 Sonnet Thinking + modelTemperature: true, + modelMaxTokens: true, + modelMaxThinkingTokens: true, + // Fake AI + fakeAi: true, +} + +export const PROVIDER_SETTINGS_KEYS = Object.keys(providerSettingsKeys) as ProviderSettingsKey[] + +/** + * SecretStateKey + */ + +const secretStateKeys: Record = { + apiKey: true, + glamaApiKey: true, + openRouterApiKey: true, + awsAccessKey: true, + awsSecretKey: true, + awsSessionToken: true, + openAiApiKey: true, + geminiApiKey: true, + openAiNativeApiKey: true, + deepSeekApiKey: true, + mistralApiKey: true, + unboundApiKey: true, + requestyApiKey: true, +} + +export const SECRET_STATE_KEYS = Object.keys(secretStateKeys) as SecretStateKey[] + +export const isSecretStateKey = (key: string): key is SecretStateKey => + SECRET_STATE_KEYS.includes(key as SecretStateKey) + +/** + * GlobalStateKey + */ + +export const globalStateKeys: Record = { + apiProvider: true, + apiModelId: true, + glamaModelId: true, + glamaModelInfo: true, + awsRegion: true, + awsUseCrossRegionInference: true, + awsProfile: true, + awsUseProfile: true, + awsCustomArn: true, + awsUsePromptCache: true, + awspromptCacheId: true, + vertexKeyFile: true, + vertexJsonCredentials: true, + vertexProjectId: true, + vertexRegion: true, + lastShownAnnouncementId: true, + customInstructions: true, + alwaysAllowReadOnly: true, + alwaysAllowReadOnlyOutsideWorkspace: true, + alwaysAllowWrite: true, + alwaysAllowWriteOutsideWorkspace: true, + alwaysAllowExecute: true, + alwaysAllowBrowser: true, + alwaysAllowMcp: true, + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + taskHistory: true, + openAiBaseUrl: true, + openAiModelId: true, + openAiCustomModelInfo: true, + openAiUseAzure: true, + ollamaModelId: true, + ollamaBaseUrl: true, + lmStudioModelId: true, + lmStudioBaseUrl: true, + anthropicBaseUrl: true, + includeMaxTokens: true, + modelMaxThinkingTokens: true, + azureApiVersion: true, + openAiStreamingEnabled: true, + openAiR1FormatEnabled: true, + openRouterModelId: true, + openRouterModelInfo: true, + openRouterBaseUrl: true, + openRouterSpecificProvider: true, + openRouterUseMiddleOutTransform: true, + googleGeminiBaseUrl: true, + deepSeekBaseUrl: true, + allowedCommands: true, + soundEnabled: true, + ttsEnabled: true, + ttsSpeed: true, + soundVolume: true, + diffEnabled: true, + enableCheckpoints: true, + checkpointStorage: true, + browserViewportSize: true, + screenshotQuality: true, + remoteBrowserHost: true, + fuzzyMatchThreshold: true, + writeDelayMs: true, + terminalOutputLineLimit: true, + terminalShellIntegrationTimeout: true, + mcpEnabled: true, + enableMcpServerCreation: true, + alwaysApproveResubmit: true, + requestDelaySeconds: true, + rateLimitSeconds: true, + currentApiConfigName: true, + listApiConfigMeta: true, + vsCodeLmModelSelector: true, + mode: true, + modeApiConfigs: true, + customModePrompts: true, + customSupportPrompts: true, + enhancementApiConfigId: true, + experiments: true, // Map of experiment IDs to their enabled state. + autoApprovalEnabled: true, + customModes: true, // Array of custom modes. + unboundModelId: true, + requestyModelId: true, + requestyModelInfo: true, + unboundModelInfo: true, + modelTemperature: true, + modelMaxTokens: true, + mistralCodestralUrl: true, + maxOpenTabsContext: true, + browserToolEnabled: true, + lmStudioSpeculativeDecodingEnabled: true, + lmStudioDraftModelId: true, + telemetrySetting: true, + showRooIgnoredFiles: true, + remoteBrowserEnabled: true, + language: true, + maxWorkspaceFiles: true, + maxReadFileLine: true, + fakeAi: true, +} + +export const GLOBAL_STATE_KEYS = Object.keys(globalStateKeys) as GlobalStateKey[] export const isGlobalStateKey = (key: string): key is GlobalStateKey => GLOBAL_STATE_KEYS.includes(key as GlobalStateKey) +/** + * Schemas + */ + +const apiConfigMetaSchema = z.object({ + id: z.string(), + name: z.string(), + apiProvider: z.enum(providerNamesEnum).optional(), +}) + +const taskHistorySchema = z.object({ + id: z.string(), + number: z.number(), + ts: z.number(), + task: z.string(), + tokensIn: z.number(), + tokensOut: z.number(), + cacheWrites: z.number().optional(), + cacheReads: z.number().optional(), + totalCost: z.number(), + size: z.number().optional(), +}) + +const toolGroupSchema = z.enum(TOOL_GROUPS) + +const groupEntrySchema = z.union([ + toolGroupSchema, + z + .tuple([ + toolGroupSchema, + z.object({ + fileRegex: z.string().optional(), + description: z.string().optional(), + }), + ]) + .readonly(), +]) + +const modeConfigSchema = z.object({ + slug: z.string(), + name: z.string(), + roleDefinition: z.string(), + customInstructions: z.string().optional(), + groups: z.array(groupEntrySchema).readonly(), + source: z.enum(["global", "project"]).optional(), +}) + +const experimentsSchema = z.object({ + experimentalDiffStrategy: z.boolean(), + search_and_replace: z.boolean(), + insert_content: z.boolean(), + powerSteering: z.boolean(), + multi_search_and_replace: z.boolean(), +}) + +// Throws a type error if the inferred type of the schema is not equal to the +// type of the GlobalSettings. +type _AssertExperiments = AssertEqual>>> + +export const globalSettingsSchema = z.object({ + currentApiConfigName: z.string().optional(), + listApiConfigMeta: z.array(apiConfigMetaSchema).optional(), + lastShownAnnouncementId: z.string().optional(), + customInstructions: z.string().optional(), + taskHistory: z.array(taskHistorySchema).optional(), + + autoApprovalEnabled: z.boolean().optional(), + alwaysAllowReadOnly: z.boolean().optional(), + alwaysAllowReadOnlyOutsideWorkspace: z.boolean().optional(), + alwaysAllowWrite: z.boolean().optional(), + alwaysAllowWriteOutsideWorkspace: z.boolean().optional(), + writeDelayMs: z.number().optional(), + alwaysAllowBrowser: z.boolean().optional(), + alwaysApproveResubmit: z.boolean().optional(), + requestDelaySeconds: z.number().optional(), + alwaysAllowMcp: z.boolean().optional(), + alwaysAllowModeSwitch: z.boolean().optional(), + alwaysAllowSubtasks: z.boolean().optional(), + alwaysAllowExecute: z.boolean().optional(), + allowedCommands: z.array(z.string()).optional(), + + browserToolEnabled: z.boolean().optional(), + browserViewportSize: z.string().optional(), + screenshotQuality: z.number().optional(), + remoteBrowserEnabled: z.boolean().optional(), + remoteBrowserHost: z.string().optional(), + + enableCheckpoints: z.boolean().optional(), + checkpointStorage: z.enum(checkpointStoragesEnum).optional(), + + ttsEnabled: z.boolean().optional(), + ttsSpeed: z.number().optional(), + soundEnabled: z.boolean().optional(), + soundVolume: z.number().optional(), + + maxOpenTabsContext: z.number().optional(), + maxWorkspaceFiles: z.number().optional(), + showRooIgnoredFiles: z.boolean().optional(), + maxReadFileLine: z.number().optional(), + + terminalOutputLineLimit: z.number().optional(), + terminalShellIntegrationTimeout: z.number().optional(), + + rateLimitSeconds: z.number().optional(), + diffEnabled: z.boolean().optional(), + fuzzyMatchThreshold: z.number().optional(), + experiments: experimentsSchema.optional(), + + language: z.enum(languagesEnum).optional(), + + telemetrySetting: z.enum(telemetrySettingsEnum).optional(), + + mcpEnabled: z.boolean().optional(), + enableMcpServerCreation: z.boolean().optional(), + + mode: z.string().optional(), + modeApiConfigs: z.record(z.string(), z.string()).optional(), + customModes: z.array(modeConfigSchema).optional(), + customModePrompts: z + .record( + z.string(), + z + .object({ + roleDefinition: z.string().optional(), + customInstructions: z.string().optional(), + }) + .optional(), + ) + .optional(), + customSupportPrompts: z.record(z.string(), z.string().optional()).optional(), + enhancementApiConfigId: z.string().optional(), +}) + +type Key = "vsCodeLmModelSelector" + +// Throws a type error if the inferred type of the schema is not equal to the +// type of the GlobalSettings. +type _AssertGlobalSettings = AssertEqual>> + +export const modelInfoSchema = z.object({ + maxTokens: z.number().optional(), + contextWindow: z.number(), + supportsImages: z.boolean().optional(), + supportsComputerUse: z.boolean().optional(), + supportsPromptCache: z.boolean(), + inputPrice: z.number().optional(), + outputPrice: z.number().optional(), + cacheWritesPrice: z.number().optional(), + cacheReadsPrice: z.number().optional(), + description: z.string().optional(), + reasoningEffort: z.enum(["low", "medium", "high"]).optional(), + thinking: z.boolean().optional(), +}) + +// Throws a type error if the inferred type of the schema is not equal to the +// type of the ModelInfo. +type _AssertModelInfo = AssertEqual>> + +export const providerSettingsSchema = z.object({ + apiProvider: z.enum(providerNamesEnum).optional(), + // Anthropic + apiModelId: z.string().optional(), + apiKey: z.string().optional(), + anthropicBaseUrl: z.string().optional(), + // Glama + glamaModelId: z.string().optional(), + glamaModelInfo: modelInfoSchema.optional(), + glamaApiKey: z.string().optional(), + // OpenRouter + openRouterApiKey: z.string().optional(), + openRouterModelId: z.string().optional(), + openRouterModelInfo: modelInfoSchema.optional(), + openRouterBaseUrl: z.string().optional(), + openRouterSpecificProvider: z.string().optional(), + // AWS Bedrock + awsAccessKey: z.string().optional(), + awsSecretKey: z.string().optional(), + awsSessionToken: z.string().optional(), + awsRegion: z.string().optional(), + awsUseCrossRegionInference: z.boolean().optional(), + awsUsePromptCache: z.boolean().optional(), + awspromptCacheId: z.string().optional(), + awsProfile: z.string().optional(), + awsUseProfile: z.boolean().optional(), + awsCustomArn: z.string().optional(), + // Google Vertex + vertexKeyFile: z.string().optional(), + vertexJsonCredentials: z.string().optional(), + vertexProjectId: z.string().optional(), + vertexRegion: z.string().optional(), + // OpenAI + openAiBaseUrl: z.string().optional(), + openAiApiKey: z.string().optional(), + openAiR1FormatEnabled: z.boolean().optional(), + openAiModelId: z.string().optional(), + openAiCustomModelInfo: modelInfoSchema.optional(), + openAiUseAzure: z.boolean().optional(), + // Ollama + ollamaModelId: z.string().optional(), + ollamaBaseUrl: z.string().optional(), + // VS Code LM + vsCodeLmModelSelector: z + .object({ + vendor: z.string().optional(), + family: z.string().optional(), + version: z.string().optional(), + id: z.string().optional(), + }) + .optional(), + // LM Studio + lmStudioModelId: z.string().optional(), + lmStudioBaseUrl: z.string().optional(), + lmStudioDraftModelId: z.string().optional(), + lmStudioSpeculativeDecodingEnabled: z.boolean().optional(), + // Gemini + geminiApiKey: z.string().optional(), + googleGeminiBaseUrl: z.string().optional(), + // OpenAI Native + openAiNativeApiKey: z.string().optional(), + // Mistral + mistralApiKey: z.string().optional(), + mistralCodestralUrl: z.string().optional(), + // Azure + azureApiVersion: z.string().optional(), + // OpenRouter + openRouterUseMiddleOutTransform: z.boolean().optional(), + openAiStreamingEnabled: z.boolean().optional(), + // DeepSeek + deepSeekBaseUrl: z.string().optional(), + deepSeekApiKey: z.string().optional(), + // Unbound + unboundApiKey: z.string().optional(), + unboundModelId: z.string().optional(), + unboundModelInfo: modelInfoSchema.optional(), + // Requesty + requestyApiKey: z.string().optional(), + requestyModelId: z.string().optional(), + requestyModelInfo: modelInfoSchema.optional(), + // Claude 3.7 Sonnet Thinking + modelTemperature: z.number().nullish(), + modelMaxTokens: z.number().optional(), + modelMaxThinkingTokens: z.number().optional(), + // Generic + includeMaxTokens: z.boolean().optional(), + // Fake AI + fakeAi: z.unknown().optional(), +}) + +// Throws a type error if the inferred type of the schema is not equal to the +// type of the ProviderSettings. +type _AssertProviderSettings = AssertEqual>> + +export const rooCodeSettingsSchema = globalSettingsSchema.merge(providerSettingsSchema) + +/** + * Pass-through state keys. + * TODO: What are these? + */ + +export const PASS_THROUGH_STATE_KEYS = ["taskHistory"] as const + export const isPassThroughStateKey = (key: string): key is (typeof PASS_THROUGH_STATE_KEYS)[number] => PASS_THROUGH_STATE_KEYS.includes(key as (typeof PASS_THROUGH_STATE_KEYS)[number]) diff --git a/src/shared/language.ts b/src/shared/language.ts index c7bc53b00c..99830177eb 100644 --- a/src/shared/language.ts +++ b/src/shared/language.ts @@ -1,7 +1,13 @@ +import { type Language } from "../exports/roo-code" +import { isLanguage } from "./globalState" + +export type { Language } + /** * Language name mapping from ISO codes to full language names */ -export const LANGUAGES: Record = { + +export const LANGUAGES: Record = { ca: "Català", de: "Deutsch", en: "English", @@ -26,10 +32,12 @@ export const LANGUAGES: Record = { * @param vscodeLocale - The VSCode locale string to format (e.g., "en-us", "fr-ca") * @returns The formatted locale string with uppercase region code */ -export function formatLanguage(vscodeLocale: string): string { + +export function formatLanguage(vscodeLocale: string): Language { if (!vscodeLocale) { - return "en" // Default to English if no locale is provided + return "en" } - return vscodeLocale.replace(/-(\w+)$/, (_, region) => `-${region.toUpperCase()}`) + const formattedLocale = vscodeLocale.replace(/-(\w+)$/, (_, region) => `-${region.toUpperCase()}`) + return isLanguage(formattedLocale) ? formattedLocale : "en" } diff --git a/src/shared/modes.ts b/src/shared/modes.ts index 6fd57206bf..cb295df6ae 100644 --- a/src/shared/modes.ts +++ b/src/shared/modes.ts @@ -1,38 +1,12 @@ import * as vscode from "vscode" + +import { GroupOptions, GroupEntry, ModeConfig, PromptComponent, CustomModePrompts } from "../exports/roo-code" import { TOOL_GROUPS, ToolGroup, ALWAYS_AVAILABLE_TOOLS } from "./tool-groups" import { addCustomInstructions } from "../core/prompts/sections/custom-instructions" -// Mode types export type Mode = string -// Group options type -export type GroupOptions = { - fileRegex?: string // Regular expression pattern - description?: string // Human-readable description of the pattern -} - -// Group entry can be either a string or tuple with options -export type GroupEntry = ToolGroup | readonly [ToolGroup, GroupOptions] - -// Mode configuration type -export type ModeConfig = { - slug: string - name: string - roleDefinition: string - customInstructions?: string - groups: readonly GroupEntry[] // Now supports both simple strings and tuples with options - source?: "global" | "project" // Where this mode was loaded from -} - -// Mode-specific prompts only -export type PromptComponent = { - roleDefinition?: string - customInstructions?: string -} - -export type CustomModePrompts = { - [key: string]: PromptComponent | undefined -} +export type { GroupOptions, GroupEntry, ModeConfig, PromptComponent, CustomModePrompts } // Helper to extract group name regardless of format export function getGroupName(group: GroupEntry): ToolGroup { diff --git a/src/shared/tool-groups.ts b/src/shared/tool-groups.ts index 3cc4338394..4d52b65f4d 100644 --- a/src/shared/tool-groups.ts +++ b/src/shared/tool-groups.ts @@ -23,8 +23,10 @@ export const TOOL_DISPLAY_NAMES = { new_task: "create new task", } as const +export type ToolGroup = "read" | "edit" | "browser" | "command" | "mcp" | "modes" + // Define available tool groups -export const TOOL_GROUPS: Record = { +export const TOOL_GROUPS: Record = { read: { tools: ["read_file", "fetch_instructions", "search_files", "list_files", "list_code_definition_names"], }, @@ -46,8 +48,6 @@ export const TOOL_GROUPS: Record = { }, } -export type ToolGroup = keyof typeof TOOL_GROUPS - // Tools that are always available to all modes export const ALWAYS_AVAILABLE_TOOLS = [ "ask_followup_question", diff --git a/src/utils/type-fu.ts b/src/utils/type-fu.ts new file mode 100644 index 0000000000..e7d93b77ac --- /dev/null +++ b/src/utils/type-fu.ts @@ -0,0 +1,7 @@ +export type Keys = keyof T + +export type Values = T[keyof T] + +export type Equals = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? true : false + +export type AssertEqual = T diff --git a/webview-ui/src/components/settings/About.tsx b/webview-ui/src/components/settings/About.tsx index d196ec037d..c3f20f1196 100644 --- a/webview-ui/src/components/settings/About.tsx +++ b/webview-ui/src/components/settings/About.tsx @@ -1,14 +1,15 @@ import { HTMLAttributes } from "react" import { useAppTranslation } from "@/i18n/TranslationContext" import { Trans } from "react-i18next" -import { Info } from "lucide-react" +import { Info, Download, Upload, TriangleAlert } from "lucide-react" -import { VSCodeButton, VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react" +import { VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react" import { TelemetrySetting } from "../../../../src/shared/TelemetrySetting" import { vscode } from "@/utils/vscode" import { cn } from "@/lib/utils" +import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui" import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" @@ -34,7 +35,6 @@ export const About = ({ version, telemetrySetting, setTelemetrySetting, classNam
{ const checked = e.target.checked === true @@ -42,12 +42,7 @@ export const About = ({ version, telemetrySetting, setTelemetrySetting, classNam }}> {t("settings:footer.telemetry.label")} -

+

{t("settings:footer.telemetry.description")}

@@ -63,15 +58,47 @@ export const About = ({ version, telemetrySetting, setTelemetrySetting, classNam /> -
-

{t("settings:footer.reset.description")}

- vscode.postMessage({ type: "resetState" })} - appearance="secondary" - className="shrink-0"> - +
+ + + + + + vscode.postMessage({ type: "importSettings", text: "provider" })}> + Current Provider Settings + + vscode.postMessage({ type: "importSettings", text: "global" })}> + Global Settings + + + + + + + + + vscode.postMessage({ type: "exportSettings", text: "provider" })}> + Current Provider Settings + + vscode.postMessage({ type: "exportSettings", text: "global" })}> + Global Settings + + + +
diff --git a/webview-ui/src/components/settings/LanguageSettings.tsx b/webview-ui/src/components/settings/LanguageSettings.tsx index 7e6d1ea9bc..2fe6bcc347 100644 --- a/webview-ui/src/components/settings/LanguageSettings.tsx +++ b/webview-ui/src/components/settings/LanguageSettings.tsx @@ -4,7 +4,7 @@ import { Globe } from "lucide-react" import { cn } from "@/lib/utils" import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui" -import { LANGUAGES } from "../../../../src/shared/language" +import { Language, LANGUAGES } from "../../../../src/shared/language" import { SetCachedStateField } from "./types" import { SectionHeader } from "./SectionHeader" @@ -28,7 +28,7 @@ export const LanguageSettings = ({ language, setCachedStateField, className, ...
- setCachedStateField("language", value as Language)}> diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index a269e6b672..2dde325ba9 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -85,7 +85,7 @@ const SettingsView = forwardRef(({ onDone }, const { t } = useAppTranslation() const extensionState = useExtensionState() - const { currentApiConfigName, listApiConfigMeta, uriScheme, version } = extensionState + const { currentApiConfigName, listApiConfigMeta, uriScheme, version, settingsImportedAt } = extensionState const [isDiscardDialogShow, setDiscardDialogShow] = useState(false) const [isChangeDetected, setChangeDetected] = useState(false) @@ -138,6 +138,7 @@ const SettingsView = forwardRef(({ onDone }, // Make sure apiConfiguration is initialized and managed by SettingsView. const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration]) + useEffect(() => { // Update only when currentApiConfigName is changed. // Expected to be triggered by loadApiConfiguration/upsertApiConfiguration. @@ -150,6 +151,14 @@ const SettingsView = forwardRef(({ onDone }, setChangeDetected(false) }, [currentApiConfigName, extensionState, isChangeDetected]) + // Bust the cache when settings are imported. + useEffect(() => { + if (settingsImportedAt) { + setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState })) + setChangeDetected(false) + } + }, [settingsImportedAt, extensionState]) + const setCachedStateField: SetCachedStateField = useCallback((field, value) => { setCachedState((prevState) => { if (prevState[field] === value) { @@ -182,11 +191,7 @@ const SettingsView = forwardRef(({ onDone }, } setChangeDetected(true) - - return { - ...prevState, - experiments: { ...prevState.experiments, [id]: enabled }, - } + return { ...prevState, experiments: { ...prevState.experiments, [id]: enabled } } }) }, []) @@ -195,11 +200,9 @@ const SettingsView = forwardRef(({ onDone }, if (prevState.telemetrySetting === setting) { return prevState } + setChangeDetected(true) - return { - ...prevState, - telemetrySetting: setting, - } + return { ...prevState, telemetrySetting: setting } }) }, []) @@ -460,8 +463,8 @@ const SettingsView = forwardRef(({ onDone }, maxOpenTabsContext={maxOpenTabsContext} maxWorkspaceFiles={maxWorkspaceFiles ?? 200} showRooIgnoredFiles={showRooIgnoredFiles} - setCachedStateField={setCachedStateField} maxReadFileLine={maxReadFileLine} + setCachedStateField={setCachedStateField} /> From 3594162a3b3add7885c9ca806c7c70e465ed1e7f Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Wed, 26 Mar 2025 00:25:08 -0700 Subject: [PATCH 02/10] Update src/core/contextProxy.ts Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- src/core/contextProxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/contextProxy.ts b/src/core/contextProxy.ts index e213a6e7de..0b8834f465 100644 --- a/src/core/contextProxy.ts +++ b/src/core/contextProxy.ts @@ -250,7 +250,7 @@ export class ContextProxy { return providerSettings } catch (error) { logger.error( - `Error importing API configuration from ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + `Error importing provider settings from ${filePath}: ${error instanceof Error ? error.message : String(error)}`, ) return undefined } From 63185a963d45bb7bf004de2bebd2cf9080ae0ebf Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Wed, 26 Mar 2025 00:25:29 -0700 Subject: [PATCH 03/10] Update src/core/contextProxy.ts Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- src/core/contextProxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/contextProxy.ts b/src/core/contextProxy.ts index 0b8834f465..413abd9766 100644 --- a/src/core/contextProxy.ts +++ b/src/core/contextProxy.ts @@ -234,7 +234,7 @@ export class ContextProxy { return sanitized } catch (error) { logger.error( - `Error exporting API configuration to ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + `Error exporting provider settings to ${filePath}: ${error instanceof Error ? error.message : String(error)}`, ) return undefined } From 920acba9c842567e54b06b8a72581e738619d331 Mon Sep 17 00:00:00 2001 From: cte Date: Wed, 26 Mar 2025 00:25:24 -0700 Subject: [PATCH 04/10] Add comment, tweak function name --- src/core/contextProxy.ts | 4 ++-- src/exports/roo-code.d.ts | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/core/contextProxy.ts b/src/core/contextProxy.ts index 413abd9766..2dc4156066 100644 --- a/src/core/contextProxy.ts +++ b/src/core/contextProxy.ts @@ -149,7 +149,7 @@ export class ContextProxy { : this.originalContext.secrets.store(key, value) } - private getAllSecrets(): SecretState { + private getAllSecretState(): SecretState { return Object.fromEntries(SECRET_STATE_KEYS.map((key) => [key, this.getSecret(key)])) } @@ -271,7 +271,7 @@ export class ContextProxy { } public getValues(): RooCodeSettings { - return { ...this.getAllGlobalState(), ...this.getAllSecrets() } + return { ...this.getAllGlobalState(), ...this.getAllSecretState() } } public async setValues(values: RooCodeSettings) { diff --git a/src/exports/roo-code.d.ts b/src/exports/roo-code.d.ts index 392c999404..7c112b7b0e 100644 --- a/src/exports/roo-code.d.ts +++ b/src/exports/roo-code.d.ts @@ -328,6 +328,10 @@ export type GlobalSettingsKey = keyof GlobalSettings /** * DiscriminatedProviderSettings + * + * NOTE: This is actually how our provider settings should be typed, but it + * will take a little elbow grease to move to this shape. For now we're just + * using it to generate the `ProviderName`. */ export type DiscriminatedProviderSettings = From 9dc7612ca81508bd54ee2477d9fd039d829c5792 Mon Sep 17 00:00:00 2001 From: cte Date: Wed, 26 Mar 2025 00:28:24 -0700 Subject: [PATCH 05/10] Add changeset --- .changeset/lucky-hairs-join.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lucky-hairs-join.md diff --git a/.changeset/lucky-hairs-join.md b/.changeset/lucky-hairs-join.md new file mode 100644 index 0000000000..095149b6a5 --- /dev/null +++ b/.changeset/lucky-hairs-join.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Wrangle our settings-related types and add support for settings import / export From 02b6a9a488521cf908b4f6fb8fc8e8a8635ecd2e Mon Sep 17 00:00:00 2001 From: cte Date: Wed, 26 Mar 2025 00:50:29 -0700 Subject: [PATCH 06/10] Clean up toolGroup types --- src/shared/globalState.ts | 6 +++--- src/shared/tool-groups.ts | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index fa5c692174..c4ec6bf2f1 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -77,9 +77,9 @@ const toolGroups: Record = { modes: true, } -const toolGroupKeys = Object.keys(toolGroups) as ToolGroup[] +const TOOL_GROUPS = Object.keys(toolGroups) as ToolGroup[] -const TOOL_GROUPS: [ToolGroup, ...ToolGroup[]] = [toolGroupKeys[0], ...toolGroupKeys.slice(1).map((p) => p)] +const toolGroupsEnum: [ToolGroup, ...ToolGroup[]] = [TOOL_GROUPS[0], ...TOOL_GROUPS.slice(1).map((p) => p)] /** * Language @@ -370,7 +370,7 @@ const taskHistorySchema = z.object({ size: z.number().optional(), }) -const toolGroupSchema = z.enum(TOOL_GROUPS) +const toolGroupSchema = z.enum(toolGroupsEnum) const groupEntrySchema = z.union([ toolGroupSchema, diff --git a/src/shared/tool-groups.ts b/src/shared/tool-groups.ts index 4d52b65f4d..8b5f033584 100644 --- a/src/shared/tool-groups.ts +++ b/src/shared/tool-groups.ts @@ -1,3 +1,5 @@ +import type { ToolGroup } from "../exports/roo-code" + // Define tool group configuration export type ToolGroupConfig = { tools: readonly string[] @@ -23,7 +25,7 @@ export const TOOL_DISPLAY_NAMES = { new_task: "create new task", } as const -export type ToolGroup = "read" | "edit" | "browser" | "command" | "mcp" | "modes" +export type { ToolGroup } // Define available tool groups export const TOOL_GROUPS: Record = { From f65fa56faed2b12d4075e6dd79b747419f53079b Mon Sep 17 00:00:00 2001 From: cte Date: Wed, 26 Mar 2025 00:54:06 -0700 Subject: [PATCH 07/10] Comments tweak --- src/shared/globalState.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index c4ec6bf2f1..5c96f7d3c3 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -402,8 +402,8 @@ const experimentsSchema = z.object({ multi_search_and_replace: z.boolean(), }) -// Throws a type error if the inferred type of the schema is not equal to the -// type of the GlobalSettings. +// Throws a type error if the inferred type of the experimentsSchema is not +// equal to ExperimentId. type _AssertExperiments = AssertEqual>>> export const globalSettingsSchema = z.object({ @@ -482,8 +482,8 @@ export const globalSettingsSchema = z.object({ type Key = "vsCodeLmModelSelector" -// Throws a type error if the inferred type of the schema is not equal to the -// type of the GlobalSettings. +// Throws a type error if the inferred type of the globalSettingsSchema is not +// equal to GlobalSettings. type _AssertGlobalSettings = AssertEqual>> export const modelInfoSchema = z.object({ @@ -501,8 +501,8 @@ export const modelInfoSchema = z.object({ thinking: z.boolean().optional(), }) -// Throws a type error if the inferred type of the schema is not equal to the -// type of the ModelInfo. +// Throws a type error if the inferred type of the modelInfoSchema is not equal +// to ModelInfo. type _AssertModelInfo = AssertEqual>> export const providerSettingsSchema = z.object({ @@ -595,8 +595,8 @@ export const providerSettingsSchema = z.object({ fakeAi: z.unknown().optional(), }) -// Throws a type error if the inferred type of the schema is not equal to the -// type of the ProviderSettings. +// Throws a type error if the inferred type of the providerSettingsSchema is not +// equal to ProviderSettings. type _AssertProviderSettings = AssertEqual>> export const rooCodeSettingsSchema = globalSettingsSchema.merge(providerSettingsSchema) From 421deb836e3dbcd832d19ba914672130c164f1a0 Mon Sep 17 00:00:00 2001 From: cte Date: Wed, 26 Mar 2025 00:54:56 -0700 Subject: [PATCH 08/10] Remove unused type --- src/shared/globalState.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index 5c96f7d3c3..e9fcdfabe2 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -480,8 +480,6 @@ export const globalSettingsSchema = z.object({ enhancementApiConfigId: z.string().optional(), }) -type Key = "vsCodeLmModelSelector" - // Throws a type error if the inferred type of the globalSettingsSchema is not // equal to GlobalSettings. type _AssertGlobalSettings = AssertEqual>> From d79b94d0a6f9efcff0bb07bdfbc6fa2809bc34a0 Mon Sep 17 00:00:00 2001 From: cte Date: Wed, 26 Mar 2025 01:09:52 -0700 Subject: [PATCH 09/10] More type safety --- src/shared/globalState.ts | 128 +++++++++++++++++++++++++------------- 1 file changed, 84 insertions(+), 44 deletions(-) diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index e9fcdfabe2..f11d39fc5a 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -2,17 +2,21 @@ import { z } from "zod" import type { ProviderName, - ModelInfo, - ExperimentId, CheckpointStorage, ToolGroup, Language, TelemetrySetting, + ProviderSettingsKey, SecretStateKey, GlobalStateKey, - GlobalSettings, + ModelInfo, + ApiConfigMeta, + HistoryItem, + GroupEntry, + ModeConfig, + ExperimentId, ProviderSettings, - ProviderSettingsKey, + GlobalSettings, } from "../exports/roo-code" import { Keys, AssertEqual, Equals } from "../utils/type-fu" @@ -103,7 +107,7 @@ const languages: Record = { "zh-TW": true, } -export const LANGUAGES = Object.keys(languages) as Language[] +const LANGUAGES = Object.keys(languages) as Language[] const languagesEnum: [Language, ...Language[]] = [LANGUAGES[0], ...LANGUAGES.slice(1).map((p) => p)] @@ -119,7 +123,7 @@ const telemetrySettings: Record = { disabled: true, } -export const TELEMETRY_SETTINGS = Object.keys(telemetrySettings) as TelemetrySetting[] +const TELEMETRY_SETTINGS = Object.keys(telemetrySettings) as TelemetrySetting[] const telemetrySettingsEnum: [TelemetrySetting, ...TelemetrySetting[]] = [ TELEMETRY_SETTINGS[0], @@ -242,7 +246,7 @@ export const isSecretStateKey = (key: string): key is SecretStateKey => * GlobalStateKey */ -export const globalStateKeys: Record = { +const globalStateKeys: Record = { apiProvider: true, apiModelId: true, glamaModelId: true, @@ -344,20 +348,63 @@ export const globalStateKeys: Record = { export const GLOBAL_STATE_KEYS = Object.keys(globalStateKeys) as GlobalStateKey[] -export const isGlobalStateKey = (key: string): key is GlobalStateKey => - GLOBAL_STATE_KEYS.includes(key as GlobalStateKey) +/** + * PassThroughStateKey + * + * TODO: Why is this necessary? + */ + +const PASS_THROUGH_STATE_KEYS = ["taskHistory"] as const + +type PassThroughStateKey = (typeof PASS_THROUGH_STATE_KEYS)[number] + +export const isPassThroughStateKey = (key: string): key is PassThroughStateKey => + PASS_THROUGH_STATE_KEYS.includes(key as PassThroughStateKey) /** * Schemas */ +/** + * ModelInfo + */ + +const modelInfoSchema = z.object({ + maxTokens: z.number().optional(), + contextWindow: z.number(), + supportsImages: z.boolean().optional(), + supportsComputerUse: z.boolean().optional(), + supportsPromptCache: z.boolean(), + inputPrice: z.number().optional(), + outputPrice: z.number().optional(), + cacheWritesPrice: z.number().optional(), + cacheReadsPrice: z.number().optional(), + description: z.string().optional(), + reasoningEffort: z.enum(["low", "medium", "high"]).optional(), + thinking: z.boolean().optional(), +}) + +// Throws a type error if the inferred type of the modelInfoSchema is not equal +// to ModelInfo. +type _AssertModelInfo = AssertEqual>> + +/** + * ApiConfigMeta + */ + const apiConfigMetaSchema = z.object({ id: z.string(), name: z.string(), apiProvider: z.enum(providerNamesEnum).optional(), }) -const taskHistorySchema = z.object({ +type _AssertApiConfigMeta = AssertEqual>> + +/** + * HistoryItem + */ + +const historyItemSchema = z.object({ id: z.string(), number: z.number(), ts: z.number(), @@ -370,13 +417,17 @@ const taskHistorySchema = z.object({ size: z.number().optional(), }) -const toolGroupSchema = z.enum(toolGroupsEnum) +type _AssertHistoryItem = AssertEqual>> + +/** + * GroupEntry + */ const groupEntrySchema = z.union([ - toolGroupSchema, + z.enum(toolGroupsEnum), z .tuple([ - toolGroupSchema, + z.enum(toolGroupsEnum), z.object({ fileRegex: z.string().optional(), description: z.string().optional(), @@ -385,6 +436,12 @@ const groupEntrySchema = z.union([ .readonly(), ]) +type _AssertGroupEntry = AssertEqual>> + +/** + * ModeConfig + */ + const modeConfigSchema = z.object({ slug: z.string(), name: z.string(), @@ -394,6 +451,12 @@ const modeConfigSchema = z.object({ source: z.enum(["global", "project"]).optional(), }) +type _AssertModeConfig = AssertEqual>> + +/** + * ExperimentId + */ + const experimentsSchema = z.object({ experimentalDiffStrategy: z.boolean(), search_and_replace: z.boolean(), @@ -402,6 +465,10 @@ const experimentsSchema = z.object({ multi_search_and_replace: z.boolean(), }) +/** + * GlobalSettings + */ + // Throws a type error if the inferred type of the experimentsSchema is not // equal to ExperimentId. type _AssertExperiments = AssertEqual>>> @@ -411,7 +478,7 @@ export const globalSettingsSchema = z.object({ listApiConfigMeta: z.array(apiConfigMetaSchema).optional(), lastShownAnnouncementId: z.string().optional(), customInstructions: z.string().optional(), - taskHistory: z.array(taskHistorySchema).optional(), + taskHistory: z.array(historyItemSchema).optional(), autoApprovalEnabled: z.boolean().optional(), alwaysAllowReadOnly: z.boolean().optional(), @@ -484,24 +551,9 @@ export const globalSettingsSchema = z.object({ // equal to GlobalSettings. type _AssertGlobalSettings = AssertEqual>> -export const modelInfoSchema = z.object({ - maxTokens: z.number().optional(), - contextWindow: z.number(), - supportsImages: z.boolean().optional(), - supportsComputerUse: z.boolean().optional(), - supportsPromptCache: z.boolean(), - inputPrice: z.number().optional(), - outputPrice: z.number().optional(), - cacheWritesPrice: z.number().optional(), - cacheReadsPrice: z.number().optional(), - description: z.string().optional(), - reasoningEffort: z.enum(["low", "medium", "high"]).optional(), - thinking: z.boolean().optional(), -}) - -// Throws a type error if the inferred type of the modelInfoSchema is not equal -// to ModelInfo. -type _AssertModelInfo = AssertEqual>> +/** + * ProviderSettings + */ export const providerSettingsSchema = z.object({ apiProvider: z.enum(providerNamesEnum).optional(), @@ -596,15 +648,3 @@ export const providerSettingsSchema = z.object({ // Throws a type error if the inferred type of the providerSettingsSchema is not // equal to ProviderSettings. type _AssertProviderSettings = AssertEqual>> - -export const rooCodeSettingsSchema = globalSettingsSchema.merge(providerSettingsSchema) - -/** - * Pass-through state keys. - * TODO: What are these? - */ - -export const PASS_THROUGH_STATE_KEYS = ["taskHistory"] as const - -export const isPassThroughStateKey = (key: string): key is (typeof PASS_THROUGH_STATE_KEYS)[number] => - PASS_THROUGH_STATE_KEYS.includes(key as (typeof PASS_THROUGH_STATE_KEYS)[number]) From 316bfce01b69b6259304bbbd2001adfced6f075c Mon Sep 17 00:00:00 2001 From: cte Date: Wed, 26 Mar 2025 16:26:13 -0700 Subject: [PATCH 10/10] Improve import / export, add translations --- src/core/config/ConfigManager.ts | 293 ------------- .../ContextProxy.ts} | 103 +---- src/core/config/ProviderSettingsManager.ts | 293 +++++++++++++ .../__tests__/ContextProxy.test.ts} | 92 +---- ...est.ts => ProviderSettingsManager.test.ts} | 146 +++---- .../config/__tests__/importExport.test.ts | 391 ++++++++++++++++++ src/core/config/importExport.ts | 69 ++++ src/core/webview/ClineProvider.ts | 117 +++--- .../webview/__tests__/ClineProvider.test.ts | 59 ++- src/i18n/locales/ca/common.json | 3 +- src/i18n/locales/de/common.json | 3 +- src/i18n/locales/en/common.json | 3 +- src/i18n/locales/es/common.json | 3 +- src/i18n/locales/fr/common.json | 3 +- src/i18n/locales/hi/common.json | 3 +- src/i18n/locales/it/common.json | 3 +- src/i18n/locales/ja/common.json | 3 +- src/i18n/locales/ko/common.json | 3 +- src/i18n/locales/pl/common.json | 3 +- src/i18n/locales/pt-BR/common.json | 3 +- src/i18n/locales/tr/common.json | 3 +- src/i18n/locales/vi/common.json | 3 +- src/i18n/locales/zh-CN/common.json | 3 +- src/i18n/locales/zh-TW/common.json | 3 +- .../src/components/chat/ChatTextArea.tsx | 18 +- webview-ui/src/components/settings/About.tsx | 48 +-- .../src/components/ui/select-dropdown.tsx | 5 +- webview-ui/src/i18n/locales/ca/settings.json | 7 +- webview-ui/src/i18n/locales/de/settings.json | 7 +- webview-ui/src/i18n/locales/en/settings.json | 7 +- webview-ui/src/i18n/locales/es/settings.json | 7 +- webview-ui/src/i18n/locales/fr/settings.json | 7 +- webview-ui/src/i18n/locales/hi/settings.json | 7 +- webview-ui/src/i18n/locales/it/settings.json | 7 +- webview-ui/src/i18n/locales/ja/settings.json | 7 +- webview-ui/src/i18n/locales/ko/settings.json | 7 +- webview-ui/src/i18n/locales/pl/settings.json | 7 +- .../src/i18n/locales/pt-BR/settings.json | 7 +- webview-ui/src/i18n/locales/tr/settings.json | 7 +- webview-ui/src/i18n/locales/vi/settings.json | 7 +- .../src/i18n/locales/zh-CN/settings.json | 7 +- .../src/i18n/locales/zh-TW/settings.json | 7 +- 42 files changed, 1020 insertions(+), 764 deletions(-) delete mode 100644 src/core/config/ConfigManager.ts rename src/core/{contextProxy.ts => config/ContextProxy.ts} (70%) create mode 100644 src/core/config/ProviderSettingsManager.ts rename src/core/{__tests__/contextProxy.test.ts => config/__tests__/ContextProxy.test.ts} (81%) rename src/core/config/__tests__/{ConfigManager.test.ts => ProviderSettingsManager.test.ts} (68%) create mode 100644 src/core/config/__tests__/importExport.test.ts create mode 100644 src/core/config/importExport.ts diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts deleted file mode 100644 index 3a110ad4bb..0000000000 --- a/src/core/config/ConfigManager.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { ExtensionContext } from "vscode" -import { Mode } from "../../shared/modes" -import { ApiConfigMeta } from "../../shared/ExtensionMessage" -import { ProviderSettings } from "../../exports/roo-code" - -type ProviderSettingsWithId = ProviderSettings & { id?: string } - -export interface ApiConfigData { - currentApiConfigName: string - apiConfigs: { [key: string]: ProviderSettingsWithId } - modeApiConfigs?: Partial> -} - -export class ConfigManager { - private readonly defaultConfig: ApiConfigData = { - currentApiConfigName: "default", - apiConfigs: { default: { id: this.generateId() } }, - } - - private readonly SCOPE_PREFIX = "roo_cline_config_" - private readonly context: ExtensionContext - - constructor(context: ExtensionContext) { - this.context = context - this.initConfig().catch(console.error) - } - - private generateId() { - return Math.random().toString(36).substring(2, 15) - } - - // Synchronize readConfig/writeConfig operations to avoid data loss. - private _lock = Promise.resolve() - private lock(cb: () => Promise) { - const next = this._lock.then(cb) - this._lock = next.catch(() => {}) as Promise - return next - } - - /** - * Initialize config if it doesn't exist. - */ - async initConfig() { - try { - return await this.lock(async () => { - const config = await this.readConfig() - - if (!config) { - await this.writeConfig(this.defaultConfig) - return - } - - // Migrate: ensure all configs have IDs. - let needsMigration = false - - for (const [name, apiConfig] of Object.entries(config.apiConfigs)) { - if (!apiConfig.id) { - apiConfig.id = this.generateId() - needsMigration = true - } - } - - if (needsMigration) { - await this.writeConfig(config) - } - }) - } catch (error) { - throw new Error(`Failed to initialize config: ${error}`) - } - } - - /** - * List all available configs with metadata. - */ - async listConfig(): Promise { - try { - return await this.lock(async () => { - const config = await this.readConfig() - - return Object.entries(config.apiConfigs).map(([name, apiConfig]) => ({ - name, - id: apiConfig.id || "", - apiProvider: apiConfig.apiProvider, - })) - }) - } catch (error) { - throw new Error(`Failed to list configs: ${error}`) - } - } - - /** - * Save a config with the given name. - * Preserves the ID from the input 'config' object if it exists, - * otherwise generates a new one (for creation scenarios). - */ - async saveConfig(name: string, config: ProviderSettingsWithId) { - try { - return await this.lock(async () => { - const currentConfig = await this.readConfig() - // Preserve the existing ID if this is an update to an existing config. - const existingId = currentConfig.apiConfigs[name]?.id - currentConfig.apiConfigs[name] = { ...config, id: config.id || existingId || this.generateId() } - await this.writeConfig(currentConfig) - }) - } catch (error) { - throw new Error(`Failed to save config: ${error}`) - } - } - - /** - * Load a config by name. - */ - async loadConfig(name: string) { - try { - return await this.lock(async () => { - const config = await this.readConfig() - const apiConfig = config.apiConfigs[name] - - if (!apiConfig) { - throw new Error(`Config '${name}' not found`) - } - - config.currentApiConfigName = name - await this.writeConfig(config) - - return apiConfig - }) - } catch (error) { - throw new Error(`Failed to load config: ${error}`) - } - } - - /** - * Load a config by ID. - */ - async loadConfigById(id: string) { - try { - return await this.lock(async () => { - const config = await this.readConfig() - - // Find the config with the matching ID - const entry = Object.entries(config.apiConfigs).find(([_, apiConfig]) => apiConfig.id === id) - - if (!entry) { - throw new Error(`Config with ID '${id}' not found`) - } - - const [name, apiConfig] = entry - - // Update current config name - config.currentApiConfigName = name - await this.writeConfig(config) - - return { config: apiConfig, name } - }) - } catch (error) { - throw new Error(`Failed to load config by ID: ${error}`) - } - } - - /** - * Delete a config by name. - */ - async deleteConfig(name: string) { - try { - return await this.lock(async () => { - const currentConfig = await this.readConfig() - - if (!currentConfig.apiConfigs[name]) { - throw new Error(`Config '${name}' not found`) - } - - // Don't allow deleting the default config - if (Object.keys(currentConfig.apiConfigs).length === 1) { - throw new Error(`Cannot delete the last remaining configuration.`) - } - - delete currentConfig.apiConfigs[name] - await this.writeConfig(currentConfig) - }) - } catch (error) { - throw new Error(`Failed to delete config: ${error}`) - } - } - - /** - * Set the current active API configuration. - */ - async setCurrentConfig(name: string) { - try { - return await this.lock(async () => { - const currentConfig = await this.readConfig() - - if (!currentConfig.apiConfigs[name]) { - throw new Error(`Config '${name}' not found`) - } - - currentConfig.currentApiConfigName = name - await this.writeConfig(currentConfig) - }) - } catch (error) { - throw new Error(`Failed to set current config: ${error}`) - } - } - - /** - * Check if a config exists by name. - */ - async hasConfig(name: string) { - try { - return await this.lock(async () => { - const config = await this.readConfig() - return name in config.apiConfigs - }) - } catch (error) { - throw new Error(`Failed to check config existence: ${error}`) - } - } - - /** - * Set the API config for a specific mode. - */ - async setModeConfig(mode: Mode, configId: string) { - try { - return await this.lock(async () => { - const currentConfig = await this.readConfig() - - if (!currentConfig.modeApiConfigs) { - currentConfig.modeApiConfigs = {} - } - - currentConfig.modeApiConfigs[mode] = configId - await this.writeConfig(currentConfig) - }) - } catch (error) { - throw new Error(`Failed to set mode config: ${error}`) - } - } - - /** - * Get the API config ID for a specific mode. - */ - async getModeConfigId(mode: Mode) { - try { - return await this.lock(async () => { - const config = await this.readConfig() - return config.modeApiConfigs?.[mode] - }) - } catch (error) { - throw new Error(`Failed to get mode config: ${error}`) - } - } - - /** - * Get the key used for storing config in secrets. - */ - private getConfigKey() { - return `${this.SCOPE_PREFIX}api_config` - } - - /** - * Reset all configuration by deleting the stored config from secrets. - */ - public async resetAllConfigs() { - return await this.lock(async () => { - await this.context.secrets.delete(this.getConfigKey()) - }) - } - - private async readConfig(): Promise { - try { - const content = await this.context.secrets.get(this.getConfigKey()) - - if (!content) { - return this.defaultConfig - } - - // TODO: Use a zod schema to validate the config. - return JSON.parse(content) - } catch (error) { - throw new Error(`Failed to read config from secrets: ${error}`) - } - } - - private async writeConfig(config: ApiConfigData) { - try { - const content = JSON.stringify(config, null, 2) - await this.context.secrets.store(this.getConfigKey(), content) - } catch (error) { - throw new Error(`Failed to write config to secrets: ${error}`) - } - } -} diff --git a/src/core/contextProxy.ts b/src/core/config/ContextProxy.ts similarity index 70% rename from src/core/contextProxy.ts rename to src/core/config/ContextProxy.ts index 2dc4156066..f4a8c56a8d 100644 --- a/src/core/contextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -1,8 +1,6 @@ import * as vscode from "vscode" -import * as fs from "fs/promises" -import * as path from "path" -import { logger } from "../utils/logging" +import { logger } from "../../utils/logging" import type { ProviderSettings, RooCodeSettings, @@ -12,7 +10,7 @@ import type { SecretStateKey, SecretState, GlobalSettings, -} from "../exports/roo-code" +} from "../../exports/roo-code" import { PROVIDER_SETTINGS_KEYS, GLOBAL_STATE_KEYS, @@ -21,7 +19,7 @@ import { isPassThroughStateKey, globalSettingsSchema, providerSettingsSchema, -} from "../shared/globalState" +} from "../../shared/globalState" const globalSettingsExportSchema = globalSettingsSchema.omit({ taskHistory: true, @@ -29,13 +27,6 @@ const globalSettingsExportSchema = globalSettingsSchema.omit({ currentApiConfigName: true, }) -const providerSettingsExportSchema = providerSettingsSchema.omit({ - glamaModelInfo: true, - openRouterModelInfo: true, - unboundModelInfo: true, - requestyModelInfo: true, -}) - export class ContextProxy { private readonly originalContext: vscode.ExtensionContext @@ -161,43 +152,6 @@ export class ContextProxy { return globalSettingsSchema.parse({ ...this.stateCache }) } - public async exportGlobalSettings(filePath: string): Promise { - try { - const globalSettings = globalSettingsExportSchema.parse(this.getValues()) - - const sanitized = Object.fromEntries( - Object.entries(globalSettings).filter(([_, value]) => value !== undefined), - ) - - const dirname = path.dirname(filePath) - await fs.mkdir(dirname, { recursive: true }) - await fs.writeFile(filePath, JSON.stringify(sanitized, null, 2), "utf-8") - return sanitized - } catch (error) { - console.log(error.message) - logger.error( - `Error exporting global configuration to ${filePath}: ${error instanceof Error ? error.message : String(error)}`, - ) - return undefined - } - } - - public async importGlobalSettings(filePath: string) { - try { - const globalConfiguration = globalSettingsExportSchema.parse( - JSON.parse(await fs.readFile(filePath, "utf-8")), - ) - - await this.setValues(globalConfiguration) - return globalConfiguration - } catch (error) { - logger.error( - `Error importing global configuration from ${filePath}: ${error instanceof Error ? error.message : String(error)}`, - ) - return undefined - } - } - /** * ProviderSettings */ @@ -220,42 +174,6 @@ export class ContextProxy { }) } - public async exportProviderSettings(filePath: string): Promise { - try { - const providerSettings = providerSettingsExportSchema.parse(this.getValues()) - - const sanitized = Object.fromEntries( - Object.entries(providerSettings).filter(([_, value]) => value !== undefined), - ) - - const dirname = path.dirname(filePath) - await fs.mkdir(dirname, { recursive: true }) - await fs.writeFile(filePath, JSON.stringify(sanitized, null, 2), "utf-8") - return sanitized - } catch (error) { - logger.error( - `Error exporting provider settings to ${filePath}: ${error instanceof Error ? error.message : String(error)}`, - ) - return undefined - } - } - - public async importProviderSettings(filePath: string): Promise { - try { - const providerSettings = providerSettingsExportSchema.parse( - JSON.parse(await fs.readFile(filePath, "utf-8")), - ) - - await this.setProviderSettings(providerSettings) - return providerSettings - } catch (error) { - logger.error( - `Error importing provider settings from ${filePath}: ${error instanceof Error ? error.message : String(error)}`, - ) - return undefined - } - } - /** * RooCodeSettings */ @@ -279,6 +197,21 @@ export class ContextProxy { await Promise.all(entries.map(([key, value]) => this.setValue(key, value))) } + /** + * Import / Export + */ + + public async export(): Promise { + try { + const globalSettings = globalSettingsExportSchema.parse(this.getValues()) + + return Object.fromEntries(Object.entries(globalSettings).filter(([_, value]) => value !== undefined)) + } catch (error) { + console.log(error.message) + return undefined + } + } + /** * Resets all global state, secrets, and in-memory caches. * This clears all data from both the in-memory caches and the VSCode storage. diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts new file mode 100644 index 0000000000..5a25f4dfdb --- /dev/null +++ b/src/core/config/ProviderSettingsManager.ts @@ -0,0 +1,293 @@ +import { ExtensionContext } from "vscode" +import { z } from "zod" + +import { providerSettingsSchema } from "../../shared/globalState" +import { Mode } from "../../shared/modes" +import { ApiConfigMeta } from "../../shared/ExtensionMessage" + +const providerSettingsWithIdSchema = providerSettingsSchema.extend({ id: z.string().optional() }) + +type ProviderSettingsWithId = z.infer + +export const providerProfilesSchema = z.object({ + currentApiConfigName: z.string(), + apiConfigs: z.record(z.string(), providerSettingsWithIdSchema), + modeApiConfigs: z.record(z.string(), z.string()).optional(), +}) + +export type ProviderProfiles = z.infer + +const providerProfilesExportSchema = providerProfilesSchema.extend({ + apiConfigs: z.record( + z.string(), + providerSettingsWithIdSchema.omit({ + glamaModelInfo: true, + openRouterModelInfo: true, + unboundModelInfo: true, + requestyModelInfo: true, + }), + ), +}) + +export class ProviderSettingsManager { + private static readonly SCOPE_PREFIX = "roo_cline_config_" + + private readonly defaultProviderProfiles: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { default: { id: this.generateId() } }, + } + + private readonly context: ExtensionContext + + constructor(context: ExtensionContext) { + this.context = context + + // TODO: We really shouldn't have async methods in the constructor. + this.initialize().catch(console.error) + } + + private generateId() { + return Math.random().toString(36).substring(2, 15) + } + + // Synchronize readConfig/writeConfig operations to avoid data loss. + private _lock = Promise.resolve() + private lock(cb: () => Promise) { + const next = this._lock.then(cb) + this._lock = next.catch(() => {}) as Promise + return next + } + + /** + * Initialize config if it doesn't exist. + */ + public async initialize() { + try { + return await this.lock(async () => { + const providerProfiles = await this.load() + + if (!providerProfiles) { + await this.store(this.defaultProviderProfiles) + return + } + + let isDirty = false + + // Ensure all configs have IDs. + for (const [name, apiConfig] of Object.entries(providerProfiles.apiConfigs)) { + if (!apiConfig.id) { + apiConfig.id = this.generateId() + isDirty = true + } + } + + if (isDirty) { + await this.store(providerProfiles) + } + }) + } catch (error) { + throw new Error(`Failed to initialize config: ${error}`) + } + } + + /** + * List all available configs with metadata. + */ + public async listConfig(): Promise { + try { + return await this.lock(async () => { + const providerProfiles = await this.load() + + return Object.entries(providerProfiles.apiConfigs).map(([name, apiConfig]) => ({ + name, + id: apiConfig.id || "", + apiProvider: apiConfig.apiProvider, + })) + }) + } catch (error) { + throw new Error(`Failed to list configs: ${error}`) + } + } + + /** + * Save a config with the given name. + * Preserves the ID from the input 'config' object if it exists, + * otherwise generates a new one (for creation scenarios). + */ + public async saveConfig(name: string, config: ProviderSettingsWithId) { + try { + return await this.lock(async () => { + const providerProfiles = await this.load() + // Preserve the existing ID if this is an update to an existing config. + const existingId = providerProfiles.apiConfigs[name]?.id + providerProfiles.apiConfigs[name] = { ...config, id: config.id || existingId || this.generateId() } + await this.store(providerProfiles) + }) + } catch (error) { + throw new Error(`Failed to save config: ${error}`) + } + } + + /** + * Load a config by name and set it as the current config. + */ + public async loadConfig(name: string) { + try { + return await this.lock(async () => { + const providerProfiles = await this.load() + const providerSettings = providerProfiles.apiConfigs[name] + + if (!providerSettings) { + throw new Error(`Config '${name}' not found`) + } + + providerProfiles.currentApiConfigName = name + await this.store(providerProfiles) + + return providerSettings + }) + } catch (error) { + throw new Error(`Failed to load config: ${error}`) + } + } + + /** + * Load a config by ID and set it as the current config. + */ + public async loadConfigById(id: string) { + try { + return await this.lock(async () => { + const providerProfiles = await this.load() + const providerSettings = Object.entries(providerProfiles.apiConfigs).find( + ([_, apiConfig]) => apiConfig.id === id, + ) + + if (!providerSettings) { + throw new Error(`Config with ID '${id}' not found`) + } + + const [name, apiConfig] = providerSettings + providerProfiles.currentApiConfigName = name + await this.store(providerProfiles) + + return { config: apiConfig, name } + }) + } catch (error) { + throw new Error(`Failed to load config by ID: ${error}`) + } + } + + /** + * Delete a config by name. + */ + public async deleteConfig(name: string) { + try { + return await this.lock(async () => { + const providerProfiles = await this.load() + + if (!providerProfiles.apiConfigs[name]) { + throw new Error(`Config '${name}' not found`) + } + + if (Object.keys(providerProfiles.apiConfigs).length === 1) { + throw new Error(`Cannot delete the last remaining configuration`) + } + + delete providerProfiles.apiConfigs[name] + await this.store(providerProfiles) + }) + } catch (error) { + throw new Error(`Failed to delete config: ${error}`) + } + } + + /** + * Check if a config exists by name. + */ + public async hasConfig(name: string) { + try { + return await this.lock(async () => { + const providerProfiles = await this.load() + return name in providerProfiles.apiConfigs + }) + } catch (error) { + throw new Error(`Failed to check config existence: ${error}`) + } + } + + /** + * Set the API config for a specific mode. + */ + public async setModeConfig(mode: Mode, configId: string) { + try { + return await this.lock(async () => { + const providerProfiles = await this.load() + const { modeApiConfigs = {} } = providerProfiles + modeApiConfigs[mode] = configId + await this.store(providerProfiles) + }) + } catch (error) { + throw new Error(`Failed to set mode config: ${error}`) + } + } + + /** + * Get the API config ID for a specific mode. + */ + public async getModeConfigId(mode: Mode) { + try { + return await this.lock(async () => { + const { modeApiConfigs } = await this.load() + return modeApiConfigs?.[mode] + }) + } catch (error) { + throw new Error(`Failed to get mode config: ${error}`) + } + } + + public async export() { + try { + return await this.lock(async () => providerProfilesExportSchema.parse(await this.load())) + } catch (error) { + throw new Error(`Failed to export provider profiles: ${error}`) + } + } + + public async import(providerProfiles: ProviderProfiles) { + try { + return await this.lock(() => this.store(providerProfiles)) + } catch (error) { + throw new Error(`Failed to import provider profiles: ${error}`) + } + } + + /** + * Reset provider profiles by deleting them from secrets. + */ + public async resetAllConfigs() { + return await this.lock(async () => { + await this.context.secrets.delete(this.secretsKey) + }) + } + + private get secretsKey() { + return `${ProviderSettingsManager.SCOPE_PREFIX}api_config` + } + + private async load(): Promise { + try { + const content = await this.context.secrets.get(this.secretsKey) + return content ? providerProfilesSchema.parse(JSON.parse(content)) : this.defaultProviderProfiles + } catch (error) { + throw new Error(`Failed to read provider profiles from secrets: ${error}`) + } + } + + private async store(providerProfiles: ProviderProfiles) { + try { + await this.context.secrets.store(this.secretsKey, JSON.stringify(providerProfiles, null, 2)) + } catch (error) { + throw new Error(`Failed to write provider profiles to secrets: ${error}`) + } + } +} diff --git a/src/core/__tests__/contextProxy.test.ts b/src/core/config/__tests__/ContextProxy.test.ts similarity index 81% rename from src/core/__tests__/contextProxy.test.ts rename to src/core/config/__tests__/ContextProxy.test.ts index 94274cbee2..85b72c8a32 100644 --- a/src/core/__tests__/contextProxy.test.ts +++ b/src/core/config/__tests__/ContextProxy.test.ts @@ -1,12 +1,12 @@ -// npx jest src/core/__tests__/contextProxy.test.ts +// npx jest src/core/config/__tests__/ContextProxy.test.ts import fs from "fs/promises" import * as vscode from "vscode" -import { ContextProxy } from "../contextProxy" +import { ContextProxy } from "../ContextProxy" -import { logger } from "../../utils/logging" -import { GLOBAL_STATE_KEYS, SECRET_STATE_KEYS } from "../../shared/globalState" +import { logger } from "../../../utils/logging" +import { GLOBAL_STATE_KEYS, SECRET_STATE_KEYS } from "../../../shared/globalState" jest.mock("vscode", () => ({ Uri: { @@ -421,88 +421,4 @@ describe("ContextProxy", () => { expect(initializeSpy).toHaveBeenCalledTimes(1) }) }) - - describe("exportGlobalSettings", () => { - it("should write global settings to a file when filePath is provided", async () => { - await proxy.setValues({ - apiModelId: "gpt-4", - apiProvider: "openai", - openAiApiKey: "test-api-key", - autoApprovalEnabled: true, - }) - - const filePath = `/tmp/roo-global-config-${Date.now()}.json` - const result = await proxy.exportGlobalSettings(filePath) - expect(result).toEqual({ autoApprovalEnabled: true }) - const fileContent = await fs.readFile(filePath, "utf-8") - expect(fileContent).toContain('"autoApprovalEnabled": true') - - await proxy.setValue("autoApprovalEnabled", false) - expect(proxy.getValue("autoApprovalEnabled")).toBe(false) - - const importedConfig = await proxy.importGlobalSettings(filePath) - expect(importedConfig).toEqual({ autoApprovalEnabled: true }) - expect(proxy.getValue("autoApprovalEnabled")).toBe(true) - - await fs.unlink(filePath) - }) - }) - - describe("exportProviderSettings", () => { - it("should write provider settings to a file when filePath is provided", async () => { - await proxy.setValues({ - apiModelId: "gpt-4", - apiProvider: "openai", - openAiApiKey: "test-api-key", - autoApprovalEnabled: true, - }) - - const filePath = `/tmp/roo-api-config-${Date.now()}.json` - const result = await proxy.exportProviderSettings(filePath) - expect(result).toEqual({ - apiModelId: "gpt-4", - apiProvider: "openai", - openAiApiKey: "test-api-key", - apiKey: "test-secret", - awsAccessKey: "test-secret", - awsSecretKey: "test-secret", - awsSessionToken: "test-secret", - deepSeekApiKey: "test-secret", - geminiApiKey: "test-secret", - glamaApiKey: "test-secret", - mistralApiKey: "test-secret", - openAiNativeApiKey: "test-secret", - openRouterApiKey: "test-secret", - requestyApiKey: "test-secret", - unboundApiKey: "test-secret", - }) - const fileContent = await fs.readFile(filePath, "utf-8") - expect(fileContent).toContain('"openAiApiKey": "test-api-key"') - - await proxy.setValue("openAiApiKey", "new-test-api-key") - expect(proxy.getValue("openAiApiKey")).toBe("new-test-api-key") - - const importedConfig = await proxy.importProviderSettings(filePath) - expect(importedConfig).toEqual({ - apiModelId: "gpt-4", - apiProvider: "openai", - openAiApiKey: "test-api-key", - apiKey: "test-secret", - awsAccessKey: "test-secret", - awsSecretKey: "test-secret", - awsSessionToken: "test-secret", - deepSeekApiKey: "test-secret", - geminiApiKey: "test-secret", - glamaApiKey: "test-secret", - mistralApiKey: "test-secret", - openAiNativeApiKey: "test-secret", - openRouterApiKey: "test-secret", - requestyApiKey: "test-secret", - unboundApiKey: "test-secret", - }) - expect(proxy.getValue("openAiApiKey")).toBe("test-api-key") - - await fs.unlink(filePath) - }) - }) }) diff --git a/src/core/config/__tests__/ConfigManager.test.ts b/src/core/config/__tests__/ProviderSettingsManager.test.ts similarity index 68% rename from src/core/config/__tests__/ConfigManager.test.ts rename to src/core/config/__tests__/ProviderSettingsManager.test.ts index 3d65021a8d..43cc789beb 100644 --- a/src/core/config/__tests__/ConfigManager.test.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.test.ts @@ -1,6 +1,9 @@ +// npx jest src/core/config/__tests__/ProviderSettingsManager.test.ts + import { ExtensionContext } from "vscode" -import { ConfigManager, ApiConfigData } from "../ConfigManager" -import { ApiConfiguration } from "../../../shared/api" + +import { ProviderSettings } from "../../../exports/roo-code" +import { ProviderSettingsManager, ProviderProfiles } from "../ProviderSettingsManager" // Mock VSCode ExtensionContext const mockSecrets = { @@ -13,20 +16,20 @@ const mockContext = { secrets: mockSecrets, } as unknown as ExtensionContext -describe("ConfigManager", () => { - let configManager: ConfigManager +describe("ProviderSettingsManager", () => { + let providerSettingsManager: ProviderSettingsManager beforeEach(() => { jest.clearAllMocks() - configManager = new ConfigManager(mockContext) + providerSettingsManager = new ProviderSettingsManager(mockContext) }) - describe("initConfig", () => { + describe("initialize", () => { it("should not write to storage when secrets.get returns null", async () => { // Mock readConfig to return null mockSecrets.get.mockResolvedValueOnce(null) - await configManager.initConfig() + await providerSettingsManager.initialize() // Should not write to storage because readConfig returns defaultConfig expect(mockSecrets.store).not.toHaveBeenCalled() @@ -45,7 +48,7 @@ describe("ConfigManager", () => { }), ) - await configManager.initConfig() + await providerSettingsManager.initialize() expect(mockSecrets.store).not.toHaveBeenCalled() }) @@ -66,7 +69,7 @@ describe("ConfigManager", () => { }), ) - await configManager.initConfig() + await providerSettingsManager.initialize() // Should have written the config with new IDs expect(mockSecrets.store).toHaveBeenCalled() @@ -78,15 +81,15 @@ describe("ConfigManager", () => { it("should throw error if secrets storage fails", async () => { mockSecrets.get.mockRejectedValue(new Error("Storage failed")) - await expect(configManager.initConfig()).rejects.toThrow( - "Failed to initialize config: Error: Failed to read config from secrets: Error: Storage failed", + await expect(providerSettingsManager.initialize()).rejects.toThrow( + "Failed to initialize config: Error: Failed to read provider profiles from secrets: Error: Storage failed", ) }) }) describe("ListConfig", () => { it("should list all available configs", async () => { - const existingConfig: ApiConfigData = { + const existingConfig: ProviderProfiles = { currentApiConfigName: "default", apiConfigs: { default: { @@ -106,7 +109,7 @@ describe("ConfigManager", () => { mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) - const configs = await configManager.listConfig() + const configs = await providerSettingsManager.listConfig() expect(configs).toEqual([ { name: "default", id: "default", apiProvider: undefined }, { name: "test", id: "test-id", apiProvider: "anthropic" }, @@ -114,7 +117,7 @@ describe("ConfigManager", () => { }) it("should handle empty config file", async () => { - const emptyConfig: ApiConfigData = { + const emptyConfig: ProviderProfiles = { currentApiConfigName: "default", apiConfigs: {}, modeApiConfigs: { @@ -126,15 +129,15 @@ describe("ConfigManager", () => { mockSecrets.get.mockResolvedValue(JSON.stringify(emptyConfig)) - const configs = await configManager.listConfig() + const configs = await providerSettingsManager.listConfig() expect(configs).toEqual([]) }) it("should throw error if reading from secrets fails", async () => { mockSecrets.get.mockRejectedValue(new Error("Read failed")) - await expect(configManager.listConfig()).rejects.toThrow( - "Failed to list configs: Error: Failed to read config from secrets: Error: Read failed", + await expect(providerSettingsManager.listConfig()).rejects.toThrow( + "Failed to list configs: Error: Failed to read provider profiles from secrets: Error: Read failed", ) }) }) @@ -155,12 +158,12 @@ describe("ConfigManager", () => { }), ) - const newConfig: ApiConfiguration = { + const newConfig: ProviderSettings = { apiProvider: "anthropic", apiKey: "test-key", } - await configManager.saveConfig("test", newConfig) + await providerSettingsManager.saveConfig("test", newConfig) // Get the actual stored config to check the generated ID const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1]) @@ -189,7 +192,7 @@ describe("ConfigManager", () => { }) it("should update existing config", async () => { - const existingConfig: ApiConfigData = { + const existingConfig: ProviderProfiles = { currentApiConfigName: "default", apiConfigs: { test: { @@ -202,12 +205,12 @@ describe("ConfigManager", () => { mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) - const updatedConfig: ApiConfiguration = { + const updatedConfig: ProviderSettings = { apiProvider: "anthropic", apiKey: "new-key", } - await configManager.saveConfig("test", updatedConfig) + await providerSettingsManager.saveConfig("test", updatedConfig) const expectedConfig = { currentApiConfigName: "default", @@ -235,15 +238,15 @@ describe("ConfigManager", () => { ) mockSecrets.store.mockRejectedValueOnce(new Error("Storage failed")) - await expect(configManager.saveConfig("test", {})).rejects.toThrow( - "Failed to save config: Error: Failed to write config to secrets: Error: Storage failed", + await expect(providerSettingsManager.saveConfig("test", {})).rejects.toThrow( + "Failed to save config: Error: Failed to write provider profiles to secrets: Error: Storage failed", ) }) }) describe("DeleteConfig", () => { it("should delete existing config", async () => { - const existingConfig: ApiConfigData = { + const existingConfig: ProviderProfiles = { currentApiConfigName: "default", apiConfigs: { default: { @@ -258,7 +261,7 @@ describe("ConfigManager", () => { mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) - await configManager.deleteConfig("test") + await providerSettingsManager.deleteConfig("test") // Get the stored config to check the ID const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1]) @@ -275,7 +278,9 @@ describe("ConfigManager", () => { }), ) - await expect(configManager.deleteConfig("nonexistent")).rejects.toThrow("Config 'nonexistent' not found") + await expect(providerSettingsManager.deleteConfig("nonexistent")).rejects.toThrow( + "Config 'nonexistent' not found", + ) }) it("should throw error when trying to delete last remaining config", async () => { @@ -290,15 +295,15 @@ describe("ConfigManager", () => { }), ) - await expect(configManager.deleteConfig("default")).rejects.toThrow( - "Cannot delete the last remaining configuration.", + await expect(providerSettingsManager.deleteConfig("default")).rejects.toThrow( + "Failed to delete config: Error: Cannot delete the last remaining configuration", ) }) }) describe("LoadConfig", () => { it("should load config and update current config name", async () => { - const existingConfig: ApiConfigData = { + const existingConfig: ProviderProfiles = { currentApiConfigName: "default", apiConfigs: { test: { @@ -311,7 +316,7 @@ describe("ConfigManager", () => { mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) - const config = await configManager.loadConfig("test") + const config = await providerSettingsManager.loadConfig("test") expect(config).toEqual({ apiProvider: "anthropic", @@ -342,7 +347,9 @@ describe("ConfigManager", () => { }), ) - await expect(configManager.loadConfig("nonexistent")).rejects.toThrow("Config 'nonexistent' not found") + await expect(providerSettingsManager.loadConfig("nonexistent")).rejects.toThrow( + "Config 'nonexistent' not found", + ) }) it("should throw error if secrets storage fails", async () => { @@ -361,67 +368,8 @@ describe("ConfigManager", () => { ) mockSecrets.store.mockRejectedValueOnce(new Error("Storage failed")) - await expect(configManager.loadConfig("test")).rejects.toThrow( - "Failed to load config: Error: Failed to write config to secrets: Error: Storage failed", - ) - }) - }) - - describe("SetCurrentConfig", () => { - it("should set current config", async () => { - const existingConfig: ApiConfigData = { - currentApiConfigName: "default", - apiConfigs: { - default: { - id: "default", - }, - test: { - apiProvider: "anthropic", - id: "test-id", - }, - }, - } - - mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) - - await configManager.setCurrentConfig("test") - - // Get the stored config to check the structure - const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1]) - expect(storedConfig.currentApiConfigName).toBe("test") - expect(storedConfig.apiConfigs.default.id).toBe("default") - expect(storedConfig.apiConfigs.test).toEqual({ - apiProvider: "anthropic", - id: "test-id", - }) - }) - - it("should throw error when config does not exist", async () => { - mockSecrets.get.mockResolvedValue( - JSON.stringify({ - currentApiConfigName: "default", - apiConfigs: { default: {} }, - }), - ) - - await expect(configManager.setCurrentConfig("nonexistent")).rejects.toThrow( - "Config 'nonexistent' not found", - ) - }) - - it("should throw error if secrets storage fails", async () => { - mockSecrets.get.mockResolvedValue( - JSON.stringify({ - currentApiConfigName: "default", - apiConfigs: { - test: { apiProvider: "anthropic" }, - }, - }), - ) - mockSecrets.store.mockRejectedValueOnce(new Error("Storage failed")) - - await expect(configManager.setCurrentConfig("test")).rejects.toThrow( - "Failed to set current config: Error: Failed to write config to secrets: Error: Storage failed", + await expect(providerSettingsManager.loadConfig("test")).rejects.toThrow( + "Failed to load config: Error: Failed to write provider profiles to secrets: Error: Storage failed", ) }) }) @@ -441,7 +389,7 @@ describe("ConfigManager", () => { }), ) - await configManager.resetAllConfigs() + await providerSettingsManager.resetAllConfigs() // Should have called delete with the correct config key expect(mockSecrets.delete).toHaveBeenCalledWith("roo_cline_config_api_config") @@ -450,7 +398,7 @@ describe("ConfigManager", () => { describe("HasConfig", () => { it("should return true for existing config", async () => { - const existingConfig: ApiConfigData = { + const existingConfig: ProviderProfiles = { currentApiConfigName: "default", apiConfigs: { default: { @@ -465,7 +413,7 @@ describe("ConfigManager", () => { mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) - const hasConfig = await configManager.hasConfig("test") + const hasConfig = await providerSettingsManager.hasConfig("test") expect(hasConfig).toBe(true) }) @@ -477,15 +425,15 @@ describe("ConfigManager", () => { }), ) - const hasConfig = await configManager.hasConfig("nonexistent") + const hasConfig = await providerSettingsManager.hasConfig("nonexistent") expect(hasConfig).toBe(false) }) it("should throw error if secrets storage fails", async () => { mockSecrets.get.mockRejectedValue(new Error("Storage failed")) - await expect(configManager.hasConfig("test")).rejects.toThrow( - "Failed to check config existence: Error: Failed to read config from secrets: Error: Storage failed", + await expect(providerSettingsManager.hasConfig("test")).rejects.toThrow( + "Failed to check config existence: Error: Failed to read provider profiles from secrets: Error: Storage failed", ) }) }) diff --git a/src/core/config/__tests__/importExport.test.ts b/src/core/config/__tests__/importExport.test.ts new file mode 100644 index 0000000000..f5d370605b --- /dev/null +++ b/src/core/config/__tests__/importExport.test.ts @@ -0,0 +1,391 @@ +// npx jest src/core/config/__tests__/importExport.test.ts + +import fs from "fs/promises" +import * as path from "path" +import os from "os" + +import * as vscode from "vscode" + +import { importSettings, exportSettings } from "../importExport" +import { ProviderSettingsManager } from "../ProviderSettingsManager" +import { ContextProxy } from "../ContextProxy" +import { ProviderName } from "../../../exports/roo-code" + +// Mock VSCode modules +jest.mock("vscode", () => ({ + window: { + showOpenDialog: jest.fn(), + showSaveDialog: jest.fn(), + }, + Uri: { + file: jest.fn((filePath) => ({ fsPath: filePath })), + }, +})) + +// Mock fs/promises +jest.mock("fs/promises", () => ({ + readFile: jest.fn(), + mkdir: jest.fn(), + writeFile: jest.fn(), +})) + +// Mock os module +jest.mock("os", () => ({ + homedir: jest.fn(() => "/mock/home"), +})) + +describe("importExport", () => { + let mockProviderSettingsManager: jest.Mocked + let mockContextProxy: jest.Mocked + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks() + + // Setup providerSettingsManager mock + mockProviderSettingsManager = { + export: jest.fn(), + import: jest.fn(), + listConfig: jest.fn(), + } as unknown as jest.Mocked + + // Setup contextProxy mock with properly typed export method + mockContextProxy = { + setValues: jest.fn(), + setValue: jest.fn(), + export: jest.fn().mockImplementation(() => Promise.resolve({})), + } as unknown as jest.Mocked + }) + + describe("importSettings", () => { + it("should return success: false when user cancels file selection", async () => { + // Mock user canceling file selection + ;(vscode.window.showOpenDialog as jest.Mock).mockResolvedValue(undefined) + + const result = await importSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + }) + + expect(result).toEqual({ success: false }) + expect(vscode.window.showOpenDialog).toHaveBeenCalledWith({ + filters: { JSON: ["json"] }, + canSelectMany: false, + }) + expect(fs.readFile).not.toHaveBeenCalled() + expect(mockProviderSettingsManager.import).not.toHaveBeenCalled() + expect(mockContextProxy.setValues).not.toHaveBeenCalled() + }) + + it("should import settings successfully from a valid file", async () => { + // Mock successful file selection + ;(vscode.window.showOpenDialog as jest.Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) + + // Valid settings content + const mockFileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigName: "test", + apiConfigs: { + test: { + apiProvider: "openai" as ProviderName, + apiKey: "test-key", + id: "test-id", + }, + }, + }, + globalSettings: { + mode: "code", + autoApprovalEnabled: true, + }, + }) + + // Mock reading file + ;(fs.readFile as jest.Mock).mockResolvedValue(mockFileContent) + + // Mock export returning previous provider profiles + const previousProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + default: { + apiProvider: "anthropic" as ProviderName, + id: "default-id", + }, + }, + } + mockProviderSettingsManager.export.mockResolvedValue(previousProviderProfiles) + + // Mock listConfig + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "test", id: "test-id", apiProvider: "openai" as ProviderName }, + { name: "default", id: "default-id", apiProvider: "anthropic" as ProviderName }, + ]) + + // Mock contextProxy.export + mockContextProxy.export.mockResolvedValue({ + mode: "code", + }) + + const result = await importSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + }) + + expect(result.success).toBe(true) + expect(fs.readFile).toHaveBeenCalledWith("/mock/path/settings.json", "utf-8") + expect(mockProviderSettingsManager.export).toHaveBeenCalled() + expect(mockProviderSettingsManager.import).toHaveBeenCalledWith({ + ...previousProviderProfiles, + currentApiConfigName: "test", + apiConfigs: { + test: { + apiProvider: "openai" as ProviderName, + apiKey: "test-key", + id: "test-id", + }, + }, + }) + expect(mockContextProxy.setValues).toHaveBeenCalledWith({ + mode: "code", + autoApprovalEnabled: true, + }) + expect(mockContextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "test") + expect(mockContextProxy.setValue).toHaveBeenCalledWith("listApiConfigMeta", [ + { name: "test", id: "test-id", apiProvider: "openai" as ProviderName }, + { name: "default", id: "default-id", apiProvider: "anthropic" as ProviderName }, + ]) + }) + + it("should return success: false when file content is invalid", async () => { + // Mock successful file selection + ;(vscode.window.showOpenDialog as jest.Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) + + // Invalid content (missing required fields) + const mockInvalidContent = JSON.stringify({ + providerProfiles: { + apiConfigs: {}, + }, + globalSettings: {}, + }) + + // Mock reading file + ;(fs.readFile as jest.Mock).mockResolvedValue(mockInvalidContent) + + const result = await importSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + }) + + expect(result).toEqual({ success: false }) + expect(fs.readFile).toHaveBeenCalledWith("/mock/path/settings.json", "utf-8") + expect(mockProviderSettingsManager.import).not.toHaveBeenCalled() + expect(mockContextProxy.setValues).not.toHaveBeenCalled() + }) + + it("should return success: false when file content is not valid JSON", async () => { + // Mock successful file selection + ;(vscode.window.showOpenDialog as jest.Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) + + // Invalid JSON + const mockInvalidJson = "{ this is not valid JSON }" + + // Mock reading file + ;(fs.readFile as jest.Mock).mockResolvedValue(mockInvalidJson) + + const result = await importSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + }) + + expect(result).toEqual({ success: false }) + expect(fs.readFile).toHaveBeenCalledWith("/mock/path/settings.json", "utf-8") + expect(mockProviderSettingsManager.import).not.toHaveBeenCalled() + expect(mockContextProxy.setValues).not.toHaveBeenCalled() + }) + + it("should return success: false when reading file fails", async () => { + // Mock successful file selection + ;(vscode.window.showOpenDialog as jest.Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) + + // Mock file read error + ;(fs.readFile as jest.Mock).mockRejectedValue(new Error("File read error")) + + const result = await importSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + }) + + expect(result).toEqual({ success: false }) + expect(fs.readFile).toHaveBeenCalledWith("/mock/path/settings.json", "utf-8") + expect(mockProviderSettingsManager.import).not.toHaveBeenCalled() + expect(mockContextProxy.setValues).not.toHaveBeenCalled() + }) + }) + + describe("exportSettings", () => { + it("should not export settings when user cancels file selection", async () => { + // Mock user canceling file selection + ;(vscode.window.showSaveDialog as jest.Mock).mockResolvedValue(undefined) + + await exportSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + }) + + expect(vscode.window.showSaveDialog).toHaveBeenCalledWith({ + filters: { JSON: ["json"] }, + defaultUri: expect.anything(), + }) + expect(mockProviderSettingsManager.export).not.toHaveBeenCalled() + expect(mockContextProxy.export).not.toHaveBeenCalled() + expect(fs.writeFile).not.toHaveBeenCalled() + }) + + it("should export settings to the selected file location", async () => { + // Mock successful file location selection + ;(vscode.window.showSaveDialog as jest.Mock).mockResolvedValue({ + fsPath: "/mock/path/roo-code-settings.json", + }) + + // Mock providerProfiles data + const mockProviderProfiles = { + currentApiConfigName: "test", + apiConfigs: { + test: { + apiProvider: "openai" as ProviderName, + id: "test-id", + }, + }, + } + mockProviderSettingsManager.export.mockResolvedValue(mockProviderProfiles) + + // Mock globalSettings data + const mockGlobalSettings = { + mode: "code", + autoApprovalEnabled: true, + } + mockContextProxy.export.mockResolvedValue(mockGlobalSettings) + + await exportSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + }) + + expect(vscode.window.showSaveDialog).toHaveBeenCalledWith({ + filters: { JSON: ["json"] }, + defaultUri: expect.anything(), + }) + expect(mockProviderSettingsManager.export).toHaveBeenCalled() + expect(mockContextProxy.export).toHaveBeenCalled() + expect(fs.mkdir).toHaveBeenCalledWith("/mock/path", { recursive: true }) + expect(fs.writeFile).toHaveBeenCalledWith( + "/mock/path/roo-code-settings.json", + JSON.stringify( + { + providerProfiles: mockProviderProfiles, + globalSettings: mockGlobalSettings, + }, + null, + 2, + ), + "utf-8", + ) + }) + + it("should handle errors during the export process", async () => { + // Mock successful file location selection + ;(vscode.window.showSaveDialog as jest.Mock).mockResolvedValue({ + fsPath: "/mock/path/roo-code-settings.json", + }) + + // Mock provider profiles + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "test", + apiConfigs: { + test: { + apiProvider: "openai" as ProviderName, + id: "test-id", + }, + }, + }) + + // Mock global settings + mockContextProxy.export.mockResolvedValue({ + mode: "code", + }) + + // Mock file write error + ;(fs.writeFile as jest.Mock).mockRejectedValue(new Error("Write error")) + + // The function catches errors internally and doesn't throw or return anything + await exportSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + }) + + expect(vscode.window.showSaveDialog).toHaveBeenCalled() + expect(mockProviderSettingsManager.export).toHaveBeenCalled() + expect(mockContextProxy.export).toHaveBeenCalled() + expect(fs.mkdir).toHaveBeenCalledWith("/mock/path", { recursive: true }) + expect(fs.writeFile).toHaveBeenCalled() + // The error is caught and the function exits silently + }) + + it("should handle errors during directory creation", async () => { + // Mock successful file location selection + ;(vscode.window.showSaveDialog as jest.Mock).mockResolvedValue({ + fsPath: "/mock/path/roo-code-settings.json", + }) + + // Mock provider profiles + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "test", + apiConfigs: { + test: { + apiProvider: "openai" as ProviderName, + id: "test-id", + }, + }, + }) + + // Mock global settings + mockContextProxy.export.mockResolvedValue({ + mode: "code", + }) + + // Mock directory creation error + ;(fs.mkdir as jest.Mock).mockRejectedValue(new Error("Directory creation error")) + + // The function catches errors internally and doesn't throw or return anything + await exportSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + }) + + expect(vscode.window.showSaveDialog).toHaveBeenCalled() + expect(mockProviderSettingsManager.export).toHaveBeenCalled() + expect(mockContextProxy.export).toHaveBeenCalled() + expect(fs.mkdir).toHaveBeenCalled() + expect(fs.writeFile).not.toHaveBeenCalled() // Should not be called since mkdir failed + }) + + it("should use the correct default save location", async () => { + // Mock user cancels to avoid full execution + ;(vscode.window.showSaveDialog as jest.Mock).mockResolvedValue(undefined) + + // Call the function + await exportSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + }) + + // Verify the default save location + expect(vscode.window.showSaveDialog).toHaveBeenCalledWith({ + filters: { JSON: ["json"] }, + defaultUri: expect.anything(), + }) + + // Verify Uri.file was called with the correct path + expect(vscode.Uri.file).toHaveBeenCalledWith(path.join("/mock/home", "Documents", "roo-code-settings.json")) + }) + }) +}) diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts new file mode 100644 index 0000000000..35782b5fed --- /dev/null +++ b/src/core/config/importExport.ts @@ -0,0 +1,69 @@ +import os from "os" +import * as path from "path" +import fs from "fs/promises" + +import * as vscode from "vscode" +import { z } from "zod" + +import { globalSettingsSchema } from "../../shared/globalState" +import { ProviderSettingsManager, providerProfilesSchema } from "./ProviderSettingsManager" +import { ContextProxy } from "./ContextProxy" + +type ImportExportOptions = { + providerSettingsManager: ProviderSettingsManager + contextProxy: ContextProxy +} + +export const importSettings = async ({ providerSettingsManager, contextProxy }: ImportExportOptions) => { + const uris = await vscode.window.showOpenDialog({ + filters: { JSON: ["json"] }, + canSelectMany: false, + }) + + if (!uris) { + return { success: false } + } + + const schema = z.object({ + providerProfiles: providerProfilesSchema, + globalSettings: globalSettingsSchema, + }) + + try { + const { providerProfiles, globalSettings } = schema.parse( + JSON.parse(await fs.readFile(uris[0].fsPath, "utf-8")), + ) + + const previousProviderProfiles = await providerSettingsManager.export() + + await contextProxy.setValues(globalSettings) + await providerSettingsManager.import({ ...previousProviderProfiles, ...providerProfiles }) + + contextProxy.setValue("currentApiConfigName", providerProfiles.currentApiConfigName) + contextProxy.setValue("listApiConfigMeta", await providerSettingsManager.listConfig()) + + return { providerProfiles, globalSettings, success: true } + } catch (e) { + return { success: false } + } +} + +export const exportSettings = async ({ providerSettingsManager, contextProxy }: ImportExportOptions) => { + const uri = await vscode.window.showSaveDialog({ + filters: { JSON: ["json"] }, + defaultUri: vscode.Uri.file(path.join(os.homedir(), "Documents", "roo-code-settings.json")), + }) + + if (!uri) { + return + } + + try { + const providerProfiles = await providerSettingsManager.export() + const globalSettings = await contextProxy.export() + + const dirname = path.dirname(uri.fsPath) + await fs.mkdir(dirname, { recursive: true }) + await fs.writeFile(uri.fsPath, JSON.stringify({ providerProfiles, globalSettings }, null, 2), "utf-8") + } catch (e) {} +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index ad144e09ee..87ba91196f 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1,11 +1,12 @@ +import os from "os" +import * as path from "path" +import fs from "fs/promises" +import EventEmitter from "events" + import { Anthropic } from "@anthropic-ai/sdk" import delay from "delay" import axios from "axios" -import EventEmitter from "events" -import fs from "fs/promises" -import os from "os" import pWaitFor from "p-wait-for" -import * as path from "path" import * as vscode from "vscode" import { @@ -36,7 +37,7 @@ import { GlobalFileNames } from "../../shared/globalFileNames" import { HistoryItem } from "../../shared/HistoryItem" import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage" import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage" -import { Mode, PromptComponent, defaultModeSlug, ModeConfig, getModeBySlug, getGroupName } from "../../shared/modes" +import { Mode, PromptComponent, defaultModeSlug, getModeBySlug, getGroupName } from "../../shared/modes" import { checkExistKey } from "../../shared/checkExistApiConfig" import { EXPERIMENT_IDS, experiments as Experiments, experimentDefault, ExperimentId } from "../../shared/experiments" import { formatLanguage } from "../../shared/language" @@ -59,9 +60,10 @@ import { singleCompletionHandler } from "../../utils/single-completion-handler" import { searchCommits } from "../../utils/git" import { getDiffStrategy } from "../diff/DiffStrategy" import { SYSTEM_PROMPT } from "../prompts/system" -import { ConfigManager } from "../config/ConfigManager" +import { ContextProxy } from "../config/ContextProxy" +import { ProviderSettingsManager } from "../config/ProviderSettingsManager" +import { exportSettings, importSettings } from "../config/importExport" 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" @@ -102,8 +104,8 @@ export class ClineProvider extends EventEmitter implements private latestAnnouncementId = "mar-20-2025-3-10" // update to some unique identifier when we add a new announcement private settingsImportedAt?: number private contextProxy: ContextProxy - configManager: ConfigManager - customModesManager: CustomModesManager + public readonly providerSettingsManager: ProviderSettingsManager + public readonly customModesManager: CustomModesManager constructor( readonly context: vscode.ExtensionContext, @@ -116,11 +118,14 @@ export class ClineProvider extends EventEmitter implements this.contextProxy = new ContextProxy(context) ClineProvider.activeInstances.add(this) - // Register this provider with the telemetry service to enable it to add properties like mode and provider + // Register this provider with the telemetry service to enable it to add + // properties like mode and provider. telemetryService.setProvider(this) this.workspaceTracker = new WorkspaceTracker(this) - this.configManager = new ConfigManager(this.context) + + this.providerSettingsManager = new ProviderSettingsManager(this.context) + this.customModesManager = new CustomModesManager(this.context, async () => { await this.postStateToWebview() }) @@ -886,7 +891,7 @@ export class ClineProvider extends EventEmitter implements } }) - this.configManager + this.providerSettingsManager .listConfig() .then(async (listApiConfig) => { if (!listApiConfig) { @@ -898,7 +903,7 @@ export class ClineProvider extends EventEmitter implements if (!checkExistKey(listApiConfig[0])) { const { apiConfiguration } = await this.getState() - await this.configManager.saveConfig( + await this.providerSettingsManager.saveConfig( listApiConfig[0].name ?? "default", apiConfiguration, ) @@ -910,11 +915,11 @@ export class ClineProvider extends EventEmitter implements const currentConfigName = this.getGlobalState("currentApiConfigName") if (currentConfigName) { - if (!(await this.configManager.hasConfig(currentConfigName))) { + if (!(await this.providerSettingsManager.hasConfig(currentConfigName))) { // current config name not valid, get first config in list await this.updateGlobalState("currentApiConfigName", listApiConfig?.[0]?.name) if (listApiConfig?.[0]?.name) { - const apiConfig = await this.configManager.loadConfig( + const apiConfig = await this.providerSettingsManager.loadConfig( listApiConfig?.[0]?.name, ) @@ -1039,6 +1044,7 @@ export class ClineProvider extends EventEmitter implements break case "deleteMultipleTasksWithIds": { const ids = message.ids + if (Array.isArray(ids)) { // Process in batches of 20 (or another reasonable number) const batchSize = 20 @@ -1084,39 +1090,24 @@ export class ClineProvider extends EventEmitter implements this.exportTaskWithId(message.text!) break case "importSettings": - const uris = await vscode.window.showOpenDialog({ - filters: { JSON: ["json"] }, - canSelectMany: false, + const { success } = await importSettings({ + providerSettingsManager: this.providerSettingsManager, + contextProxy: this.contextProxy, }) - if (uris) { - if (message.text === "global") { - await this.contextProxy.importGlobalSettings(uris[0].fsPath) - } else { - await this.contextProxy.importGlobalSettings(uris[0].fsPath) - } - + if (success) { this.settingsImportedAt = Date.now() await this.postStateToWebview() await vscode.window.showInformationMessage(t("common:info.settings_imported")) } + break case "exportSettings": - const uri = await vscode.window.showSaveDialog({ - filters: { JSON: ["json"] }, - defaultUri: vscode.Uri.file( - path.join(os.homedir(), "Documents", `roo-code-${message.text}.json`), - ), + await exportSettings({ + providerSettingsManager: this.providerSettingsManager, + contextProxy: this.contextProxy, }) - if (uri) { - if (message.text === "global") { - await this.contextProxy.exportGlobalSettings(uri.fsPath) - } else { - await this.contextProxy.exportProviderSettings(uri.fsPath) - } - } - break case "resetState": await this.resetState() @@ -1725,7 +1716,7 @@ export class ClineProvider extends EventEmitter implements (c: ApiConfigMeta) => c.id === enhancementApiConfigId, ) if (config?.name) { - const loadedConfig = await this.configManager.loadConfig(config.name) + const loadedConfig = await this.providerSettingsManager.loadConfig(config.name) if (loadedConfig.apiProvider) { configToUse = loadedConfig } @@ -1848,8 +1839,8 @@ export class ClineProvider extends EventEmitter implements case "saveApiConfiguration": if (message.text && message.apiConfiguration) { try { - await this.configManager.saveConfig(message.text, message.apiConfiguration) - const listApiConfig = await this.configManager.listConfig() + await this.providerSettingsManager.saveConfig(message.text, message.apiConfiguration) + const listApiConfig = await this.providerSettingsManager.listConfig() await this.updateGlobalState("listApiConfigMeta", listApiConfig) } catch (error) { this.outputChannel.appendLine( @@ -1874,7 +1865,7 @@ export class ClineProvider extends EventEmitter implements } // Load the old configuration to get its ID - const oldConfig = await this.configManager.loadConfig(oldName) + const oldConfig = await this.providerSettingsManager.loadConfig(oldName) // Create a new configuration with the same ID const newConfig = { @@ -1883,10 +1874,10 @@ export class ClineProvider extends EventEmitter implements } // Save with the new name but same ID - await this.configManager.saveConfig(newName, newConfig) - await this.configManager.deleteConfig(oldName) + await this.providerSettingsManager.saveConfig(newName, newConfig) + await this.providerSettingsManager.deleteConfig(oldName) - const listApiConfig = await this.configManager.listConfig() + const listApiConfig = await this.providerSettingsManager.listConfig() // Update listApiConfigMeta first to ensure UI has latest data await this.updateGlobalState("listApiConfigMeta", listApiConfig) @@ -1904,8 +1895,8 @@ export class ClineProvider extends EventEmitter implements case "loadApiConfiguration": if (message.text) { try { - const apiConfig = await this.configManager.loadConfig(message.text) - const listApiConfig = await this.configManager.listConfig() + const apiConfig = await this.providerSettingsManager.loadConfig(message.text) + const listApiConfig = await this.providerSettingsManager.listConfig() await Promise.all([ this.updateGlobalState("listApiConfigMeta", listApiConfig), @@ -1925,10 +1916,10 @@ export class ClineProvider extends EventEmitter implements case "loadApiConfigurationById": if (message.text) { try { - const { config: apiConfig, name } = await this.configManager.loadConfigById( + const { config: apiConfig, name } = await this.providerSettingsManager.loadConfigById( message.text, ) - const listApiConfig = await this.configManager.listConfig() + const listApiConfig = await this.providerSettingsManager.listConfig() await Promise.all([ this.updateGlobalState("listApiConfigMeta", listApiConfig), @@ -1958,8 +1949,8 @@ export class ClineProvider extends EventEmitter implements } try { - await this.configManager.deleteConfig(message.text) - const listApiConfig = await this.configManager.listConfig() + await this.providerSettingsManager.deleteConfig(message.text) + const listApiConfig = await this.providerSettingsManager.listConfig() // Update listApiConfigMeta first to ensure UI has latest data await this.updateGlobalState("listApiConfigMeta", listApiConfig) @@ -1968,7 +1959,9 @@ export class ClineProvider extends EventEmitter implements const currentApiConfigName = this.getGlobalState("currentApiConfigName") if (message.text === currentApiConfigName && listApiConfig?.[0]?.name) { - const apiConfig = await this.configManager.loadConfig(listApiConfig[0].name) + const apiConfig = await this.providerSettingsManager.loadConfig( + listApiConfig[0].name, + ) await Promise.all([ this.updateGlobalState("currentApiConfigName", listApiConfig[0].name), this.updateApiConfiguration(apiConfig), @@ -1986,7 +1979,7 @@ export class ClineProvider extends EventEmitter implements break case "getListApiConfiguration": try { - const listApiConfig = await this.configManager.listConfig() + const listApiConfig = await this.providerSettingsManager.listConfig() await this.updateGlobalState("listApiConfigMeta", listApiConfig) this.postMessageToWebview({ type: "listApiConfig", listApiConfig }) } catch (error) { @@ -2176,8 +2169,8 @@ export class ClineProvider extends EventEmitter implements await this.updateGlobalState("mode", newMode) // Load the saved API config for the new mode if it exists - const savedConfigId = await this.configManager.getModeConfigId(newMode) - const listApiConfig = await this.configManager.listConfig() + const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode) + const listApiConfig = await this.providerSettingsManager.listConfig() // Update listApiConfigMeta first to ensure UI has latest data await this.updateGlobalState("listApiConfigMeta", listApiConfig) @@ -2186,7 +2179,7 @@ export class ClineProvider extends EventEmitter implements if (savedConfigId) { const config = listApiConfig?.find((c) => c.id === savedConfigId) if (config?.name) { - const apiConfig = await this.configManager.loadConfig(config.name) + const apiConfig = await this.providerSettingsManager.loadConfig(config.name) await Promise.all([ this.updateGlobalState("currentApiConfigName", config.name), this.updateApiConfiguration(apiConfig), @@ -2199,7 +2192,7 @@ export class ClineProvider extends EventEmitter implements if (currentApiConfigName) { const config = listApiConfig?.find((c) => c.name === currentApiConfigName) if (config?.id) { - await this.configManager.setModeConfig(newMode, config.id) + await this.providerSettingsManager.setModeConfig(newMode, config.id) } } } @@ -2213,11 +2206,11 @@ export class ClineProvider extends EventEmitter implements if (mode) { const currentApiConfigName = this.getGlobalState("currentApiConfigName") - const listApiConfig = await this.configManager.listConfig() + const listApiConfig = await this.providerSettingsManager.listConfig() const config = listApiConfig?.find((c) => c.name === currentApiConfigName) if (config?.id) { - await this.configManager.setModeConfig(mode, config.id) + await this.providerSettingsManager.setModeConfig(mode, config.id) } } @@ -2416,8 +2409,8 @@ export class ClineProvider extends EventEmitter implements async upsertApiConfiguration(configName: string, apiConfiguration: ApiConfiguration) { try { - await this.configManager.saveConfig(configName, apiConfiguration) - const listApiConfig = await this.configManager.listConfig() + await this.providerSettingsManager.saveConfig(configName, apiConfiguration) + const listApiConfig = await this.providerSettingsManager.listConfig() await Promise.all([ this.updateGlobalState("listApiConfigMeta", listApiConfig), @@ -2817,7 +2810,7 @@ export class ClineProvider extends EventEmitter implements } await this.contextProxy.resetAllState() - await this.configManager.resetAllConfigs() + await this.providerSettingsManager.resetAllConfigs() await this.customModesManager.resetCustomModes() await this.removeClineFromStack() await this.postStateToWebview() diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index dff1b64f34..54da8296eb 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -14,7 +14,7 @@ import { experimentDefault } from "../../../shared/experiments" jest.mock("../../prompts/sections/custom-instructions") // Mock ContextProxy -jest.mock("../../contextProxy", () => { +jest.mock("../../config/ContextProxy", () => { return { ContextProxy: jest.fn().mockImplementation((context) => ({ originalContext: context, @@ -647,8 +647,7 @@ describe("ClineProvider", () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] - // Mock ConfigManager methods - provider.configManager = { + ;(provider as any).providerSettingsManager = { getModeConfigId: jest.fn().mockResolvedValue("test-id"), listConfig: jest.fn().mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), loadConfig: jest.fn().mockResolvedValue({ apiProvider: "anthropic" }), @@ -659,8 +658,8 @@ describe("ClineProvider", () => { await messageHandler({ type: "mode", text: "architect" }) // Should load the saved config for architect mode - expect(provider.configManager.getModeConfigId).toHaveBeenCalledWith("architect") - expect(provider.configManager.loadConfig).toHaveBeenCalledWith("test-config") + expect(provider.providerSettingsManager.getModeConfigId).toHaveBeenCalledWith("architect") + expect(provider.providerSettingsManager.loadConfig).toHaveBeenCalledWith("test-config") expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "test-config") }) @@ -668,8 +667,7 @@ describe("ClineProvider", () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] - // Mock ConfigManager methods - provider.configManager = { + ;(provider as any).providerSettingsManager = { getModeConfigId: jest.fn().mockResolvedValue(undefined), listConfig: jest .fn() @@ -689,14 +687,14 @@ describe("ClineProvider", () => { await messageHandler({ type: "mode", text: "architect" }) // Should save current config as default for architect mode - expect(provider.configManager.setModeConfig).toHaveBeenCalledWith("architect", "current-id") + expect(provider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("architect", "current-id") }) test("saves config as default for current mode when loading config", async () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] - provider.configManager = { + ;(provider as any).providerSettingsManager = { loadConfig: jest.fn().mockResolvedValue({ apiProvider: "anthropic", id: "new-id" }), loadConfigById: jest .fn() @@ -713,14 +711,14 @@ describe("ClineProvider", () => { await messageHandler({ type: "loadApiConfiguration", text: "new-config" }) // Should save new config as default for architect mode - expect(provider.configManager.setModeConfig).toHaveBeenCalledWith("architect", "new-id") + expect(provider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("architect", "new-id") }) test("load API configuration by ID works and updates mode config", async () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] - provider.configManager = { + ;(provider as any).providerSettingsManager = { loadConfigById: jest.fn().mockResolvedValue({ config: { apiProvider: "anthropic", id: "config-id-123" }, name: "config-by-id", @@ -739,10 +737,10 @@ describe("ClineProvider", () => { await messageHandler({ type: "loadApiConfigurationById", text: "config-id-123" }) // Should save new config as default for architect mode - expect(provider.configManager.setModeConfig).toHaveBeenCalledWith("architect", "config-id-123") + expect(provider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("architect", "config-id-123") // Ensure the loadConfigById method was called with the correct ID - expect(provider.configManager.loadConfigById).toHaveBeenCalledWith("config-id-123") + expect(provider.providerSettingsManager.loadConfigById).toHaveBeenCalledWith("config-id-123") }) test("handles browserToolEnabled setting", async () => { @@ -971,7 +969,7 @@ describe("ClineProvider", () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] - provider.configManager = { + ;(provider as any).providerSettingsManager = { listConfig: jest.fn().mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), setModeConfig: jest.fn(), } as any @@ -983,7 +981,7 @@ describe("ClineProvider", () => { }) // Should save config as default for current mode - expect(provider.configManager.setModeConfig).toHaveBeenCalledWith("code", "test-id") + expect(provider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("code", "test-id") }) test("file content includes line numbers", async () => { @@ -1617,8 +1615,7 @@ describe("ClineProvider", () => { }) test("loads saved API config when switching modes", async () => { - // Mock ConfigManager methods - provider.configManager = { + ;(provider as any).providerSettingsManager = { getModeConfigId: jest.fn().mockResolvedValue("saved-config-id"), listConfig: jest .fn() @@ -1634,8 +1631,8 @@ describe("ClineProvider", () => { expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") // Verify saved config was loaded - expect(provider.configManager.getModeConfigId).toHaveBeenCalledWith("architect") - expect(provider.configManager.loadConfig).toHaveBeenCalledWith("saved-config") + expect(provider.providerSettingsManager.getModeConfigId).toHaveBeenCalledWith("architect") + expect(provider.providerSettingsManager.loadConfig).toHaveBeenCalledWith("saved-config") expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "saved-config") // Verify state was posted to webview @@ -1643,8 +1640,7 @@ describe("ClineProvider", () => { }) test("saves current config when switching to mode without config", async () => { - // Mock ConfigManager methods - provider.configManager = { + ;(provider as any).providerSettingsManager = { getModeConfigId: jest.fn().mockResolvedValue(undefined), listConfig: jest .fn() @@ -1665,7 +1661,7 @@ describe("ClineProvider", () => { expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") // Verify current config was saved as default for new mode - expect(provider.configManager.setModeConfig).toHaveBeenCalledWith("architect", "current-id") + expect(provider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("architect", "current-id") // Verify state was posted to webview expect(mockPostMessage).toHaveBeenCalledWith(expect.objectContaining({ type: "state" })) @@ -1678,7 +1674,7 @@ describe("ClineProvider", () => { const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] // Mock CustomModesManager methods - provider.customModesManager = { + ;(provider as any).customModesManager = { updateCustomMode: jest.fn().mockResolvedValue(undefined), getCustomModes: jest.fn().mockResolvedValue({ customModes: [ @@ -1748,8 +1744,7 @@ describe("ClineProvider", () => { provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] - // Mock ConfigManager methods to simulate error - provider.configManager = { + ;(provider as any).providerSettingsManager = { setModeConfig: jest.fn().mockRejectedValue(new Error("Failed to update mode config")), listConfig: jest .fn() @@ -1783,8 +1778,7 @@ describe("ClineProvider", () => { provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] - // Mock ConfigManager methods - provider.configManager = { + ;(provider as any).providerSettingsManager = { saveConfig: jest.fn().mockResolvedValue(undefined), listConfig: jest .fn() @@ -1804,7 +1798,7 @@ describe("ClineProvider", () => { }) // Verify config was saved - expect(provider.configManager.saveConfig).toHaveBeenCalledWith("test-config", testApiConfig) + expect(provider.providerSettingsManager.saveConfig).toHaveBeenCalledWith("test-config", testApiConfig) // Verify state updates expect(mockContext.globalState.update).toHaveBeenCalledWith("listApiConfigMeta", [ @@ -1825,9 +1819,7 @@ describe("ClineProvider", () => { ;(buildApiHandler as jest.Mock).mockImplementationOnce(() => { throw new Error("API handler error") }) - - // Mock ConfigManager methods - provider.configManager = { + ;(provider as any).providerSettingsManager = { saveConfig: jest.fn().mockResolvedValue(undefined), listConfig: jest .fn() @@ -1868,8 +1860,7 @@ describe("ClineProvider", () => { provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] - // Mock ConfigManager methods - provider.configManager = { + ;(provider as any).providerSettingsManager = { saveConfig: jest.fn().mockResolvedValue(undefined), listConfig: jest .fn() @@ -1889,7 +1880,7 @@ describe("ClineProvider", () => { }) // Verify config was saved - expect(provider.configManager.saveConfig).toHaveBeenCalledWith("test-config", testApiConfig) + expect(provider.providerSettingsManager.saveConfig).toHaveBeenCalledWith("test-config", testApiConfig) // Verify state updates expect(mockContext.globalState.update).toHaveBeenCalledWith("listApiConfigMeta", [ diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index cbedf48a22..8147258595 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -69,7 +69,8 @@ "mcp_server_deleted": "Servidor MCP eliminat: {{serverName}}", "mcp_server_not_found": "Servidor \"{{serverName}}\" no trobat a la configuració", "custom_storage_path_set": "Ruta d'emmagatzematge personalitzada establerta: {{path}}", - "default_storage_path": "S'ha reprès l'ús de la ruta d'emmagatzematge predeterminada" + "default_storage_path": "S'ha reprès l'ús de la ruta d'emmagatzematge predeterminada", + "settings_imported": "Configuració importada correctament." }, "answers": { "yes": "Sí", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 6e953dd9ab..0b004641c8 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -65,7 +65,8 @@ "mcp_server_deleted": "MCP-Server gelöscht: {{serverName}}", "mcp_server_not_found": "Server \"{{serverName}}\" nicht in der Konfiguration gefunden", "custom_storage_path_set": "Benutzerdefinierter Speicherpfad festgelegt: {{path}}", - "default_storage_path": "Auf Standardspeicherpfad zurückgesetzt" + "default_storage_path": "Auf Standardspeicherpfad zurückgesetzt", + "settings_imported": "Einstellungen erfolgreich importiert." }, "answers": { "yes": "Ja", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 6f1e496f64..0f964f8adf 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -65,7 +65,8 @@ "mcp_server_deleted": "Deleted MCP server: {{serverName}}", "mcp_server_not_found": "Server \"{{serverName}}\" not found in configuration", "custom_storage_path_set": "Custom storage path set: {{path}}", - "default_storage_path": "Reverted to using default storage path" + "default_storage_path": "Reverted to using default storage path", + "settings_imported": "Settings imported successfully." }, "answers": { "yes": "Yes", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 52c87275c9..4b57081f6c 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -65,7 +65,8 @@ "mcp_server_deleted": "Servidor MCP eliminado: {{serverName}}", "mcp_server_not_found": "Servidor \"{{serverName}}\" no encontrado en la configuración", "custom_storage_path_set": "Ruta de almacenamiento personalizada establecida: {{path}}", - "default_storage_path": "Se ha vuelto a usar la ruta de almacenamiento predeterminada" + "default_storage_path": "Se ha vuelto a usar la ruta de almacenamiento predeterminada", + "settings_imported": "Configuración importada correctamente." }, "answers": { "yes": "Sí", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index cbbf692e4a..56a52ee83d 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -65,7 +65,8 @@ "mcp_server_deleted": "Serveur MCP supprimé : {{serverName}}", "mcp_server_not_found": "Serveur \"{{serverName}}\" introuvable dans la configuration", "custom_storage_path_set": "Chemin de stockage personnalisé défini : {{path}}", - "default_storage_path": "Retour au chemin de stockage par défaut" + "default_storage_path": "Retour au chemin de stockage par défaut", + "settings_imported": "Paramètres importés avec succès." }, "answers": { "yes": "Oui", diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index eef4c4b751..852e2a58c1 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -65,7 +65,8 @@ "mcp_server_deleted": "MCP सर्वर हटाया गया: {{serverName}}", "mcp_server_not_found": "सर्वर \"{{serverName}}\" कॉन्फ़िगरेशन में नहीं मिला", "custom_storage_path_set": "कस्टम स्टोरेज पाथ सेट किया गया: {{path}}", - "default_storage_path": "डिफ़ॉल्ट स्टोरेज पाथ का उपयोग पुनः शुरू किया गया" + "default_storage_path": "डिफ़ॉल्ट स्टोरेज पाथ का उपयोग पुनः शुरू किया गया", + "settings_imported": "सेटिंग्स सफलतापूर्वक इम्पोर्ट की गईं।" }, "answers": { "yes": "हां", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 2212ee2ff8..a919d1bdb9 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -65,7 +65,8 @@ "mcp_server_deleted": "Server MCP eliminato: {{serverName}}", "mcp_server_not_found": "Server \"{{serverName}}\" non trovato nella configurazione", "custom_storage_path_set": "Percorso di archiviazione personalizzato impostato: {{path}}", - "default_storage_path": "Tornato al percorso di archiviazione predefinito" + "default_storage_path": "Tornato al percorso di archiviazione predefinito", + "settings_imported": "Impostazioni importate con successo." }, "answers": { "yes": "Sì", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index be37e832a1..6bb6def7a8 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -65,7 +65,8 @@ "mcp_server_deleted": "MCPサーバーが削除されました:{{serverName}}", "mcp_server_not_found": "サーバー\"{{serverName}}\"が設定内に見つかりません", "custom_storage_path_set": "カスタムストレージパスが設定されました:{{path}}", - "default_storage_path": "デフォルトのストレージパスに戻りました" + "default_storage_path": "デフォルトのストレージパスに戻りました", + "settings_imported": "設定が正常にインポートされました。" }, "answers": { "yes": "はい", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 794aa8d59a..24f03a778b 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -65,7 +65,8 @@ "mcp_server_deleted": "MCP 서버 삭제됨: {{serverName}}", "mcp_server_not_found": "구성에서 서버 \"{{serverName}}\"을(를) 찾을 수 없습니다", "custom_storage_path_set": "사용자 지정 저장 경로 설정됨: {{path}}", - "default_storage_path": "기본 저장 경로로 되돌아갔습니다" + "default_storage_path": "기본 저장 경로로 되돌아갔습니다", + "settings_imported": "설정이 성공적으로 가져와졌습니다." }, "answers": { "yes": "예", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 4218218c67..dd4e385e67 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -65,7 +65,8 @@ "mcp_server_deleted": "Usunięto serwer MCP: {{serverName}}", "mcp_server_not_found": "Serwer \"{{serverName}}\" nie znaleziony w konfiguracji", "custom_storage_path_set": "Ustawiono niestandardową ścieżkę przechowywania: {{path}}", - "default_storage_path": "Wznowiono używanie domyślnej ścieżki przechowywania" + "default_storage_path": "Wznowiono używanie domyślnej ścieżki przechowywania", + "settings_imported": "Ustawienia zaimportowane pomyślnie." }, "answers": { "yes": "Tak", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 844970f220..9e2db6f7a6 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -69,7 +69,8 @@ "mcp_server_deleted": "Servidor MCP excluído: {{serverName}}", "mcp_server_not_found": "Servidor \"{{serverName}}\" não encontrado na configuração", "custom_storage_path_set": "Caminho de armazenamento personalizado definido: {{path}}", - "default_storage_path": "Retornado ao caminho de armazenamento padrão" + "default_storage_path": "Retornado ao caminho de armazenamento padrão", + "settings_imported": "Configurações importadas com sucesso." }, "answers": { "yes": "Sim", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 82464b7342..3413057693 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -65,7 +65,8 @@ "mcp_server_deleted": "MCP sunucusu silindi: {{serverName}}", "mcp_server_not_found": "Yapılandırmada \"{{serverName}}\" sunucusu bulunamadı", "custom_storage_path_set": "Özel depolama yolu ayarlandı: {{path}}", - "default_storage_path": "Varsayılan depolama yoluna geri dönüldü" + "default_storage_path": "Varsayılan depolama yoluna geri dönüldü", + "settings_imported": "Ayarlar başarıyla içe aktarıldı." }, "answers": { "yes": "Evet", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index a2824be182..9e0bde90b1 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -65,7 +65,8 @@ "mcp_server_deleted": "Đã xóa máy chủ MCP: {{serverName}}", "mcp_server_not_found": "Không tìm thấy máy chủ \"{{serverName}}\" trong cấu hình", "custom_storage_path_set": "Đã thiết lập đường dẫn lưu trữ tùy chỉnh: {{path}}", - "default_storage_path": "Đã quay lại sử dụng đường dẫn lưu trữ mặc định" + "default_storage_path": "Đã quay lại sử dụng đường dẫn lưu trữ mặc định", + "settings_imported": "Cài đặt đã được nhập thành công." }, "answers": { "yes": "Có", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index b4c41db5e2..0b8e679580 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -65,7 +65,8 @@ "mcp_server_deleted": "已删除MCP服务器:{{serverName}}", "mcp_server_not_found": "在配置中未找到服务器\"{{serverName}}\"", "custom_storage_path_set": "自定义存储路径已设置:{{path}}", - "default_storage_path": "已恢复使用默认存储路径" + "default_storage_path": "已恢复使用默认存储路径", + "settings_imported": "设置已成功导入。" }, "answers": { "yes": "是", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 7b36be8145..19d1e3bdbc 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -65,7 +65,8 @@ "mcp_server_deleted": "已刪除MCP服務器:{{serverName}}", "mcp_server_not_found": "在配置中未找到服務器\"{{serverName}}\"", "custom_storage_path_set": "自定義存儲路徑已設置:{{path}}", - "default_storage_path": "已恢復使用默認存儲路徑" + "default_storage_path": "已恢復使用默認存儲路徑", + "settings_imported": "設置已成功導入。" }, "answers": { "yes": "是", diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index b01dca3b37..ebc781e8eb 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -1033,6 +1033,7 @@ const ChatTextArea = forwardRef( }} contentClassName="max-h-[300px] overflow-y-auto" triggerClassName="w-full text-ellipsis overflow-hidden" + itemClassName="group" renderItem={({ type, value, label, pinned }) => { if (type !== DropdownOptionType.ITEM) { return label @@ -1042,9 +1043,16 @@ const ChatTextArea = forwardRef( const isCurrentConfig = config?.name === currentApiConfigName return ( -
+
{label}
-
+
+
+ +
- {isCurrentConfig && }
) diff --git a/webview-ui/src/components/settings/About.tsx b/webview-ui/src/components/settings/About.tsx index c3f20f1196..93857832fe 100644 --- a/webview-ui/src/components/settings/About.tsx +++ b/webview-ui/src/components/settings/About.tsx @@ -9,7 +9,7 @@ import { TelemetrySetting } from "../../../../src/shared/TelemetrySetting" import { vscode } from "@/utils/vscode" import { cn } from "@/lib/utils" -import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui" +import { Button } from "@/components/ui" import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" @@ -59,45 +59,17 @@ export const About = ({ version, telemetrySetting, setTelemetrySetting, classNam
- - - - - - vscode.postMessage({ type: "importSettings", text: "provider" })}> - Current Provider Settings - - vscode.postMessage({ type: "importSettings", text: "global" })}> - Global Settings - - - - - - - - - vscode.postMessage({ type: "exportSettings", text: "provider" })}> - Current Provider Settings - - vscode.postMessage({ type: "exportSettings", text: "global" })}> - Global Settings - - - + +
diff --git a/webview-ui/src/components/ui/select-dropdown.tsx b/webview-ui/src/components/ui/select-dropdown.tsx index 4018418439..680a01f811 100644 --- a/webview-ui/src/components/ui/select-dropdown.tsx +++ b/webview-ui/src/components/ui/select-dropdown.tsx @@ -36,6 +36,7 @@ export interface SelectDropdownProps { title?: string triggerClassName?: string contentClassName?: string + itemClassName?: string sideOffset?: number align?: "start" | "center" | "end" placeholder?: string @@ -53,6 +54,7 @@ export const SelectDropdown = React.forwardRef handleSelect(option)}> + onClick={() => handleSelect(option)} + className={itemClassName}> {renderItem ? ( renderItem(option) ) : ( diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index b88860eedc..4ca952528c 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -381,9 +381,10 @@ "label": "Permetre informes anònims d'errors i ús", "description": "Ajudeu a millorar Roo Code enviant dades d'ús anònimes i informes d'errors. Mai s'envia codi, prompts o informació personal. Vegeu la nostra política de privacitat per a més detalls." }, - "reset": { - "description": "Restablir tot l'estat global i emmagatzematge secret a l'extensió.", - "button": "Restablir" + "settings": { + "import": "Importar", + "export": "Exportar", + "reset": "Restablir" } }, "thinkingBudget": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index d93d661473..d934c54c6a 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -381,9 +381,10 @@ "label": "Anonyme Fehler- und Nutzungsberichte zulassen", "description": "Helfen Sie, Roo Code zu verbessern, indem Sie anonyme Nutzungsdaten und Fehlerberichte senden. Es werden niemals Code, Prompts oder persönliche Informationen gesendet. Weitere Details finden Sie in unserer Datenschutzrichtlinie." }, - "reset": { - "description": "Setze alle globalen Zustände und geheimen Speicher in der Erweiterung zurück.", - "button": "Zurücksetzen" + "settings": { + "import": "Importieren", + "export": "Exportieren", + "reset": "Zurücksetzen" } }, "thinkingBudget": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 2ad31355a1..a860f0b79c 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -380,9 +380,10 @@ "label": "Allow anonymous error and usage reporting", "description": "Help improve Roo Code by sending anonymous usage data and error reports. No code, prompts, or personal information is ever sent. See our privacy policy for more details." }, - "reset": { - "description": "Reset all global state and secret storage in the extension.", - "button": "Reset" + "settings": { + "import": "Import", + "export": "Export", + "reset": "Reset" } }, "thinkingBudget": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index d98b59c63a..3a838cce1f 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -381,9 +381,10 @@ "label": "Permitir informes anónimos de errores y uso", "description": "Ayude a mejorar Roo Code enviando datos de uso anónimos e informes de errores. Nunca se envía código, prompts o información personal. Consulte nuestra política de privacidad para más detalles." }, - "reset": { - "description": "Restablecer todo el estado global y almacenamiento secreto en la extensión.", - "button": "Restablecer" + "settings": { + "import": "Importar", + "export": "Exportar", + "reset": "Restablecer" } }, "thinkingBudget": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index ca4e6fc494..ce024bf4be 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -381,9 +381,10 @@ "label": "Autoriser les rapports anonymes d'erreurs et d'utilisation", "description": "Aidez à améliorer Roo Code en envoyant des données d'utilisation anonymes et des rapports d'erreurs. Aucun code, prompt ou information personnelle n'est jamais envoyé. Consultez notre politique de confidentialité pour plus de détails." }, - "reset": { - "description": "Réinitialiser tous les états globaux et le stockage secret dans l'extension.", - "button": "Réinitialiser" + "settings": { + "import": "Importer", + "export": "Exporter", + "reset": "Réinitialiser" } }, "thinkingBudget": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index b60bf6c72e..d82f8a9eb9 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -381,9 +381,10 @@ "label": "गुमनाम त्रुटि और उपयोग रिपोर्टिंग की अनुमति दें", "description": "गुमनाम उपयोग डेटा और त्रुटि रिपोर्ट भेजकर Roo Code को बेहतर बनाने में मदद करें। कोड, प्रॉम्प्ट, या व्यक्तिगत जानकारी कभी भी नहीं भेजी जाती है। अधिक विवरण के लिए हमारी गोपनीयता नीति देखें।" }, - "reset": { - "description": "एक्सटेंशन में सभी वैश्विक स्थिति और गुप्त भंडारण को रीसेट करें।", - "button": "रीसेट करें" + "settings": { + "import": "इम्पोर्ट", + "export": "एक्सपोर्ट", + "reset": "रीसेट करें" } }, "thinkingBudget": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index fe849f4ea4..06257f4ede 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -381,9 +381,10 @@ "label": "Consenti segnalazioni anonime di errori e utilizzo", "description": "Aiuta a migliorare Roo Code inviando dati di utilizzo anonimi e segnalazioni di errori. Non vengono mai inviati codice, prompt o informazioni personali. Consulta la nostra politica sulla privacy per maggiori dettagli." }, - "reset": { - "description": "Reimposta tutti gli stati globali e l'archivio segreto nell'estensione.", - "button": "Ripristina" + "settings": { + "import": "Importa", + "export": "Esporta", + "reset": "Ripristina" } }, "thinkingBudget": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index ec195a3ace..6726257d2e 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -381,9 +381,10 @@ "label": "匿名のエラーと使用状況レポートを許可", "description": "匿名の使用データとエラーレポートを送信してRoo Codeの改善にご協力ください。コード、プロンプト、個人情報が送信されることはありません。詳細については、プライバシーポリシーをご覧ください。" }, - "reset": { - "description": "拡張機能内のすべてのグローバル状態とシークレットストレージをリセットします。", - "button": "リセット" + "settings": { + "import": "インポート", + "export": "エクスポート", + "reset": "リセット" } }, "thinkingBudget": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index d83111b89e..903e783aa8 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -381,9 +381,10 @@ "label": "익명 오류 및 사용 보고 허용", "description": "익명 사용 데이터 및 오류 보고서를 보내 Roo Code 개선에 도움을 주세요. 코드, 프롬프트 또는 개인 정보는 절대 전송되지 않습니다. 자세한 내용은 개인정보 보호정책을 참조하세요." }, - "reset": { - "description": "확장 프로그램의 모든 전역 상태 및 보안 저장소를 재설정합니다.", - "button": "초기화" + "settings": { + "import": "가져오기", + "export": "내보내기", + "reset": "초기화" } }, "thinkingBudget": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 8ea8a6b250..3c589f5497 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -381,9 +381,10 @@ "label": "Zezwól na anonimowe raportowanie błędów i użycia", "description": "Pomóż ulepszyć Roo Code, wysyłając anonimowe dane o użytkowaniu i raporty o błędach. Nigdy nie są wysyłane kod, podpowiedzi ani informacje osobiste. Zobacz naszą politykę prywatności, aby uzyskać więcej szczegółów." }, - "reset": { - "description": "Zresetuj wszystkie globalne stany i tajne magazyny w rozszerzeniu.", - "button": "Resetuj" + "settings": { + "import": "Importuj", + "export": "Eksportuj", + "reset": "Resetuj" } }, "thinkingBudget": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 017f5714d1..e73f822cec 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -381,9 +381,10 @@ "label": "Permitir relatórios anônimos de erros e uso", "description": "Ajude a melhorar o Roo Code enviando dados de uso anônimos e relatórios de erros. Nunca são enviados código, prompts ou informações pessoais. Consulte nossa política de privacidade para mais detalhes." }, - "reset": { - "description": "Redefinir todo o estado global e armazenamento secreto na extensão.", - "button": "Redefinir" + "settings": { + "import": "Importar", + "export": "Exportar", + "reset": "Redefinir" } }, "thinkingBudget": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 7cc8cc06ac..5d00bfa13c 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -381,9 +381,10 @@ "label": "Anonim hata ve kullanım raporlamaya izin ver", "description": "Anonim kullanım verileri ve hata raporları göndererek Roo Code'u geliştirmeye yardımcı olun. Hiçbir kod, istem veya kişisel bilgi asla gönderilmez. Daha fazla ayrıntı için gizlilik politikamıza bakın." }, - "reset": { - "description": "Uzantıdaki tüm global durumu ve gizli depolamayı sıfırlayın.", - "button": "Sıfırla" + "settings": { + "import": "İçe Aktar", + "export": "Dışa Aktar", + "reset": "Sıfırla" } }, "thinkingBudget": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index b93b4617cd..6af2f6a5ef 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -381,9 +381,10 @@ "label": "Cho phép báo cáo lỗi và sử dụng ẩn danh", "description": "Giúp cải thiện Roo Code bằng cách gửi dữ liệu sử dụng ẩn danh và báo cáo lỗi. Không bao giờ gửi mã, lời nhắc hoặc thông tin cá nhân. Xem chính sách bảo mật của chúng tôi để biết thêm chi tiết." }, - "reset": { - "description": "Đặt lại tất cả trạng thái toàn cầu và lưu trữ bí mật trong tiện ích mở rộng.", - "button": "Đặt lại" + "settings": { + "import": "Nhập", + "export": "Xuất", + "reset": "Đặt lại" } }, "thinkingBudget": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 8ab8055767..c13ed64312 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -381,9 +381,10 @@ "label": "允许匿名错误和使用情况报告", "description": "通过发送匿名使用数据和错误报告来帮助改进 Roo Code。绝不会发送代码、提示或个人信息。有关更多详细信息,请参阅我们的隐私政策。" }, - "reset": { - "description": "重置扩展中的所有全局状态和密钥存储。", - "button": "重置" + "settings": { + "import": "导入", + "export": "导出", + "reset": "重置" } }, "thinkingBudget": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 071d307def..706faf37bc 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -381,9 +381,10 @@ "label": "允許匿名錯誤和使用報告", "description": "匿名資料協助改善 Roo Code,絕不傳送程式碼或個人資訊" }, - "reset": { - "description": "重設擴充功能的全局狀態和金鑰儲存", - "button": "重設" + "settings": { + "import": "匯入", + "export": "匯出", + "reset": "重設" } }, "thinkingBudget": {