Преглед изворни кода

Wrangle our settings-related types and add support for settings import / export (#1997)

* Strongly type our settings use composition where possible

* Update src/core/contextProxy.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update src/core/contextProxy.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Add comment, tweak function name

* Add changeset

* Clean up toolGroup types

* Comments tweak

* Remove unused type

* More type safety

* Improve import / export, add translations

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Chris Estreich пре 9 месеци
родитељ
комит
a414c7d028
64 измењених фајлова са 2668 додато и 1532 уклоњено
  1. 5 0
      .changeset/lucky-hairs-join.md
  2. 9 7
      src/core/Cline.ts
  3. 0 294
      src/core/config/ConfigManager.ts
  4. 232 0
      src/core/config/ContextProxy.ts
  5. 293 0
      src/core/config/ProviderSettingsManager.ts
  6. 54 49
      src/core/config/__tests__/ContextProxy.test.ts
  7. 47 99
      src/core/config/__tests__/ProviderSettingsManager.test.ts
  8. 391 0
      src/core/config/__tests__/importExport.test.ts
  9. 69 0
      src/core/config/importExport.ts
  10. 0 189
      src/core/contextProxy.ts
  11. 3 2
      src/core/prompts/sections/custom-instructions.ts
  12. 145 208
      src/core/webview/ClineProvider.ts
  13. 28 39
      src/core/webview/__tests__/ClineProvider.test.ts
  14. 2 3
      src/exports/api.ts
  15. 403 100
      src/exports/roo-code.d.ts
  16. 2 1
      src/i18n/locales/ca/common.json
  17. 2 1
      src/i18n/locales/de/common.json
  18. 2 1
      src/i18n/locales/en/common.json
  19. 2 1
      src/i18n/locales/es/common.json
  20. 2 1
      src/i18n/locales/fr/common.json
  21. 2 1
      src/i18n/locales/hi/common.json
  22. 2 1
      src/i18n/locales/it/common.json
  23. 2 1
      src/i18n/locales/ja/common.json
  24. 2 1
      src/i18n/locales/ko/common.json
  25. 2 1
      src/i18n/locales/pl/common.json
  26. 2 1
      src/i18n/locales/pt-BR/common.json
  27. 2 1
      src/i18n/locales/tr/common.json
  28. 2 1
      src/i18n/locales/vi/common.json
  29. 2 1
      src/i18n/locales/zh-CN/common.json
  30. 2 1
      src/i18n/locales/zh-TW/common.json
  31. 79 56
      src/shared/ExtensionMessage.ts
  32. 3 12
      src/shared/HistoryItem.ts
  33. 2 0
      src/shared/WebviewMessage.ts
  34. 4 3
      src/shared/__tests__/language.test.ts
  35. 1 1
      src/shared/__tests__/modes.test.ts
  36. 4 156
      src/shared/api.ts
  37. 9 7
      src/shared/checkExistApiConfig.ts
  38. 3 1
      src/shared/checkpoints.ts
  39. 19 29
      src/shared/experiments.ts
  40. 695 145
      src/shared/globalState.ts
  41. 12 4
      src/shared/language.ts
  42. 3 29
      src/shared/modes.ts
  43. 5 3
      src/shared/tool-groups.ts
  44. 7 0
      src/utils/type-fu.ts
  45. 14 4
      webview-ui/src/components/chat/ChatTextArea.tsx
  46. 17 18
      webview-ui/src/components/settings/About.tsx
  47. 2 2
      webview-ui/src/components/settings/LanguageSettings.tsx
  48. 14 11
      webview-ui/src/components/settings/SettingsView.tsx
  49. 4 1
      webview-ui/src/components/ui/select-dropdown.tsx
  50. 4 3
      webview-ui/src/i18n/locales/ca/settings.json
  51. 4 3
      webview-ui/src/i18n/locales/de/settings.json
  52. 4 3
      webview-ui/src/i18n/locales/en/settings.json
  53. 4 3
      webview-ui/src/i18n/locales/es/settings.json
  54. 4 3
      webview-ui/src/i18n/locales/fr/settings.json
  55. 4 3
      webview-ui/src/i18n/locales/hi/settings.json
  56. 4 3
      webview-ui/src/i18n/locales/it/settings.json
  57. 4 3
      webview-ui/src/i18n/locales/ja/settings.json
  58. 4 3
      webview-ui/src/i18n/locales/ko/settings.json
  59. 4 3
      webview-ui/src/i18n/locales/pl/settings.json
  60. 4 3
      webview-ui/src/i18n/locales/pt-BR/settings.json
  61. 4 3
      webview-ui/src/i18n/locales/tr/settings.json
  62. 4 3
      webview-ui/src/i18n/locales/vi/settings.json
  63. 4 3
      webview-ui/src/i18n/locales/zh-CN/settings.json
  64. 4 3
      webview-ui/src/i18n/locales/zh-TW/settings.json

+ 5 - 0
.changeset/lucky-hairs-join.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Wrangle our settings-related types and add support for settings import / export

+ 9 - 7
src/core/Cline.ts

@@ -994,7 +994,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 			}
 		}
 
