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 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/config/ConfigManager.ts b/src/core/config/ConfigManager.ts deleted file mode 100644 index 0ff8b0d400..0000000000 --- a/src/core/config/ConfigManager.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { ExtensionContext } from "vscode" -import { ApiConfiguration } from "../../shared/api" -import { Mode } from "../../shared/modes" -import { ApiConfigMeta } from "../../shared/ExtensionMessage" - -export interface ApiConfigData { - currentApiConfigName: string - apiConfigs: { - [key: string]: ApiConfiguration - } - 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(): string { - 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(): Promise { - 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: ApiConfiguration): Promise { - 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): Promise { - 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): Promise<{ config: ApiConfiguration; name: 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): Promise { - 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): Promise { - 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): Promise { - 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): Promise { - 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): Promise { - 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(): string { - return `${this.SCOPE_PREFIX}api_config` - } - - /** - * Reset all configuration by deleting the stored config from secrets - */ - public async resetAllConfigs(): Promise { - 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 - } - - return JSON.parse(content) - } catch (error) { - throw new Error(`Failed to read config from secrets: ${error}`) - } - } - - private async writeConfig(config: ApiConfigData): Promise { - 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/config/ContextProxy.ts b/src/core/config/ContextProxy.ts new file mode 100644 index 0000000000..f4a8c56a8d --- /dev/null +++ b/src/core/config/ContextProxy.ts @@ -0,0 +1,232 @@ +import * as vscode from "vscode" + +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_STATE_KEYS, + isSecretStateKey, + isPassThroughStateKey, + globalSettingsSchema, + providerSettingsSchema, +} from "../../shared/globalState" + +const globalSettingsExportSchema = globalSettingsSchema.omit({ + taskHistory: true, + listApiConfigMeta: true, + currentApiConfigName: true, +}) + +export class ContextProxy { + private readonly originalContext: vscode.ExtensionContext + + private stateCache: GlobalState + private secretCache: SecretState + private _isInitialized = false + + constructor(context: vscode.ExtensionContext) { + this.originalContext = context + this.stateCache = {} + this.secretCache = {} + this._isInitialized = false + } + + public get isInitialized() { + return this._isInitialized + } + + public async initialize() { + for (const key of GLOBAL_STATE_KEYS) { + try { + 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_STATE_KEYS.map(async (key) => { + try { + this.secretCache[key] = await this.originalContext.secrets.get(key) + } catch (error) { + logger.error(`Error loading secret ${key}: ${error instanceof Error ? error.message : String(error)}`) + } + }) + + await Promise.all(promises) + + this._isInitialized = true + } + + public get extensionUri() { + return this.originalContext.extensionUri + } + + public get extensionPath() { + return this.originalContext.extensionPath + } + + public get globalStorageUri() { + return this.originalContext.globalStorageUri + } + + public get logUri() { + return this.originalContext.logUri + } + + public get extension() { + return this.originalContext.extension + } + + public get extensionMode() { + return this.originalContext.extensionMode + } + + /** + * 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 + } + + const value = this.stateCache[key] + return value !== undefined ? value : defaultValue + } + + updateGlobalState(key: K, value: GlobalState[K]) { + if (isPassThroughStateKey(key)) { + return this.originalContext.globalState.update(key, value) + } + + this.stateCache[key] = value + return this.originalContext.globalState.update(key, value) + } + + 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: SecretStateKey, value?: string) { + // Update cache. + this.secretCache[key] = value + + // Write directly to context. + return value === undefined + ? this.originalContext.secrets.delete(key) + : this.originalContext.secrets.store(key, value) + } + + private getAllSecretState(): SecretState { + return Object.fromEntries(SECRET_STATE_KEYS.map((key) => [key, this.getSecret(key)])) + } + + /** + * GlobalSettings + */ + + public getGlobalSettings(): GlobalSettings { + return globalSettingsSchema.parse({ ...this.stateCache }) + } + + /** + * ProviderSettings + */ + + public getProviderSettings(): ProviderSettings { + return providerSettingsSchema.parse(this.getValues()) + } + + 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({ + ...PROVIDER_SETTINGS_KEYS.filter((key) => !isSecretStateKey(key)) + .filter((key) => !!this.stateCache[key]) + .reduce((acc, key) => ({ ...acc, [key]: undefined }), {} as ProviderSettings), + ...values, + }) + } + + /** + * 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.getAllSecretState() } + } + + public async setValues(values: RooCodeSettings) { + const entries = Object.entries(values) as [RooCodeSettingsKey, unknown][] + 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. + * @returns A promise that resolves when all reset operations are complete + */ + public async resetAllState() { + // Clear in-memory caches + this.stateCache = {} + this.secretCache = {} + + await Promise.all([ + ...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)), + ...SECRET_STATE_KEYS.map((key) => this.originalContext.secrets.delete(key)), + ]) + + await this.initialize() + } +} 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 83% rename from src/core/__tests__/contextProxy.test.ts rename to src/core/config/__tests__/ContextProxy.test.ts index e2d6c4ad12..85b72c8a32 100644 --- a/src/core/__tests__/contextProxy.test.ts +++ b/src/core/config/__tests__/ContextProxy.test.ts @@ -1,10 +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_KEYS, ConfigurationKey, GlobalStateKey } from "../../shared/globalState" +import { logger } from "../../../utils/logging" +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 () => { 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/contextProxy.ts b/src/core/contextProxy.ts deleted file mode 100644 index 698713179d..0000000000 --- a/src/core/contextProxy.ts +++ /dev/null @@ -1,189 +0,0 @@ -import * as vscode from "vscode" - -import { logger } from "../utils/logging" -import { - GLOBAL_STATE_KEYS, - SECRET_KEYS, - GlobalStateKey, - SecretKey, - ConfigurationKey, - ConfigurationValues, - isSecretKey, - isGlobalStateKey, - isPassThroughStateKey, -} from "../shared/globalState" -import { API_CONFIG_KEYS, ApiConfiguration } from "../shared/api" - -export class ContextProxy { - private readonly originalContext: vscode.ExtensionContext - - private stateCache: Map - private secretCache: Map - private _isInitialized = false - - constructor(context: vscode.ExtensionContext) { - this.originalContext = context - this.stateCache = new Map() - this.secretCache = new Map() - this._isInitialized = false - } - - public get isInitialized() { - return this._isInitialized - } - - public async initialize() { - for (const key of GLOBAL_STATE_KEYS) { - try { - this.stateCache.set(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) => { - try { - this.secretCache.set(key, await this.originalContext.secrets.get(key)) - } catch (error) { - logger.error(`Error loading secret ${key}: ${error instanceof Error ? error.message : String(error)}`) - } - }) - - await Promise.all(promises) - - this._isInitialized = true - } - - get extensionUri() { - return this.originalContext.extensionUri - } - - get extensionPath() { - return this.originalContext.extensionPath - } - - get globalStorageUri() { - return this.originalContext.globalStorageUri - } - - get logUri() { - return this.originalContext.logUri - } - - get extension() { - return this.originalContext.extension - } - - get extensionMode() { - return this.originalContext.extensionMode - } - - getGlobalState(key: GlobalStateKey): T | undefined - getGlobalState(key: GlobalStateKey, defaultValue: T): T - getGlobalState(key: GlobalStateKey, defaultValue?: T): T | undefined { - if (isPassThroughStateKey(key)) { - const value = this.originalContext.globalState.get(key) - return value === undefined || value === null ? defaultValue : (value as T) - } - const value = this.stateCache.get(key) as T | undefined - return value !== undefined ? value : (defaultValue as T | undefined) - } - - updateGlobalState(key: GlobalStateKey, value: T) { - if (isPassThroughStateKey(key)) { - return this.originalContext.globalState.update(key, value) - } - this.stateCache.set(key, value) - return this.originalContext.globalState.update(key, value) - } - - getSecret(key: SecretKey) { - return this.secretCache.get(key) - } - - storeSecret(key: SecretKey, value?: string) { - // Update cache. - this.secretCache.set(key, value) - - // Write directly to context. - return value === undefined - ? this.originalContext.secrets.delete(key) - : this.originalContext.secrets.store(key, value) - } - - /** - * 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 - */ - setValue(key: ConfigurationKey, value: any) { - if (isSecretKey(key)) { - return this.storeSecret(key, value) - } - - if (isGlobalStateKey(key)) { - return this.updateGlobalState(key, value) - } - - logger.warn(`Unknown key: ${key}. Storing as global state.`) - return this.updateGlobalState(key, value) - } - - /** - * 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 - */ - 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) - } - - async setApiConfiguration(apiConfiguration: ApiConfiguration) { - // 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, - }) - } - - /** - * 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() { - // 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)) - - // Wait for all reset operations to complete. - await Promise.all([...stateResetPromises, ...secretResetPromises]) - - 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 db0491ad27..87ba91196f 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1,20 +1,29 @@ +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 { + 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,17 +34,10 @@ 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" -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" @@ -58,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" @@ -99,12 +102,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() - } + public readonly providerSettingsManager: ProviderSettingsManager + public readonly customModesManager: CustomModesManager + constructor( readonly context: vscode.ExtensionContext, private readonly outputChannel: vscode.OutputChannel, @@ -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) { @@ -894,25 +899,27 @@ export class ClineProvider extends EventEmitter implements } if (listApiConfig.length === 1) { - // check if first time init then sync with exist config + // Check if first time init then sync with exist config. if (!checkExistKey(listApiConfig[0])) { const { apiConfiguration } = await this.getState() - await this.configManager.saveConfig( + + await this.providerSettingsManager.saveConfig( listApiConfig[0].name ?? "default", apiConfiguration, ) + listApiConfig[0].apiProvider = apiConfiguration.apiProvider } } - const currentConfigName = (await this.getGlobalState("currentApiConfigName")) as string + 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, ) @@ -1037,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 @@ -1080,6 +1088,26 @@ export class ClineProvider extends EventEmitter implements } case "exportTaskWithId": this.exportTaskWithId(message.text!) + break + case "importSettings": + const { success } = await importSettings({ + providerSettingsManager: this.providerSettingsManager, + contextProxy: this.contextProxy, + }) + + if (success) { + this.settingsImportedAt = Date.now() + await this.postStateToWebview() + await vscode.window.showInformationMessage(t("common:info.settings_imported")) + } + + break + case "exportSettings": + await exportSettings({ + providerSettingsManager: this.providerSettingsManager, + contextProxy: this.contextProxy, + }) + break case "resetState": await this.resetState() @@ -1338,7 +1366,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": @@ -1485,13 +1513,8 @@ export class ClineProvider extends EventEmitter implements return } - const existingPrompts = (await this.getGlobalState("customSupportPrompts")) || {} - - const updatedPrompts = { - ...existingPrompts, - ...message.values, - } - + const existingPrompts = this.getGlobalState("customSupportPrompts") ?? {} + const updatedPrompts = { ...existingPrompts, ...message.values } await this.updateGlobalState("customSupportPrompts", updatedPrompts) await this.postStateToWebview() } catch (error) { @@ -1507,15 +1530,9 @@ export class ClineProvider extends EventEmitter implements return } - const existingPrompts = ((await this.getGlobalState("customSupportPrompts")) || - {}) as Record - - const updatedPrompts = { - ...existingPrompts, - } - + const existingPrompts = this.getGlobalState("customSupportPrompts") ?? {} + const updatedPrompts = { ...existingPrompts } updatedPrompts[message.text] = undefined - await this.updateGlobalState("customSupportPrompts", updatedPrompts) await this.postStateToWebview() } catch (error) { @@ -1527,28 +1544,12 @@ export class ClineProvider extends EventEmitter implements break case "updatePrompt": if (message.promptMode && message.customPrompt !== undefined) { - const existingPrompts = (await this.getGlobalState("customModePrompts")) || {} - - const updatedPrompts = { - ...existingPrompts, - [message.promptMode]: message.customPrompt, - } - + const existingPrompts = this.getGlobalState("customModePrompts") ?? {} + const updatedPrompts = { ...existingPrompts, [message.promptMode]: message.customPrompt } await this.updateGlobalState("customModePrompts", updatedPrompts) - - // Get current state and explicitly include customModePrompts const currentState = await this.getState() - - const stateWithPrompts = { - ...currentState, - customModePrompts: updatedPrompts, - } - - // Post state with prompts - this.view?.webview.postMessage({ - type: "state", - state: stateWithPrompts, - }) + const stateWithPrompts = { ...currentState, customModePrompts: updatedPrompts } + this.view?.webview.postMessage({ type: "state", state: stateWithPrompts }) } break case "deleteMessage": { @@ -1664,7 +1665,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": @@ -1677,13 +1678,9 @@ export class ClineProvider extends EventEmitter implements break case "toggleApiConfigPin": if (message.text) { - const currentPinned = ((await this.getGlobalState("pinnedApiConfigs")) || {}) as Record< - string, - boolean - > + const currentPinned = this.getGlobalState("pinnedApiConfigs") ?? {} const updatedPinned: Record = { ...currentPinned } - // Toggle the pinned state if (currentPinned[message.text]) { delete updatedPinned[message.text] } else { @@ -1719,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 } @@ -1842,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( @@ -1868,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 = { @@ -1877,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) @@ -1898,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), @@ -1919,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), @@ -1952,16 +1949,19 @@ 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) // If this was the current config, switch to first available - const currentApiConfigName = await this.getGlobalState("currentApiConfigName") + 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), @@ -1979,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) { @@ -1995,9 +1995,9 @@ export class ClineProvider extends EventEmitter implements } const updatedExperiments = { - ...((await this.getGlobalState("experiments")) ?? experimentDefault), + ...(this.getGlobalState("experiments") ?? experimentDefault), ...message.values, - } as Record + } await this.updateGlobalState("experiments", updatedExperiments) @@ -2169,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) @@ -2179,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), @@ -2187,11 +2187,12 @@ export class ClineProvider extends EventEmitter implements } } else { // If no saved config for this mode, save current config as default - const currentApiConfigName = await this.getGlobalState("currentApiConfigName") + const currentApiConfigName = this.getGlobalState("currentApiConfigName") + 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) } } } @@ -2199,23 +2200,24 @@ 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() if (mode) { - const currentApiConfigName = await this.getGlobalState("currentApiConfigName") - const listApiConfig = await this.configManager.listConfig() + const currentApiConfigName = this.getGlobalState("currentApiConfigName") + 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) } } - await this.contextProxy.setApiConfiguration(apiConfiguration) + + await this.contextProxy.setProviderSettings(providerSettings) if (this.getCurrentCline()) { - this.getCurrentCline()!.api = buildApiHandler(apiConfiguration) + this.getCurrentCline()!.api = buildApiHandler(providerSettings) } } @@ -2407,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), @@ -2434,8 +2436,9 @@ export class ClineProvider extends EventEmitter implements uiMessagesFilePath: string apiConversationHistory: Anthropic.MessageParam[] }> { - const history = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || [] + const history = this.getGlobalState("taskHistory") ?? [] const historyItem = history.find((item) => item.id === id) + if (historyItem) { const { getTaskDirectoryPath } = await import("../../shared/storagePathManager") const globalStoragePath = this.contextProxy.globalStorageUri.fsPath @@ -2443,8 +2446,10 @@ export class ClineProvider extends EventEmitter implements const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory) const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages) const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath) + if (fileExists) { const apiConversationHistory = JSON.parse(await fs.readFile(apiConversationHistoryFilePath, "utf8")) + return { historyItem, taskDirPath, @@ -2454,6 +2459,7 @@ export class ClineProvider extends EventEmitter implements } } } + // if we tried to get a task that doesn't exist, remove it from state // FIXME: this seems to happen sometimes when the json file doesnt save to disk for some reason await this.deleteTaskFromState(id) @@ -2524,12 +2530,9 @@ export class ClineProvider extends EventEmitter implements } async deleteTaskFromState(id: string) { - // Remove the task from history - const taskHistory = ((await this.getGlobalState("taskHistory")) as HistoryItem[]) || [] + const taskHistory = this.getGlobalState("taskHistory") ?? [] const updatedTaskHistory = taskHistory.filter((task) => task.id !== id) await this.updateGlobalState("taskHistory", updatedTaskHistory) - - // Notify the webview that the task has been deleted await this.postStateToWebview() } @@ -2575,6 +2578,7 @@ export class ClineProvider extends EventEmitter implements rateLimitSeconds, currentApiConfigName, listApiConfigMeta, + pinnedApiConfigs, mode, customModePrompts, customSupportPrompts, @@ -2641,6 +2645,7 @@ export class ClineProvider extends EventEmitter implements rateLimitSeconds: rateLimitSeconds ?? 0, currentApiConfigName: currentApiConfigName ?? "default", listApiConfigMeta: listApiConfigMeta ?? [], + pinnedApiConfigs: pinnedApiConfigs ?? {}, mode: mode ?? defaultModeSlug, customModePrompts: customModePrompts ?? {}, customSupportPrompts: customSupportPrompts ?? {}, @@ -2660,111 +2665,35 @@ export class ClineProvider extends EventEmitter implements language, renderContext: this.renderContext, maxReadFileLine: maxReadFileLine ?? 500, - pinnedApiConfigs: - ((await this.getGlobalState("pinnedApiConfigs")) as Record) ?? - ({} as Record), + settingsImportedAt: this.settingsImportedAt, } } - // Caching mechanism to keep track of webview messages + API conversation history per provider instance - - /* - Now that we use retainContextWhenHidden, we don't have to store a cache of cline messages in the user's state, but we could to reduce memory footprint in long conversations. - - - We have to be careful of what state is shared between ClineProvider instances since there could be multiple instances of the extension running at once. For example when we cached cline messages using the same key, two instances of the extension could end up using the same key and overwriting each other's messages. - - Some state does need to be shared between the instances, i.e. the API key--however there doesn't seem to be a good way to notfy the other instances that the API key has changed. - - We need to use a unique identifier for each ClineProvider instance's message cache since we could be running several instances of the extension outside of just the sidebar i.e. in editor panels. - - // conversation history to send in API requests - - /* - It seems that some API messages do not comply with vscode state requirements. Either the Anthropic library is manipulating these values somehow in the backend in a way thats creating cyclic references, or the API returns a function or a Symbol as part of the message content. - VSCode docs about state: "The value must be JSON-stringifyable ... value — A value. MUST not contain cyclic references." - For now we'll store the conversation history in memory, and if we need to store in state directly we'd need to do a manual conversion to ensure proper json stringification. - */ - - // getApiConversationHistory(): Anthropic.MessageParam[] { - // // const history = (await this.getGlobalState( - // // this.getApiConversationHistoryStateKey() - // // )) as Anthropic.MessageParam[] - // // return history || [] - // return this.apiConversationHistory - // } - - // setApiConversationHistory(history: Anthropic.MessageParam[] | undefined) { - // // await this.updateGlobalState(this.getApiConversationHistoryStateKey(), history) - // this.apiConversationHistory = history || [] - // } - - // addMessageToApiConversationHistory(message: Anthropic.MessageParam): Anthropic.MessageParam[] { - // // const history = await this.getApiConversationHistory() - // // history.push(message) - // // await this.setApiConversationHistory(history) - // // return history - // this.apiConversationHistory.push(message) - // return this.apiConversationHistory - // } - - /* - Storage - https://dev.to/kompotkot/how-to-use-secretstorage-in-your-vscode-extensions-2hco - https://www.eliostruyf.com/devhack-code-extension-storage-options/ - */ + /** + * Storage + * https://dev.to/kompotkot/how-to-use-secretstorage-in-your-vscode-extensions-2hco + * https://www.eliostruyf.com/devhack-code-extension-storage-options/ + */ 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)) + const stateValues = this.contextProxy.getValues() - // Add promise for custom modes which is handled separately - const customModesPromise = this.customModesManager.getCustomModes() + const customModes = await this.customModesManager.getCustomModes() - let idx = 0 - const valuePromises = await Promise.all([...statePromises, ...secretPromises, customModesPromise]) + // Determine apiProvider with the same logic as before. + const apiProvider: ApiProvider = stateValues.apiProvider ? stateValues.apiProvider : "anthropic" - // 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 - }) - - let customModes = valuePromises[idx] as ModeConfig[] | undefined - - // Determine apiProvider with the same logic as before - let apiProvider: ApiProvider - if (stateValues.apiProvider) { - apiProvider = stateValues.apiProvider - } else { - 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, @@ -2803,6 +2732,7 @@ export class ClineProvider extends EventEmitter implements rateLimitSeconds: stateValues.rateLimitSeconds ?? 0, currentApiConfigName: stateValues.currentApiConfigName ?? "default", listApiConfigMeta: stateValues.listApiConfigMeta ?? [], + pinnedApiConfigs: stateValues.pinnedApiConfigs ?? {}, modeApiConfigs: stateValues.modeApiConfigs ?? ({} as Record), customModePrompts: stateValues.customModePrompts ?? {}, customSupportPrompts: stateValues.customSupportPrompts ?? {}, @@ -2821,7 +2751,7 @@ export class ClineProvider extends EventEmitter implements } async updateTaskHistory(item: HistoryItem): Promise { - const history = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || [] + const history = (this.getGlobalState("taskHistory") as HistoryItem[] | undefined) || [] const existingItemIndex = history.findIndex((h) => h.id === item.id) if (existingItemIndex !== -1) { @@ -2829,36 +2759,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: K) { + 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() { @@ -2873,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 0c1e7d9d42..54da8296eb 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -5,18 +5,16 @@ 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") // Mock ContextProxy -jest.mock("../../contextProxy", () => { +jest.mock("../../config/ContextProxy", () => { return { ContextProxy: jest.fn().mockImplementation((context) => ({ originalContext: context, @@ -649,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" }), @@ -661,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") }) @@ -670,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() @@ -691,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() @@ -715,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", @@ -741,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 () => { @@ -973,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 @@ -985,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 () => { @@ -1619,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() @@ -1636,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 @@ -1645,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() @@ -1667,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" })) @@ -1680,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: [ @@ -1750,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() @@ -1785,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() @@ -1806,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", [ @@ -1827,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() @@ -1870,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() @@ -1891,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", [ @@ -2105,19 +2094,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 775f579889..4af32695fd 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,395 @@ 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[] + pinnedApiConfigs?: Record + + 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 + * + * 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 = + | { + 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,103 +554,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" - | "pinnedApiConfigs" // Record of API config names that should be pinned to the top of the API provides dropdown - -export type ConfigurationKey = GlobalStateKey | SecretKey - -export type ConfigurationValues = Record +export type GlobalStateKey = keyof GlobalState 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/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index fc1dc6f833..f3bceaabb8 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 @@ -103,74 +104,96 @@ export interface ExtensionMessage { error?: string } -export interface ApiConfigMeta { - id: string - name: string - apiProvider?: ApiProvider -} - -export interface ExtensionState { +export type ExtensionState = Pick< + GlobalSettings, + | "currentApiConfigName" + | "listApiConfigMeta" + | "pinnedApiConfigs" + // | "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" - pinnedApiConfigs?: Record // Map of API config names to pinned state - 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 7209eabec8..76f1667558 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -37,6 +37,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 fa8b6f864d..b0931031a2 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -1,145 +1,695 @@ -import type { SecretKey, GlobalStateKey, ConfigurationKey, ConfigurationValues } from "../exports/roo-code" - -export type { SecretKey, GlobalStateKey, ConfigurationKey, ConfigurationValues } - -/** - * 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. - */ - -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", - "pinnedApiConfigs", -] as const - -export const PASS_THROUGH_STATE_KEYS = ["taskHistory"] as const - -type CheckGlobalStateKeysExhaustiveness = - Exclude extends never ? true : false - -const _checkGlobalStateKeysExhaustiveness: CheckGlobalStateKeysExhaustiveness = true - -export const isSecretKey = (key: string): key is SecretKey => SECRET_KEYS.includes(key as SecretKey) - -export const isGlobalStateKey = (key: string): key is GlobalStateKey => - GLOBAL_STATE_KEYS.includes(key as GlobalStateKey) - -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]) +import { z } from "zod" + +import type { + ProviderName, + CheckpointStorage, + ToolGroup, + Language, + TelemetrySetting, + ProviderSettingsKey, + SecretStateKey, + GlobalStateKey, + ModelInfo, + ApiConfigMeta, + HistoryItem, + GroupEntry, + ModeConfig, + ExperimentId, + ProviderSettings, + GlobalSettings, +} from "../exports/roo-code" + +import { Keys, AssertEqual, Equals } from "../utils/type-fu" + +/** + * ProviderName + */ + +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, +} + +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 TOOL_GROUPS = Object.keys(toolGroups) as ToolGroup[] + +const toolGroupsEnum: [ToolGroup, ...ToolGroup[]] = [TOOL_GROUPS[0], ...TOOL_GROUPS.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, +} + +const LANGUAGES = Object.keys(languages) as Language[] + +const languagesEnum: [Language, ...Language[]] = [LANGUAGES[0], ...LANGUAGES.slice(1).map((p) => p)] + +export const isLanguage = (key: string): key is Language => LANGUAGES.includes(key as Language) + +/** + * TelemetrySetting + */ + +const telemetrySettings: Record = { + unset: true, + enabled: true, + disabled: true, +} + +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, + azureApiVersion: 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, + // 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 + */ + +const globalStateKeys: 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, + azureApiVersion: 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, + // 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, + + currentApiConfigName: true, + listApiConfigMeta: true, + pinnedApiConfigs: true, + + lastShownAnnouncementId: true, + customInstructions: true, + taskHistory: true, + + autoApprovalEnabled: true, + alwaysAllowReadOnly: true, + alwaysAllowReadOnlyOutsideWorkspace: true, + alwaysAllowWrite: true, + alwaysAllowWriteOutsideWorkspace: true, + writeDelayMs: true, + alwaysAllowBrowser: true, + alwaysApproveResubmit: true, + requestDelaySeconds: true, + alwaysAllowMcp: true, + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + alwaysAllowExecute: true, + allowedCommands: true, + + browserToolEnabled: true, + browserViewportSize: true, + screenshotQuality: true, + remoteBrowserEnabled: true, + remoteBrowserHost: true, + + enableCheckpoints: true, + checkpointStorage: true, + + ttsEnabled: true, + ttsSpeed: true, + soundEnabled: true, + soundVolume: true, + + maxOpenTabsContext: true, + maxWorkspaceFiles: true, + showRooIgnoredFiles: true, + maxReadFileLine: true, + + terminalOutputLineLimit: true, + terminalShellIntegrationTimeout: true, + + rateLimitSeconds: true, + diffEnabled: true, + fuzzyMatchThreshold: true, + experiments: true, + + language: true, + + telemetrySetting: true, + + mcpEnabled: true, + enableMcpServerCreation: true, + + mode: true, + modeApiConfigs: true, + customModes: true, + customModePrompts: true, + customSupportPrompts: true, + enhancementApiConfigId: true, +} + +export const GLOBAL_STATE_KEYS = Object.keys(globalStateKeys) 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(), +}) + +type _AssertApiConfigMeta = AssertEqual>> + +/** + * HistoryItem + */ + +const historyItemSchema = 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(), +}) + +type _AssertHistoryItem = AssertEqual>> + +/** + * GroupEntry + */ + +const groupEntrySchema = z.union([ + z.enum(toolGroupsEnum), + z + .tuple([ + z.enum(toolGroupsEnum), + z.object({ + fileRegex: z.string().optional(), + description: z.string().optional(), + }), + ]) + .readonly(), +]) + +type _AssertGroupEntry = AssertEqual>> + +/** + * ModeConfig + */ + +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(), +}) + +type _AssertModeConfig = AssertEqual>> + +/** + * ExperimentId + */ + +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 experimentsSchema is not +// equal to ExperimentId. +type _AssertExperiments = AssertEqual>>> + +/** + * GlobalSettings + */ + +export const globalSettingsSchema = z.object({ + currentApiConfigName: z.string().optional(), + listApiConfigMeta: z.array(apiConfigMetaSchema).optional(), + pinnedApiConfigs: z.record(z.string(), z.boolean()).optional(), + + lastShownAnnouncementId: z.string().optional(), + customInstructions: z.string().optional(), + taskHistory: z.array(historyItemSchema).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(), +}) + +// Throws a type error if the inferred type of the globalSettingsSchema is not +// equal to GlobalSettings. +type _AssertGlobalSettings = AssertEqual>> + +/** + * ProviderSettings + */ + +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 providerSettingsSchema is not +// equal to ProviderSettings. +type _AssertProviderSettings = AssertEqual>> 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..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,8 +25,10 @@ export const TOOL_DISPLAY_NAMES = { new_task: "create new task", } as const +export type { ToolGroup } + // 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 +50,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/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 d196ec037d..93857832fe 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 } 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,19 @@ export const About = ({ version, telemetrySetting, setTelemetrySetting, classNam />
-
-

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

- vscode.postMessage({ type: "resetState" })} - appearance="secondary" - className="shrink-0"> - - {t("settings:footer.reset.button")} - +
+ + +
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} />
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": {