-		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<ClineEvents> {
 								}
 
 								// 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<ClineEvents> {
 								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<ClineEvents> {
 	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<ClineEvents> {
 			?.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<ClineEvents> {
 			} 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,

+ 0 - 294
src/core/config/ConfigManager.ts

@@ -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<Record<Mode, string>>
-}
-
-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<T>(cb: () => Promise<T>) {
-		const next = this._lock.then(cb)
-		this._lock = next.catch(() => {}) as Promise<void>
-		return next
-	}
-	/**
-	 * Initialize config if it doesn't exist
-	 */
-	async initConfig(): Promise<void> {
-		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<ApiConfigMeta[]> {
-		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<void> {
-		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<ApiConfiguration> {
-		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<void> {
-		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<void> {
-		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<boolean> {
-		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<void> {
-		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<string | undefined> {
-		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<void> {
-		return await this.lock(async () => {
-			await this.context.secrets.delete(this.getConfigKey())
-		})
-	}
-
-	private async readConfig(): Promise<ApiConfigData> {
-		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<void> {
-		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}`)
-		}
-	}
-}

+ 232 - 0
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<K extends GlobalStateKey>(key: K): GlobalState[K]
+	getGlobalState<K extends GlobalStateKey>(key: K, defaultValue: GlobalState[K]): GlobalState[K]
+	getGlobalState<K extends GlobalStateKey>(key: K, defaultValue?: GlobalState[K]): GlobalState[K] {
+		if (isPassThroughStateKey(key)) {
+			const value = this.originalContext.globalState.get<GlobalState[K]>(key)
+			return value === undefined || value === null ? defaultValue : value
+		}
+
+		const value = this.stateCache[key]
+		return value !== undefined ? value : defaultValue
+	}
+
+	updateGlobalState<K extends GlobalStateKey>(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<K extends RooCodeSettingsKey>(key: K, value: RooCodeSettings[K]) {
+		return isSecretStateKey(key) ? this.storeSecret(key, value as string) : this.updateGlobalState(key, value)
+	}
+
+	public getValue<K extends RooCodeSettingsKey>(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<GlobalSettings | undefined> {
+		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()
+	}
+}

+ 293 - 0
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<typeof providerSettingsWithIdSchema>
+
+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<typeof providerProfilesSchema>
+
+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<T>(cb: () => Promise<T>) {
+		const next = this._lock.then(cb)
+		this._lock = next.catch(() => {}) as Promise<void>
+		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<ApiConfigMeta[]> {
+		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<ProviderProfiles> {
+		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}`)
+		}
+	}
+}

+ 54 - 49
src/core/__tests__/contextProxy.test.ts → 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 () => {

+ 47 - 99
src/core/config/__tests__/ConfigManager.test.ts → 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",
 			)
 		})
 	})

+ 391 - 0
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<ProviderSettingsManager>
+	let mockContextProxy: jest.Mocked<ContextProxy>
+
+	beforeEach(() => {
+		// Reset all mocks
+		jest.clearAllMocks()
+
+		// Setup providerSettingsManager mock
+		mockProviderSettingsManager = {
+			export: jest.fn(),
+			import: jest.fn(),
+			listConfig: jest.fn(),
+		} as unknown as jest.Mocked<ProviderSettingsManager>
+
+		// 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<ContextProxy>
+	})
+
+	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"))
+		})
+	})
+})

+ 69 - 0
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) {}
+}

+ 0 - 189
src/core/contextProxy.ts

@@ -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<GlobalStateKey, any>
-	private secretCache: Map<SecretKey, string | undefined>
-	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<T>(key: GlobalStateKey): T | undefined
-	getGlobalState<T>(key: GlobalStateKey, defaultValue: T): T
-	getGlobalState<T>(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<T>(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<ConfigurationValues>) {
-		const promises: Thenable<void>[] = []
-
-		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<ConfigurationValues>,
-			),
-			...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()
-	}
-}

+ 3 - 2
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<string> {
 	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.`,
 		)

+ 145 - 208
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<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> implements
 							}
 						})
 
-						this.configManager
+						this.providerSettingsManager
 							.listConfig()
 							.then(async (listApiConfig) => {
 								if (!listApiConfig) {
@@ -894,25 +899,27 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> implements
 								return
 							}
 
-							const existingPrompts = ((await this.getGlobalState("customSupportPrompts")) ||
-								{}) as Record<string, any>
-
-							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<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> 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<string, boolean> = { ...currentPinned }
 
-							// Toggle the pinned state
 							if (currentPinned[message.text]) {
 								delete updatedPinned[message.text]
 							} else {
@@ -1719,7 +1716,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> implements
 						}
 
 						const updatedExperiments = {
-							...((await this.getGlobalState("experiments")) ?? experimentDefault),
+							...(this.getGlobalState("experiments") ?? experimentDefault),
 							...message.values,
-						} as Record<ExperimentId, boolean>
+						}
 
 						await this.updateGlobalState("experiments", updatedExperiments)
 
@@ -2169,8 +2169,8 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> 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<ClineProviderEvents> implements
 			rateLimitSeconds,
 			currentApiConfigName,
 			listApiConfigMeta,
+			pinnedApiConfigs,
 			mode,
 			customModePrompts,
 			customSupportPrompts,
@@ -2641,6 +2645,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> 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<ClineProviderEvents> implements
 			language,
 			renderContext: this.renderContext,
 			maxReadFileLine: maxReadFileLine ?? 500,
-			pinnedApiConfigs:
-				((await this.getGlobalState("pinnedApiConfigs")) as Record<string, boolean>) ??
-				({} as Record<string, boolean>),
+			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<GlobalStateKey | SecretKey, any> = {} as Record<GlobalStateKey | SecretKey, any>
-		const secretValues: Record<SecretKey, any> = {} as Record<SecretKey, any>
-
-		// 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<ClineProviderEvents> implements
 			rateLimitSeconds: stateValues.rateLimitSeconds ?? 0,
 			currentApiConfigName: stateValues.currentApiConfigName ?? "default",
 			listApiConfigMeta: stateValues.listApiConfigMeta ?? [],
+			pinnedApiConfigs: stateValues.pinnedApiConfigs ?? {},
 			modeApiConfigs: stateValues.modeApiConfigs ?? ({} as Record<Mode, string>),
 			customModePrompts: stateValues.customModePrompts ?? {},
 			customSupportPrompts: stateValues.customSupportPrompts ?? {},
@@ -2821,7 +2751,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 	}
 
 	async updateTaskHistory(item: HistoryItem): Promise<HistoryItem[]> {
-		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<ClineProviderEvents> 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<K extends GlobalStateKey>(key: K, value: GlobalState[K]) {
+		await this.contextProxy.setValue(key, value)
 	}
 
-	public async getGlobalState(key: GlobalStateKey) {
-		return await this.contextProxy.getGlobalState(key)
+	public getGlobalState<K extends GlobalStateKey>(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<ConfigurationValues>) {
+	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<ClineProviderEvents> implements
 		}
 
 		await this.contextProxy.resetAllState()
-		await this.configManager.resetAllConfigs()
+		await this.providerSettingsManager.resetAllConfigs()
 		await this.customModesManager.resetCustomModes()
 		await this.removeClineFromStack()
 		await this.postStateToWebview()

+ 28 - 39
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")
 	})
 

+ 2 - 3
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<RooCodeEvents> implements RooCodeAPI {
@@ -78,8 +78,7 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
 		await this.provider.postMessageToWebview({ type: "invoke", invoke: "secondaryButtonClick" })
 	}
 
-	// TODO: Change this to `setApiConfiguration`.
-	public async setConfiguration(values: Partial<ConfigurationValues>) {
+	public async setConfiguration(values: RooCodeSettings) {
 		await this.provider.setValues(values)
 	}
 

+ 403 - 100
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<string, boolean>
+
+	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<ExperimentId, boolean> // Map of experiment IDs to their enabled state.
+
+	language?: Language
+
+	telemetrySetting?: TelemetrySetting
+
+	mcpEnabled?: boolean
+	enableMcpServerCreation?: boolean
+
+	mode?: string
+	modeApiConfigs?: Record<string, string>
+	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<RooCodeSettings, SecretStateKey>
 
-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<ConfigurationKey, any>
+export type GlobalStateKey = keyof GlobalState

+ 2 - 1
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í",

+ 2 - 1
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",

+ 2 - 1
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",

+ 2 - 1
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í",

+ 2 - 1
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",

+ 2 - 1
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": "हां",

+ 2 - 1
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ì",

+ 2 - 1
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": "はい",

+ 2 - 1
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": "예",

+ 2 - 1
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",

+ 2 - 1
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",

+ 2 - 1
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",

+ 2 - 1
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ó",

+ 2 - 1
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": "是",

+ 2 - 1
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": "是",

+ 79 - 56
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<ExperimentId, boolean> // Map of experiment IDs to their enabled state
+
 	mcpEnabled: boolean
 	enableMcpServerCreation: boolean
+
 	mode: Mode
-	modeApiConfigs?: Record<Mode, string>
-	enhancementApiConfigId?: string
-	experiments: Record<ExperimentId, boolean> // Map of experiment IDs to their enabled state
-	autoApprovalEnabled?: boolean
 	customModes: ModeConfig[]
 	toolRequirements?: Record<string, boolean> // 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<string, boolean> // 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 }

+ 3 - 12
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 }

+ 2 - 0
src/shared/WebviewMessage.ts

@@ -37,6 +37,8 @@ export interface WebviewMessage {
 		| "showTaskWithId"
 		| "deleteTaskWithId"
 		| "exportTaskWithId"
+		| "importSettings"
+		| "exportSettings"
 		| "resetState"
 		| "requestOllamaModels"
 		| "requestLmStudioModels"

+ 4 - 3
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", () => {

+ 1 - 1
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",

+ 4 - 156
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<ProviderSettings, "apiProvider" | "id">
 
-// 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

+ 9 - 7
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 = [

+ 3 - 1
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"

+ 19 - 29
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<string, ExperimentId>
 
-export type ExperimentKey = keyof typeof EXPERIMENT_IDS
-export type ExperimentId = valueof<typeof EXPERIMENT_IDS>
+type _AssertExperimentIds = AssertEqual<Equals<ExperimentId, Values<typeof EXPERIMENT_IDS>>>
 
-export interface ExperimentConfig {
+type ExperimentKey = Keys<typeof EXPERIMENT_IDS>
+
+interface ExperimentConfig {
 	enabled: boolean
 }
 
-type valueof<X> = X[keyof X]
-
 export const experimentConfigsMap: Record<ExperimentKey, ExperimentConfig> = {
-	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<ExperimentId, boolean>
 
 export const experiments = {
-	get: (id: ExperimentKey): ExperimentConfig | undefined => {
-		return experimentConfigsMap[id]
-	},
-	isEnabled: (experimentsConfig: Record<ExperimentId, boolean>, id: ExperimentId): boolean => {
-		return experimentsConfig[id] ?? experimentDefault[id]
-	},
+	get: (id: ExperimentKey): ExperimentConfig | undefined => experimentConfigsMap[id],
+	isEnabled: (experimentsConfig: Record<ExperimentId, boolean>, id: ExperimentId) =>
+		experimentsConfig[id] ?? experimentDefault[id],
 } as const
-
-// No longer needed as we use translation keys directly in the UI

+ 695 - 145
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<SecretKey, (typeof SECRET_KEYS)[number]> 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<GlobalStateKey, (typeof GLOBAL_STATE_KEYS)[number]> 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<ProviderName, true> = {
+	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<CheckpointStorage, true> = {
+	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<ToolGroup, true> = {
+	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<Language, true> = {
+	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<TelemetrySetting, true> = {
+	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<ProviderSettingsKey, true> = {
+	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<SecretStateKey, true> = {
+	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<GlobalStateKey, true> = {
+	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<Equals<ModelInfo, z.infer<typeof modelInfoSchema>>>
+
+/**
+ * ApiConfigMeta
+ */
+
+const apiConfigMetaSchema = z.object({
+	id: z.string(),
+	name: z.string(),
+	apiProvider: z.enum(providerNamesEnum).optional(),
+})
+
+type _AssertApiConfigMeta = AssertEqual<Equals<ApiConfigMeta, z.infer<typeof apiConfigMetaSchema>>>
+
+/**
+ * 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<Equals<HistoryItem, z.infer<typeof historyItemSchema>>>
+
+/**
+ * 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<Equals<GroupEntry, z.infer<typeof groupEntrySchema>>>
+
+/**
+ * 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<Equals<ModeConfig, z.infer<typeof modeConfigSchema>>>
+
+/**
+ * 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<Equals<ExperimentId, Keys<z.infer<typeof experimentsSchema>>>>
+
+/**
+ * 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<Equals<GlobalSettings, z.infer<typeof globalSettingsSchema>>>
+
+/**
+ * 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<Equals<ProviderSettings, z.infer<typeof providerSettingsSchema>>>

+ 12 - 4
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<string, string> = {
+
+export const LANGUAGES: Record<Language, string> = {
 	ca: "Català",
 	de: "Deutsch",
 	en: "English",
@@ -26,10 +32,12 @@ export const LANGUAGES: Record<string, string> = {
  * @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"
 }

+ 3 - 29
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 {

+ 5 - 3
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<string, ToolGroupConfig> = {
+export const TOOL_GROUPS: Record<ToolGroup, ToolGroupConfig> = {
 	read: {
 		tools: ["read_file", "fetch_instructions", "search_files", "list_files", "list_code_definition_names"],
 	},
@@ -46,8 +50,6 @@ export const TOOL_GROUPS: Record<string, ToolGroupConfig> = {
 	},
 }
 
-export type ToolGroup = keyof typeof TOOL_GROUPS
-
 // Tools that are always available to all modes
 export const ALWAYS_AVAILABLE_TOOLS = [
 	"ask_followup_question",

+ 7 - 0
src/utils/type-fu.ts

@@ -0,0 +1,7 @@
+export type Keys<T> = keyof T
+
+export type Values<T> = T[keyof T]
+
+export type Equals<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false
+
+export type AssertEqual<T extends true> = T

+ 14 - 4
webview-ui/src/components/chat/ChatTextArea.tsx

@@ -1033,6 +1033,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 								}}
 								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<HTMLTextAreaElement, ChatTextAreaProps>(
 									const isCurrentConfig = config?.name === currentApiConfigName
 
 									return (
-										<div className="flex items-center justify-between gap-2 w-full">
+										<div className="flex justify-between gap-2 w-full h-5">
 											<div className={cn({ "font-medium": isCurrentConfig })}>{label}</div>
-											<div className="flex items-center gap-1">
+											<div className="flex justify-end w-10">
+												<div
+													className={cn("size-5 p-1", {
+														"block group-hover:hidden": !pinned,
+														hidden: !isCurrentConfig,
+													})}>
+													<Check className="size-3" />
+												</div>
 												<Button
 													variant="ghost"
 													size="icon"
@@ -1054,10 +1062,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 														togglePinnedApiConfig(value)
 														vscode.postMessage({ type: "toggleApiConfigPin", text: value })
 													}}
-													className={cn("w-5 h-5", { "bg-accent": pinned })}>
+													className={cn("size-5", {
+														"hidden group-hover:flex": !pinned,
+														"bg-accent": pinned,
+													})}>
 													<Pin className="size-3 p-0.5 opacity-50" />
 												</Button>
-												{isCurrentConfig && <Check className="size-3" />}
 											</div>
 										</div>
 									)

+ 17 - 18
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
 			<Section>
 				<div>
 					<VSCodeCheckbox
-						style={{ marginBottom: "5px" }}
 						checked={telemetrySetting === "enabled"}
 						onChange={(e: any) => {
 							const checked = e.target.checked === true
@@ -42,12 +42,7 @@ export const About = ({ version, telemetrySetting, setTelemetrySetting, classNam
 						}}>
 						{t("settings:footer.telemetry.label")}
 					</VSCodeCheckbox>
-					<p
-						style={{
-							fontSize: "12px",
-							marginTop: "5px",
-							color: "var(--vscode-descriptionForeground)",
-						}}>
+					<p className="text-vscode-descriptionForeground text-sm mt-0">
 						{t("settings:footer.telemetry.description")}
 					</p>
 				</div>
@@ -63,15 +58,19 @@ export const About = ({ version, telemetrySetting, setTelemetrySetting, classNam
 					/>
 				</div>
 
-				<div className="flex justify-between items-center gap-3">
-					<p>{t("settings:footer.reset.description")}</p>
-					<VSCodeButton
-						onClick={() => vscode.postMessage({ type: "resetState" })}
-						appearance="secondary"
-						className="shrink-0">
-						<span className="codicon codicon-warning text-vscode-errorForeground mr-1" />
-						{t("settings:footer.reset.button")}
-					</VSCodeButton>
+				<div className="flex items-center gap-2 mt-2">
+					<Button onClick={() => vscode.postMessage({ type: "exportSettings" })}>
+						<Upload className="p-0.5" />
+						{t("settings:footer.settings.export")}
+					</Button>
+					<Button onClick={() => vscode.postMessage({ type: "importSettings" })}>
+						<Download className="p-0.5" />
+						{t("settings:footer.settings.import")}
+					</Button>
+					<Button variant="destructive" onClick={() => vscode.postMessage({ type: "resetState" })}>
+						<TriangleAlert className="p-0.5" />
+						{t("settings:footer.settings.reset")}
+					</Button>
 				</div>
 			</Section>
 		</div>

+ 2 - 2
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, ...
 			</SectionHeader>
 
 			<Section>
-				<Select value={language} onValueChange={(value) => setCachedStateField("language", value)}>
+				<Select value={language} onValueChange={(value) => setCachedStateField("language", value as Language)}>
 					<SelectTrigger className="w-full">
 						<SelectValue placeholder={t("settings:common.select")} />
 					</SelectTrigger>

+ 14 - 11
webview-ui/src/components/settings/SettingsView.tsx

@@ -85,7 +85,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ 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<SettingsViewRef, SettingsViewProps>(({ 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<SettingsViewRef, SettingsViewProps>(({ 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<keyof ExtensionStateContextType> = useCallback((field, value) => {
 		setCachedState((prevState) => {
 			if (prevState[field] === value) {
@@ -182,11 +191,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 			}
 
 			setChangeDetected(true)
-
-			return {
-				...prevState,
-				experiments: { ...prevState.experiments, [id]: enabled },
-			}
+			return { ...prevState, experiments: { ...prevState.experiments, [id]: enabled } }
 		})
 	}, [])
 
@@ -195,11 +200,9 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 			if (prevState.telemetrySetting === setting) {
 				return prevState
 			}
+
 			setChangeDetected(true)
-			return {
-				...prevState,
-				telemetrySetting: setting,
-			}
+			return { ...prevState, telemetrySetting: setting }
 		})
 	}, [])
 
@@ -460,8 +463,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 						maxOpenTabsContext={maxOpenTabsContext}
 						maxWorkspaceFiles={maxWorkspaceFiles ?? 200}
 						showRooIgnoredFiles={showRooIgnoredFiles}
-						setCachedStateField={setCachedStateField}
 						maxReadFileLine={maxReadFileLine}
+						setCachedStateField={setCachedStateField}
 					/>
 				</div>
 

+ 4 - 1
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<React.ElementRef<typeof DropdownM
 			title = "",
 			triggerClassName = "",
 			contentClassName = "",
+			itemClassName = "",
 			sideOffset = 4,
 			align = "start",
 			placeholder = "",
@@ -125,7 +127,8 @@ export const SelectDropdown = React.forwardRef<React.ElementRef<typeof DropdownM
 							<DropdownMenuItem
 								key={`item-${option.value}`}
 								disabled={option.disabled}
-								onClick={() => handleSelect(option)}>
+								onClick={() => handleSelect(option)}
+								className={itemClassName}>
 								{renderItem ? (
 									renderItem(option)
 								) : (

+ 4 - 3
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": {

+ 4 - 3
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": {

+ 4 - 3
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": {

+ 4 - 3
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": {

+ 4 - 3
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": {

+ 4 - 3
webview-ui/src/i18n/locales/hi/settings.json

@@ -381,9 +381,10 @@
 			"label": "गुमनाम त्रुटि और उपयोग रिपोर्टिंग की अनुमति दें",
 			"description": "गुमनाम उपयोग डेटा और त्रुटि रिपोर्ट भेजकर Roo Code को बेहतर बनाने में मदद करें। कोड, प्रॉम्प्ट, या व्यक्तिगत जानकारी कभी भी नहीं भेजी जाती है। अधिक विवरण के लिए हमारी गोपनीयता नीति देखें।"
 		},
-		"reset": {
-			"description": "एक्सटेंशन में सभी वैश्विक स्थिति और गुप्त भंडारण को रीसेट करें।",
-			"button": "रीसेट करें"
+		"settings": {
+			"import": "इम्पोर्ट",
+			"export": "एक्सपोर्ट",
+			"reset": "रीसेट करें"
 		}
 	},
 	"thinkingBudget": {

+ 4 - 3
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": {

+ 4 - 3
webview-ui/src/i18n/locales/ja/settings.json

@@ -381,9 +381,10 @@
 			"label": "匿名のエラーと使用状況レポートを許可",
 			"description": "匿名の使用データとエラーレポートを送信してRoo Codeの改善にご協力ください。コード、プロンプト、個人情報が送信されることはありません。詳細については、プライバシーポリシーをご覧ください。"
 		},
-		"reset": {
-			"description": "拡張機能内のすべてのグローバル状態とシークレットストレージをリセットします。",
-			"button": "リセット"
+		"settings": {
+			"import": "インポート",
+			"export": "エクスポート",
+			"reset": "リセット"
 		}
 	},
 	"thinkingBudget": {

+ 4 - 3
webview-ui/src/i18n/locales/ko/settings.json

@@ -381,9 +381,10 @@
 			"label": "익명 오류 및 사용 보고 허용",
 			"description": "익명 사용 데이터 및 오류 보고서를 보내 Roo Code 개선에 도움을 주세요. 코드, 프롬프트 또는 개인 정보는 절대 전송되지 않습니다. 자세한 내용은 개인정보 보호정책을 참조하세요."
 		},
-		"reset": {
-			"description": "확장 프로그램의 모든 전역 상태 및 보안 저장소를 재설정합니다.",
-			"button": "초기화"
+		"settings": {
+			"import": "가져오기",
+			"export": "내보내기",
+			"reset": "초기화"
 		}
 	},
 	"thinkingBudget": {

+ 4 - 3
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": {

+ 4 - 3
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": {

+ 4 - 3
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": {

+ 4 - 3
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": {

+ 4 - 3
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": {

+ 4 - 3
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": {