Просмотр исходного кода

Merge remote-tracking branch 'origin/main' into feature/add_sse_mcp

Matt Rubens 9 месяцев назад
Родитель
Сommit
cad274176c
78 измененных файлов с 2692 добавлено и 1356 удалено
  1. 5 0
      .changeset/seven-apricots-watch.md
  2. 5 0
      .changeset/thin-fans-deliver.md
  3. 2 2
      .github/workflows/code-qa.yml
  4. 3 1
      .gitignore
  5. 29 0
      .roomodes
  6. 0 0
      e2e/.env.local.sample
  7. 11 5
      e2e/VSCODE_INTEGRATION_TESTS.md
  8. 5 4
      e2e/package.json
  9. 19 53
      e2e/src/suite/index.ts
  10. 26 68
      e2e/src/suite/modes.test.ts
  11. 58 0
      e2e/src/suite/subtasks.test.ts
  12. 5 156
      e2e/src/suite/task.test.ts
  13. 76 0
      e2e/src/suite/utils.ts
  14. 2 4
      knip.json
  15. 0 1
      package-lock.json
  16. 9 0
      src/__mocks__/vscode.js
  17. 23 18
      src/activate/createRooCodeAPI.ts
  18. 51 4
      src/api/providers/openai.ts
  19. 25 32
      src/api/providers/openrouter.ts
  20. 2 4
      src/core/Cline.ts
  21. 24 35
      src/core/__tests__/contextProxy.test.ts
  22. 69 64
      src/core/contextProxy.ts
  23. 1144 0
      src/core/prompts/__tests__/__snapshots__/system.test.ts.snap
  24. 33 40
      src/core/prompts/__tests__/custom-system-prompt.test.ts
  25. 4 4
      src/core/prompts/__tests__/sections.test.ts
  26. 154 114
      src/core/prompts/__tests__/system.test.ts
  27. 2 2
      src/core/prompts/sections/__tests__/custom-instructions.test.ts
  28. 4 3
      src/core/prompts/sections/custom-instructions.ts
  29. 11 5
      src/core/prompts/system.ts
  30. 28 45
      src/core/webview/ClineProvider.ts
  31. 6 13
      src/core/webview/__tests__/ClineProvider.test.ts
  32. 0 7
      src/exports/README.md
  33. 120 95
      src/exports/roo-code.d.ts
  34. 2 2
      src/shared/ExtensionMessage.ts
  35. 0 1
      src/shared/WebviewMessage.ts
  36. 19 0
      src/shared/__tests__/language.test.ts
  37. 2 3
      src/shared/__tests__/modes.test.ts
  38. 8 0
      src/shared/api.ts
  39. 29 10
      src/shared/globalState.ts
  40. 14 0
      src/shared/language.ts
  41. 7 4
      src/shared/modes.ts
  42. 2 0
      webview-ui/.gitignore
  43. 7 1
      webview-ui/jest.config.cjs
  44. 130 333
      webview-ui/package-lock.json
  45. 4 1
      webview-ui/package.json
  46. 4 2
      webview-ui/src/App.tsx
  47. 47 0
      webview-ui/src/__mocks__/i18n/TranslationContext.tsx
  48. 62 0
      webview-ui/src/__mocks__/i18n/setup.ts
  49. 4 1
      webview-ui/src/components/chat/ChatView.tsx
  50. 79 211
      webview-ui/src/components/prompts/PromptsView.tsx
  51. 112 2
      webview-ui/src/components/settings/ApiOptions.tsx
  52. 1 1
      webview-ui/src/components/ui/combobox-primitive.tsx
  53. 1 4
      webview-ui/src/context/ExtensionStateContext.tsx
  54. 0 1
      webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx
  55. 57 0
      webview-ui/src/i18n/TranslationContext.tsx
  56. 52 0
      webview-ui/src/i18n/__tests__/TranslationContext.test.tsx
  57. 0 0
      webview-ui/src/i18n/locales/ar/.gitkeep
  58. 0 0
      webview-ui/src/i18n/locales/ca/.gitkeep
  59. 0 0
      webview-ui/src/i18n/locales/cs/.gitkeep
  60. 0 0
      webview-ui/src/i18n/locales/de/.gitkeep
  61. 0 0
      webview-ui/src/i18n/locales/en/.gitkeep
  62. 3 0
      webview-ui/src/i18n/locales/en/chat.json
  63. 0 0
      webview-ui/src/i18n/locales/es/.gitkeep
  64. 0 0
      webview-ui/src/i18n/locales/fr/.gitkeep
  65. 0 0
      webview-ui/src/i18n/locales/hi/.gitkeep
  66. 0 0
      webview-ui/src/i18n/locales/hu/.gitkeep
  67. 0 0
      webview-ui/src/i18n/locales/it/.gitkeep
  68. 0 0
      webview-ui/src/i18n/locales/ja/.gitkeep
  69. 0 0
      webview-ui/src/i18n/locales/ko/.gitkeep
  70. 0 0
      webview-ui/src/i18n/locales/pl/.gitkeep
  71. 0 0
      webview-ui/src/i18n/locales/pt-BR/.gitkeep
  72. 0 0
      webview-ui/src/i18n/locales/pt/.gitkeep
  73. 0 0
      webview-ui/src/i18n/locales/ru/.gitkeep
  74. 0 0
      webview-ui/src/i18n/locales/tr/.gitkeep
  75. 0 0
      webview-ui/src/i18n/locales/zh-CN/.gitkeep
  76. 0 0
      webview-ui/src/i18n/locales/zh-TW/.gitkeep
  77. 54 0
      webview-ui/src/i18n/setup.ts
  78. 37 0
      webview-ui/src/i18n/test-utils.ts

+ 5 - 0
.changeset/seven-apricots-watch.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+ContextProxy fix - constructor should not be async

+ 5 - 0
.changeset/thin-fans-deliver.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Add prompt caching to OpenAI-compatible custom model info

+ 2 - 2
.github/workflows/code-qa.yml

@@ -110,9 +110,9 @@ jobs:
           cache: 'npm'
       - name: Install dependencies
         run: npm run install:all
-      - name: Create env.integration file
+      - name: Create .env.local file
         working-directory: e2e
-        run: echo "OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}" > .env.integration
+        run: echo "OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}" > .env.local
       - name: Run integration tests
         working-directory: e2e
         run: xvfb-run -a npm run ci

+ 3 - 1
.gitignore

@@ -22,7 +22,9 @@ docs/_site/
 
 # Dotenv
 .env
-.env.integration
+.env.*
+!.env.*.sample
+
 
 #Local lint config
 .eslintrc.local.json

+ 29 - 0
.roomodes

@@ -0,0 +1,29 @@
+{
+  "customModes": [
+    {
+      "slug": "translate",
+      "name": "Translate",
+      "roleDefinition": "You are Roo, a linguistic specialist focused on translating and managing localization files. Your responsibility is to help maintain and update translation files for the application, ensuring consistency and accuracy across all language resources.",
+      "groups": [
+        "read",
+        ["edit", { "fileRegex": "src/i18n/locales/", "description": "Translation files only" }]
+      ],
+      "customInstructions": "When translating content:\n- Maintain consistent terminology across all translations\n- Respect the JSON structure of translation files\n- Consider context when translating UI strings\n- Watch for placeholders (like {{variable}}) and preserve them in translations\n- Be mindful of text length in UI elements when translating to languages that might require more characters\n- If you need context for a translation, use read_file to examine the components using these strings"
+    },
+    {
+      "slug": "test",
+      "name": "Test",
+      "roleDefinition": "You are Roo, a Jest testing specialist with deep expertise in:\n- Writing and maintaining Jest test suites\n- Test-driven development (TDD) practices\n- Mocking and stubbing with Jest\n- Integration testing strategies\n- TypeScript testing patterns\n- Code coverage analysis\n- Test performance optimization\n\nYour focus is on maintaining high test quality and coverage across the codebase, working primarily with:\n- Test files in __tests__ directories\n- Mock implementations in __mocks__\n- Test utilities and helpers\n- Jest configuration and setup\n\nYou ensure tests are:\n- Well-structured and maintainable\n- Following Jest best practices\n- Properly typed with TypeScript\n- Providing meaningful coverage\n- Using appropriate mocking strategies",
+      "groups": [
+        "read",
+        "browser",
+        "command",
+        ["edit", {
+          "fileRegex": "(__tests__/.*|__mocks__/.*|\\.test\\.(ts|tsx|js|jsx)$|/test/.*|jest\\.config\\.(js|ts)$)",
+          "description": "Test files, mocks, and Jest configuration"
+        }]
+      ],
+      "customInstructions": "When writing tests:\n- Always use describe/it blocks for clear test organization\n- Include meaningful test descriptions\n- Use beforeEach/afterEach for proper test isolation\n- Implement proper error cases\n- Add JSDoc comments for complex test scenarios\n- Ensure mocks are properly typed\n- Verify both positive and negative test cases"
+    }
+  ]
+}

+ 0 - 0
e2e/.env.integration.example → e2e/.env.local.sample


+ 11 - 5
e2e/VSCODE_INTEGRATION_TESTS.md

@@ -30,7 +30,7 @@ The test runner (`runTest.ts`) is responsible for:
 
 ### Environment Setup
 
-1. Create a `.env.integration` file in the root directory with required environment variables:
+1. Create a `.env.local` file in the root directory with required environment variables:
 
 ```
 OPENROUTER_API_KEY=sk-or-v1-...
@@ -67,7 +67,7 @@ declare global {
 
 ## Running Tests
 
-1. Ensure you have the required environment variables set in `.env.integration`
+1. Ensure you have the required environment variables set in `.env.local`
 
 2. Run the integration tests:
 
@@ -117,8 +117,10 @@ const interval = 1000
 2. **State Management**: Reset extension state before/after tests:
 
 ```typescript
-await globalThis.provider.updateGlobalState("mode", "Ask")
-await globalThis.provider.updateGlobalState("alwaysAllowModeSwitch", true)
+await globalThis.api.setConfiguration({
+	mode: "Ask",
+	alwaysAllowModeSwitch: true,
+})
 ```
 
 3. **Assertions**: Use clear assertions with meaningful messages:
@@ -141,8 +143,12 @@ try {
 
 ```typescript
 let startTime = Date.now()
+
 while (Date.now() - startTime < timeout) {
-	if (condition) break
+	if (condition) {
+		break
+	}
+
 	await new Promise((resolve) => setTimeout(resolve, interval))
 }
 ```

+ 5 - 4
e2e/package.json

@@ -3,12 +3,13 @@
 	"version": "0.1.0",
 	"private": true,
 	"scripts": {
-		"build": "cd .. && npm run build",
-		"compile": "tsc -p tsconfig.json",
+		"build": "cd .. && npm run compile && npm run build:webview",
+		"compile": "rm -rf out && tsc -p tsconfig.json",
 		"lint": "eslint src --ext ts",
 		"check-types": "tsc --noEmit",
-		"test": "npm run compile && npx dotenvx run -f .env.integration -- node ./out/runTest.js",
-		"ci": "npm run build && npm run test"
+		"test": "npm run compile && npx dotenvx run -f .env.local -- node ./out/runTest.js",
+		"ci": "npm run build && npm run test",
+		"clean": "rimraf out"
 	},
 	"dependencies": {},
 	"devDependencies": {

+ 19 - 53
e2e/src/suite/index.ts

@@ -1,80 +1,46 @@
 import * as path from "path"
 import Mocha from "mocha"
 import { glob } from "glob"
-import { RooCodeAPI, ClineProvider } from "../../../src/exports/roo-code"
 import * as vscode from "vscode"
 
+import { RooCodeAPI } from "../../../src/exports/roo-code"
+
+import { waitUntilReady } from "./utils"
+
 declare global {
-	var api: RooCodeAPI
-	var provider: ClineProvider
 	var extension: vscode.Extension<RooCodeAPI> | undefined
-	var panel: vscode.WebviewPanel | undefined
+	var api: RooCodeAPI
 }
 
-export async function run(): Promise<void> {
-	const mocha = new Mocha({
-		ui: "tdd",
-		timeout: 600000, // 10 minutes to compensate for time communicating with LLM while running in GHA.
-	})
-
+export async function run() {
+	const mocha = new Mocha({ ui: "tdd", timeout: 300_000 })
 	const testsRoot = path.resolve(__dirname, "..")
 
 	try {
 		// Find all test files.
 		const files = await glob("**/**.test.js", { cwd: testsRoot })
-
-		// Add files to the test suite.
 		files.forEach((f: string) => mocha.addFile(path.resolve(testsRoot, f)))
 
-		// Set up global extension, api, provider, and panel.
-		globalThis.extension = vscode.extensions.getExtension("RooVeterinaryInc.roo-cline")
+		const extension = vscode.extensions.getExtension<RooCodeAPI>("RooVeterinaryInc.roo-cline")
 
-		if (!globalThis.extension) {
+		if (!extension) {
 			throw new Error("Extension not found")
 		}
 
-		globalThis.api = globalThis.extension.isActive
-			? globalThis.extension.exports
-			: await globalThis.extension.activate()
-
-		globalThis.provider = globalThis.api.sidebarProvider
+		const api = extension.isActive ? extension.exports : await extension.activate()
 
-		await globalThis.provider.updateGlobalState("apiProvider", "openrouter")
-		await globalThis.provider.updateGlobalState("openRouterModelId", "anthropic/claude-3.5-sonnet")
-
-		await globalThis.provider.storeSecret(
-			"openRouterApiKey",
-			process.env.OPENROUTER_API_KEY || "sk-or-v1-fake-api-key",
-		)
-
-		globalThis.panel = vscode.window.createWebviewPanel(
-			"roo-cline.SidebarProvider",
-			"Roo Code",
-			vscode.ViewColumn.One,
-			{
-				enableScripts: true,
-				enableCommandUris: true,
-				retainContextWhenHidden: true,
-				localResourceRoots: [globalThis.extension?.extensionUri],
-			},
-		)
-
-		await globalThis.provider.resolveWebviewView(globalThis.panel)
-
-		let startTime = Date.now()
-		const timeout = 60000
-		const interval = 1000
+		await api.setConfiguration({
+			apiProvider: "openrouter",
+			openRouterApiKey: process.env.OPENROUTER_API_KEY!,
+			openRouterModelId: "anthropic/claude-3.5-sonnet",
+		})
 
-		while (Date.now() - startTime < timeout) {
-			if (globalThis.provider.viewLaunched) {
-				break
-			}
+		await waitUntilReady(api)
 
-			await new Promise((resolve) => setTimeout(resolve, interval))
-		}
+		globalThis.api = api
+		globalThis.extension = extension
 
-		// Run the mocha test.
-		return new Promise((resolve, reject) => {
+		return new Promise<void>((resolve, reject) => {
 			try {
 				mocha.run((failures: number) => {
 					if (failures > 0) {

+ 26 - 68
e2e/src/suite/modes.test.ts

@@ -1,102 +1,60 @@
 import * as assert from "assert"
 
+import { waitForMessage } from "./utils"
+
 suite("Roo Code Modes", () => {
 	test("Should handle switching modes correctly", async function () {
-		const timeout = 30000
-		const interval = 1000
+		const timeout = 300_000
+		const api = globalThis.api
 
 		const testPrompt =
-			"For each mode (Code, Architect, Ask) respond with the mode name and what it specializes in after switching to that mode, do not start with the current mode, be sure to say 'I AM DONE' after the task is complete"
-
-		if (!globalThis.extension) {
-			assert.fail("Extension not found")
-		}
-
-		let startTime = Date.now()
-
-		// Ensure the webview is launched.
-		while (Date.now() - startTime < timeout) {
-			if (globalThis.provider.viewLaunched) {
-				break
-			}
-
-			await new Promise((resolve) => setTimeout(resolve, interval))
-		}
-
-		await globalThis.provider.updateGlobalState("mode", "Ask")
-		await globalThis.provider.updateGlobalState("alwaysAllowModeSwitch", true)
-		await globalThis.provider.updateGlobalState("autoApprovalEnabled", true)
-
-		// Start a new task.
-		await globalThis.api.startNewTask(testPrompt)
+			"For each mode (Code, Architect, Ask) respond with the mode name and what it specializes in after switching to that mode, do not start with the current mode, be sure to say 'I AM DONE' after the task is complete."
 
-		// Wait for task to appear in history with tokens.
-		startTime = Date.now()
+		await api.setConfiguration({ mode: "Code", alwaysAllowModeSwitch: true, autoApprovalEnabled: true })
+		await api.startNewTask(testPrompt)
 
-		while (Date.now() - startTime < timeout) {
-			const messages = globalThis.provider.messages
+		await waitForMessage(api, { include: "I AM DONE", exclude: "be sure to say", timeout })
 
-			if (
-				messages.some(
-					({ type, text }) =>
-						type === "say" && text?.includes("I AM DONE") && !text?.includes("be sure to say"),
-				)
-			) {
-				break
-			}
-
-			await new Promise((resolve) => setTimeout(resolve, interval))
-		}
-
-		if (globalThis.provider.messages.length === 0) {
+		if (api.getMessages().length === 0) {
 			assert.fail("No messages received")
 		}
 
 		// Log the messages to the console.
-		globalThis.provider.messages.forEach(({ type, text }) => {
+		api.getMessages().forEach(({ type, text }) => {
 			if (type === "say") {
 				console.log(text)
 			}
 		})
 
 		// Start Grading Portion of test to grade the response from 1 to 10.
-		await globalThis.provider.updateGlobalState("mode", "Ask")
-		let output = globalThis.provider.messages.map(({ type, text }) => (type === "say" ? text : "")).join("\n")
-
-		await globalThis.api.startNewTask(
-			`Given this prompt: ${testPrompt} grade the response from 1 to 10 in the format of "Grade: (1-10)": ${output} \n Be sure to say 'I AM DONE GRADING' after the task is complete`,
-		)
-
-		startTime = Date.now()
+		await api.setConfiguration({ mode: "Ask" })
 
-		while (Date.now() - startTime < timeout) {
-			const messages = globalThis.provider.messages
+		let output = api
+			.getMessages()
+			.map(({ type, text }) => (type === "say" ? text : ""))
+			.join("\n")
 
-			if (
-				messages.some(
-					({ type, text }) =>
-						type === "say" && text?.includes("I AM DONE GRADING") && !text?.includes("be sure to say"),
-				)
-			) {
-				break
-			}
+		await api.startNewTask(
+			`Given this prompt: ${testPrompt} grade the response from 1 to 10 in the format of "Grade: (1-10)": ${output}\nBe sure to say 'I AM DONE GRADING' after the task is complete.`,
+		)
 
-			await new Promise((resolve) => setTimeout(resolve, interval))
-		}
+		await waitForMessage(api, { include: "I AM DONE GRADING", exclude: "be sure to say", timeout })
 
-		if (globalThis.provider.messages.length === 0) {
+		if (api.getMessages().length === 0) {
 			assert.fail("No messages received")
 		}
 
-		globalThis.provider.messages.forEach(({ type, text }) => {
+		api.getMessages().forEach(({ type, text }) => {
 			if (type === "say" && text?.includes("Grade:")) {
 				console.log(text)
 			}
 		})
 
-		const gradeMessage = globalThis.provider.messages.find(
-			({ type, text }) => type === "say" && !text?.includes("Grade: (1-10)") && text?.includes("Grade:"),
-		)?.text
+		const gradeMessage = api
+			.getMessages()
+			.find(
+				({ type, text }) => type === "say" && !text?.includes("Grade: (1-10)") && text?.includes("Grade:"),
+			)?.text
 
 		const gradeMatch = gradeMessage?.match(/Grade: (\d+)/)
 		const gradeNum = gradeMatch ? parseInt(gradeMatch[1]) : undefined

+ 58 - 0
e2e/src/suite/subtasks.test.ts

@@ -0,0 +1,58 @@
+import * as assert from "assert"
+
+import { sleep, waitForToolUse, waitForMessage } from "./utils"
+
+suite("Roo Code Subtasks", () => {
+	test.skip("Should handle subtask cancellation and resumption correctly", async function () {
+		const api = globalThis.api
+
+		await api.setConfiguration({
+			mode: "Code",
+			alwaysAllowModeSwitch: true,
+			alwaysAllowSubtasks: true,
+			autoApprovalEnabled: true,
+		})
+
+		// Start a parent task that will create a subtask.
+		await api.startNewTask(
+			"You are the parent task. " +
+				"Create a subtask by using the new_task tool with the message 'You are the subtask'. " +
+				"After creating the subtask, wait for it to complete and then respond with 'Parent task resumed'.",
+		)
+
+		await waitForToolUse(api, "new_task")
+
+		// Cancel the current task (which should be the subtask).
+		await api.cancelTask()
+
+		// Check if the parent task is still waiting (not resumed). We need to
+		// wait a bit to ensure any task resumption would have happened.
+		await sleep(5_000)
+
+		// The parent task should not have resumed yet, so we shouldn't see
+		// "Parent task resumed".
+		assert.ok(
+			!api.getMessages().some(({ type, text }) => type === "say" && text?.includes("Parent task resumed")),
+			"Parent task should not have resumed after subtask cancellation.",
+		)
+
+		// Start a new task with the same message as the subtask.
+		await api.startNewTask("You are the subtask")
+
+		// Wait for the subtask to complete.
+		await waitForMessage(api, { include: "Task complete" })
+
+		// Verify that the parent task is still not resumed. We need to wait a
+		// bit to ensure any task resumption would have happened.
+		await sleep(5_000)
+
+		// The parent task should still not have resumed.
+		assert.ok(
+			!api.getMessages().some(({ type, text }) => type === "say" && text?.includes("Parent task resumed")),
+			"Parent task should not have resumed after subtask completion.",
+		)
+
+		// Clean up - cancel all tasks.
+		await api.cancelTask()
+	})
+})

+ 5 - 156
e2e/src/suite/task.test.ts

@@ -1,161 +1,10 @@
-import * as assert from "assert"
-import * as vscode from "vscode"
+import { waitForMessage } from "./utils"
 
 suite("Roo Code Task", () => {
 	test("Should handle prompt and response correctly", async function () {
-		const timeout = 30000
-		const interval = 1000
-
-		if (!globalThis.extension) {
-			assert.fail("Extension not found")
-		}
-
-		// Ensure the webview is launched.
-		let startTime = Date.now()
-
-		while (Date.now() - startTime < timeout) {
-			if (globalThis.provider.viewLaunched) {
-				break
-			}
-
-			await new Promise((resolve) => setTimeout(resolve, interval))
-		}
-
-		await globalThis.provider.updateGlobalState("mode", "Code")
-		await globalThis.provider.updateGlobalState("alwaysAllowModeSwitch", true)
-		await globalThis.provider.updateGlobalState("autoApprovalEnabled", true)
-
-		await globalThis.api.startNewTask("Hello world, what is your name? Respond with 'My name is ...'")
-
-		// Wait for task to appear in history with tokens.
-		startTime = Date.now()
-
-		while (Date.now() - startTime < timeout) {
-			const messages = globalThis.provider.messages
-
-			if (messages.some(({ type, text }) => type === "say" && text?.includes("My name is Roo"))) {
-				break
-			}
-
-			await new Promise((resolve) => setTimeout(resolve, interval))
-		}
-
-		if (globalThis.provider.messages.length === 0) {
-			assert.fail("No messages received")
-		}
-
-		assert.ok(
-			globalThis.provider.messages.some(({ type, text }) => type === "say" && text?.includes("My name is Roo")),
-			"Did not receive expected response containing 'My name is Roo'",
-		)
-	})
-
-	test("Should handle subtask cancellation and resumption correctly", async function () {
-		this.timeout(60000) // Increase timeout for this test
-		const interval = 1000
-
-		if (!globalThis.extension) {
-			assert.fail("Extension not found")
-		}
-
-		// Ensure the webview is launched
-		await ensureWebviewLaunched(30000, interval)
-
-		// Set up required global state
-		await globalThis.provider.updateGlobalState("mode", "Code")
-		await globalThis.provider.updateGlobalState("alwaysAllowModeSwitch", true)
-		await globalThis.provider.updateGlobalState("alwaysAllowSubtasks", true)
-		await globalThis.provider.updateGlobalState("autoApprovalEnabled", true)
-
-		// 1. Start a parent task that will create a subtask
-		await globalThis.api.startNewTask(
-			"You are the parent task. Create a subtask by using the new_task tool with the message 'You are the subtask'. " +
-				"After creating the subtask, wait for it to complete and then respond with 'Parent task resumed'.",
-		)
-
-		// Wait for the parent task to use the new_task tool
-		await waitForToolUse("new_task", 30000, interval)
-
-		// Wait for the subtask to be created and start responding
-		await waitForMessage("You are the subtask", 10000, interval)
-
-		// 3. Cancel the current task (which should be the subtask)
-		await globalThis.provider.cancelTask()
-
-		// 4. Check if the parent task is still waiting (not resumed)
-		// We need to wait a bit to ensure any task resumption would have happened
-		await new Promise((resolve) => setTimeout(resolve, 5000))
-
-		// The parent task should not have resumed yet, so we shouldn't see "Parent task resumed"
-		assert.ok(
-			!globalThis.provider.messages.some(
-				({ type, text }) => type === "say" && text?.includes("Parent task resumed"),
-			),
-			"Parent task should not have resumed after subtask cancellation",
-		)
-
-		// 5. Start a new task with the same message as the subtask
-		await globalThis.api.startNewTask("You are the subtask")
-
-		// Wait for the subtask to complete
-		await waitForMessage("Task complete", 20000, interval)
-
-		// 6. Verify that the parent task is still not resumed
-		// We need to wait a bit to ensure any task resumption would have happened
-		await new Promise((resolve) => setTimeout(resolve, 5000))
-
-		// The parent task should still not have resumed
-		assert.ok(
-			!globalThis.provider.messages.some(
-				({ type, text }) => type === "say" && text?.includes("Parent task resumed"),
-			),
-			"Parent task should not have resumed after subtask completion",
-		)
-
-		// Clean up - cancel all tasks
-		await globalThis.provider.cancelTask()
+		const api = globalThis.api
+		await api.setConfiguration({ mode: "Ask", alwaysAllowModeSwitch: true, autoApprovalEnabled: true })
+		await api.startNewTask("Hello world, what is your name? Respond with 'My name is ...'")
+		await waitForMessage(api, { include: "My name is Roo" })
 	})
 })
-
-// Helper functions
-async function ensureWebviewLaunched(timeout: number, interval: number): Promise<void> {
-	const startTime = Date.now()
-	while (Date.now() - startTime < timeout) {
-		if (globalThis.provider.viewLaunched) {
-			return
-		}
-		await new Promise((resolve) => setTimeout(resolve, interval))
-	}
-	throw new Error("Webview failed to launch within timeout")
-}
-
-async function waitForToolUse(toolName: string, timeout: number, interval: number): Promise<void> {
-	const startTime = Date.now()
-	while (Date.now() - startTime < timeout) {
-		const messages = globalThis.provider.messages
-		if (
-			messages.some(
-				(message) =>
-					message.type === "say" && message.say === "tool" && message.text && message.text.includes(toolName),
-			)
-		) {
-			return
-		}
-		await new Promise((resolve) => setTimeout(resolve, interval))
-	}
-	throw new Error(`Tool ${toolName} was not used within timeout`)
-}
-
-async function waitForMessage(messageContent: string, timeout: number, interval: number): Promise<void> {
-	const startTime = Date.now()
-	while (Date.now() - startTime < timeout) {
-		const messages = globalThis.provider.messages
-		if (
-			messages.some((message) => message.type === "say" && message.text && message.text.includes(messageContent))
-		) {
-			return
-		}
-		await new Promise((resolve) => setTimeout(resolve, interval))
-	}
-	throw new Error(`Message containing "${messageContent}" not found within timeout`)
-}

+ 76 - 0
e2e/src/suite/utils.ts

@@ -0,0 +1,76 @@
+import * as vscode from "vscode"
+
+import { RooCodeAPI } from "../../../src/exports/roo-code"
+
+type WaitForOptions = {
+	timeout?: number
+	interval?: number
+}
+
+export const waitFor = (
+	condition: (() => Promise<boolean>) | (() => boolean),
+	{ timeout = 30_000, interval = 250 }: WaitForOptions = {},
+) => {
+	let timeoutId: NodeJS.Timeout | undefined = undefined
+
+	return Promise.race([
+		new Promise<void>((resolve) => {
+			const check = async () => {
+				const result = condition()
+				const isSatisfied = result instanceof Promise ? await result : result
+
+				if (isSatisfied) {
+					if (timeoutId) {
+						clearTimeout(timeoutId)
+						timeoutId = undefined
+					}
+
+					resolve()
+				} else {
+					setTimeout(check, interval)
+				}
+			}
+
+			check()
+		}),
+		new Promise((_, reject) => {
+			timeoutId = setTimeout(() => {
+				reject(new Error(`Timeout after ${Math.floor(timeout / 1000)}s`))
+			}, timeout)
+		}),
+	])
+}
+
+export const waitUntilReady = async (api: RooCodeAPI, { timeout = 10_000, interval = 250 }: WaitForOptions = {}) => {
+	await vscode.commands.executeCommand("roo-cline.SidebarProvider.focus")
+	await waitFor(api.isReady, { timeout, interval })
+}
+
+export const waitForToolUse = async (api: RooCodeAPI, toolName: string, options: WaitForOptions = {}) =>
+	waitFor(
+		() =>
+			api
+				.getMessages()
+				.some(({ type, say, text }) => type === "say" && say === "tool" && text && text.includes(toolName)),
+		options,
+	)
+
+export const waitForMessage = async (
+	api: RooCodeAPI,
+	options: WaitForOptions & { include: string; exclude?: string },
+) =>
+	waitFor(
+		() =>
+			api
+				.getMessages()
+				.some(
+					({ type, text }) =>
+						type === "say" &&
+						text &&
+						text.includes(options.include) &&
+						(!options.exclude || !text.includes(options.exclude)),
+				),
+		options,
+	)
+
+export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

+ 2 - 4
knip.json

@@ -13,12 +13,10 @@
 		"dist/**",
 		"out/**",
 		"bin/**",
+		"e2e/**",
 		"src/activate/**",
 		"src/exports/**",
-		"src/extension.ts",
-		"e2e/.vscode-test.mjs",
-		"e2e/src/runTest.ts",
-		"e2e/src/suite/index.ts"
+		"src/extension.ts"
 	],
 	"workspaces": {
 		"webview-ui": {

+ 0 - 1
package-lock.json

@@ -12546,7 +12546,6 @@
 			"resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz",
 			"integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==",
 			"dev": true,
-			"license": "MIT",
 			"dependencies": {
 				"ansi-styles": "^3.2.1",
 				"chalk": "^2.4.1",

+ 9 - 0
src/__mocks__/vscode.js

@@ -1,4 +1,13 @@
 const vscode = {
+	env: {
+		language: "en", // Default language for tests
+		appName: "Visual Studio Code Test",
+		appHost: "desktop",
+		appRoot: "/test/path",
+		machineId: "test-machine-id",
+		sessionId: "test-session-id",
+		shell: "/bin/zsh",
+	},
 	window: {
 		showInformationMessage: jest.fn(),
 		showErrorMessage: jest.fn(),

+ 23 - 18
src/activate/createRooCodeAPI.ts

@@ -3,24 +3,18 @@ import * as vscode from "vscode"
 import { ClineProvider } from "../core/webview/ClineProvider"
 
 import { RooCodeAPI } from "../exports/roo-code"
+import { ConfigurationValues } from "../shared/globalState"
 
-export function createRooCodeAPI(outputChannel: vscode.OutputChannel, sidebarProvider: ClineProvider): RooCodeAPI {
+export function createRooCodeAPI(outputChannel: vscode.OutputChannel, provider: ClineProvider): RooCodeAPI {
 	return {
-		setCustomInstructions: async (value: string) => {
-			await sidebarProvider.updateCustomInstructions(value)
-			outputChannel.appendLine("Custom instructions set")
-		},
-
-		getCustomInstructions: async () => {
-			return (await sidebarProvider.getGlobalState("customInstructions")) as string | undefined
-		},
-
 		startNewTask: async (task?: string, images?: string[]) => {
 			outputChannel.appendLine("Starting new task")
-			await sidebarProvider.removeClineFromStack()
-			await sidebarProvider.postStateToWebview()
-			await sidebarProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
-			await sidebarProvider.postMessageToWebview({
+
+			await provider.removeClineFromStack()
+			await provider.postStateToWebview()
+			await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
+
+			await provider.postMessageToWebview({
 				type: "invoke",
 				invoke: "sendMessage",
 				text: task,
@@ -32,12 +26,17 @@ export function createRooCodeAPI(outputChannel: vscode.OutputChannel, sidebarPro
 			)
 		},
 
+		cancelTask: async () => {
+			outputChannel.appendLine("Cancelling current task")
+			await provider.cancelTask()
+		},
+
 		sendMessage: async (message?: string, images?: string[]) => {
 			outputChannel.appendLine(
 				`Sending message: ${message ? `"${message}"` : "undefined"} with ${images?.length || 0} image(s)`,
 			)
 
-			await sidebarProvider.postMessageToWebview({
+			await provider.postMessageToWebview({
 				type: "invoke",
 				invoke: "sendMessage",
 				text: message,
@@ -47,14 +46,20 @@ export function createRooCodeAPI(outputChannel: vscode.OutputChannel, sidebarPro
 
 		pressPrimaryButton: async () => {
 			outputChannel.appendLine("Pressing primary button")
-			await sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "primaryButtonClick" })
+			await provider.postMessageToWebview({ type: "invoke", invoke: "primaryButtonClick" })
 		},
 
 		pressSecondaryButton: async () => {
 			outputChannel.appendLine("Pressing secondary button")
-			await sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "secondaryButtonClick" })
+			await provider.postMessageToWebview({ type: "invoke", invoke: "secondaryButtonClick" })
 		},
 
-		sidebarProvider: sidebarProvider,
+		setConfiguration: async (values: Partial<ConfigurationValues>) => {
+			await provider.setValues(values)
+		},
+
+		isReady: () => provider.viewLaunched,
+
+		getMessages: () => provider.messages,
 	}
 }

+ 51 - 4
src/api/providers/openai.ts

@@ -14,6 +14,7 @@ import { convertToR1Format } from "../transform/r1-format"
 import { convertToSimpleMessages } from "../transform/simple-format"
 import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
 import { BaseProvider } from "./base-provider"
+import { XmlMatcher } from "../../utils/xml-matcher"
 
 const DEEP_SEEK_DEFAULT_TEMPERATURE = 0.6
 
@@ -72,7 +73,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
 		}
 
 		if (this.options.openAiStreamingEnabled ?? true) {
-			const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
+			let systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
 				role: "system",
 				content: systemPrompt,
 			}
@@ -83,7 +84,42 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
 			} else if (ark) {
 				convertedMessages = [systemMessage, ...convertToSimpleMessages(messages)]
 			} else {
+				if (modelInfo.supportsPromptCache) {
+					systemMessage = {
+						role: "system",
+						content: [
+							{
+								type: "text",
+								text: systemPrompt,
+								// @ts-ignore-next-line
+								cache_control: { type: "ephemeral" },
+							},
+						],
+					}
+				}
 				convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)]
+				if (modelInfo.supportsPromptCache) {
+					// Note: the following logic is copied from openrouter:
+					// Add cache_control to the last two user messages
+					// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
+					const lastTwoUserMessages = convertedMessages.filter((msg) => msg.role === "user").slice(-2)
+					lastTwoUserMessages.forEach((msg) => {
+						if (typeof msg.content === "string") {
+							msg.content = [{ type: "text", text: msg.content }]
+						}
+						if (Array.isArray(msg.content)) {
+							// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
+							let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
+
+							if (!lastTextPart) {
+								lastTextPart = { type: "text", text: "..." }
+								msg.content.push(lastTextPart)
+							}
+							// @ts-ignore-next-line
+							lastTextPart["cache_control"] = { type: "ephemeral" }
+						}
+					})
+				}
 			}
 
 			const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
@@ -99,15 +135,23 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
 
 			const stream = await this.client.chat.completions.create(requestOptions)
 
+			const matcher = new XmlMatcher(
+				"think",
+				(chunk) =>
+					({
+						type: chunk.matched ? "reasoning" : "text",
+						text: chunk.data,
+					}) as const,
+			)
+
 			let lastUsage
 
 			for await (const chunk of stream) {
 				const delta = chunk.choices[0]?.delta ?? {}
 
 				if (delta.content) {
-					yield {
-						type: "text",
-						text: delta.content,
+					for (const chunk of matcher.update(delta.content)) {
+						yield chunk
 					}
 				}
 
@@ -121,6 +165,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
 					lastUsage = chunk.usage
 				}
 			}
+			for (const chunk of matcher.final()) {
+				yield chunk
+			}
 
 			if (lastUsage) {
 				yield this.processUsageMetrics(lastUsage, modelInfo)

+ 25 - 32
src/api/providers/openrouter.ts

@@ -1,6 +1,6 @@
 import { Anthropic } from "@anthropic-ai/sdk"
 import { BetaThinkingConfigParam } from "@anthropic-ai/sdk/resources/beta"
-import axios from "axios"
+import axios, { AxiosRequestConfig } from "axios"
 import OpenAI from "openai"
 import delay from "delay"
 
@@ -132,62 +132,55 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
 			const delta = chunk.choices[0]?.delta
 
 			if ("reasoning" in delta && delta.reasoning) {
-				yield {
-					type: "reasoning",
-					text: delta.reasoning,
-				} as ApiStreamChunk
+				yield { type: "reasoning", text: delta.reasoning } as ApiStreamChunk
 			}
 
 			if (delta?.content) {
 				fullResponseText += delta.content
-				yield {
-					type: "text",
-					text: delta.content,
-				} as ApiStreamChunk
+				yield { type: "text", text: delta.content } as ApiStreamChunk
 			}
+		}
 
-			// if (chunk.usage) {
-			// 	yield {
-			// 		type: "usage",
-			// 		inputTokens: chunk.usage.prompt_tokens || 0,
-			// 		outputTokens: chunk.usage.completion_tokens || 0,
-			// 	}
-			// }
+		const endpoint = `${this.client.baseURL}/generation?id=${genId}`
+
+		const config: AxiosRequestConfig = {
+			headers: { Authorization: `Bearer ${this.options.openRouterApiKey}` },
+			timeout: 3_000,
 		}
 
-		// Retry fetching generation details.
 		let attempt = 0
+		let lastError: Error | undefined
+		const startTime = Date.now()
 
 		while (attempt++ < 10) {
-			await delay(200) // FIXME: necessary delay to ensure generation endpoint is ready
+			await delay(attempt * 100) // Give OpenRouter some time to produce the generation metadata.
 
 			try {
-				const response = await axios.get(`${this.client.baseURL}/generation?id=${genId}`, {
-					headers: {
-						Authorization: `Bearer ${this.options.openRouterApiKey}`,
-					},
-					timeout: 5_000, // this request hangs sometimes
-				})
-
+				const response = await axios.get(endpoint, config)
 				const generation = response.data?.data
 
 				yield {
 					type: "usage",
-					// cacheWriteTokens: 0,
-					// cacheReadTokens: 0,
-					// openrouter generation endpoint fails often
 					inputTokens: generation?.native_tokens_prompt || 0,
 					outputTokens: generation?.native_tokens_completion || 0,
 					totalCost: generation?.total_cost || 0,
 					fullResponseText,
 				} as OpenRouterApiStreamUsageChunk
 
-				return
-			} catch (error) {
-				// ignore if fails
-				console.error("Error fetching OpenRouter generation details:", error)
+				break
+			} catch (error: unknown) {
+				if (error instanceof Error) {
+					lastError = error
+				}
 			}
 		}
+
+		if (lastError) {
+			console.error(
+				`Failed to fetch OpenRouter generation details after ${Date.now() - startTime}ms (${genId})`,
+				lastError,
+			)
+		}
 	}
 
 	override getModel() {

+ 2 - 4
src/core/Cline.ts

@@ -70,6 +70,7 @@ import { truncateConversationIfNeeded } from "./sliding-window"
 import { ClineProvider } from "./webview/ClineProvider"
 import { detectCodeOmission } from "../integrations/editor/detect-omission"
 import { BrowserSession } from "../services/browser/BrowserSession"
+import { formatLanguage } from "../shared/language"
 import { McpHub } from "../services/mcp/McpHub"
 import crypto from "crypto"
 import { insertGroups } from "./diff/insert-groups"
@@ -1102,7 +1103,6 @@ export class Cline {
 			browserViewportSize,
 			mode,
 			customModePrompts,
-			preferredLanguage,
 			experiments,
 			enableMcpServerCreation,
 			browserToolEnabled,
@@ -1124,7 +1124,6 @@ export class Cline {
 				customModePrompts,
 				customModes,
 				this.customInstructions,
-				preferredLanguage,
 				this.diffEnabled,
 				experiments,
 				enableMcpServerCreation,
@@ -3665,13 +3664,12 @@ export class Cline {
 			customModePrompts,
 			experiments = {} as Record<ExperimentId, boolean>,
 			customInstructions: globalCustomInstructions,
-			preferredLanguage,
 		} = (await this.providerRef.deref()?.getState()) ?? {}
 		const currentMode = mode ?? defaultModeSlug
 		const modeDetails = await getFullModeDetails(currentMode, customModes, customModePrompts, {
 			cwd,
 			globalCustomInstructions,
-			preferredLanguage,
+			language: formatLanguage(vscode.env.language),
 		})
 		details += `\n\n# Current Mode\n`
 		details += `<slug>${currentMode}</slug>\n`

+ 24 - 35
src/core/__tests__/contextProxy.test.ts

@@ -1,15 +1,11 @@
+// npx jest src/core/__tests__/contextProxy.test.ts
+
 import * as vscode from "vscode"
 import { ContextProxy } from "../contextProxy"
-import { logger } from "../../utils/logging"
-import { GLOBAL_STATE_KEYS, SECRET_KEYS } from "../../shared/globalState"
 
-// Mock shared/globalState
-jest.mock("../../shared/globalState", () => ({
-	GLOBAL_STATE_KEYS: ["apiProvider", "apiModelId", "mode"],
-	SECRET_KEYS: ["apiKey", "openAiApiKey"],
-}))
+import { logger } from "../../utils/logging"
+import { GLOBAL_STATE_KEYS, SECRET_KEYS, ConfigurationKey, GlobalStateKey } from "../../shared/globalState"
 
-// Mock VSCode API
 jest.mock("vscode", () => ({
 	Uri: {
 		file: jest.fn((path) => ({ path })),
@@ -27,7 +23,7 @@ describe("ContextProxy", () => {
 	let mockGlobalState: any
 	let mockSecrets: any
 
-	beforeEach(() => {
+	beforeEach(async () => {
 		// Reset mocks
 		jest.clearAllMocks()
 
@@ -58,6 +54,7 @@ describe("ContextProxy", () => {
 
 		// Create proxy instance
 		proxy = new ContextProxy(mockContext)
+		await proxy.initialize()
 	})
 
 	describe("read-only pass-through properties", () => {
@@ -90,10 +87,10 @@ describe("ContextProxy", () => {
 	describe("getGlobalState", () => {
 		it("should return value from cache when it exists", async () => {
 			// Manually set a value in the cache
-			await proxy.updateGlobalState("test-key", "cached-value")
+			await proxy.updateGlobalState("apiProvider", "cached-value")
 
 			// Should return the cached value
-			const result = proxy.getGlobalState("test-key")
+			const result = proxy.getGlobalState("apiProvider")
 			expect(result).toBe("cached-value")
 
 			// Original context should be called once during updateGlobalState
@@ -102,20 +99,20 @@ describe("ContextProxy", () => {
 
 		it("should handle default values correctly", async () => {
 			// No value in cache
-			const result = proxy.getGlobalState("unknown-key", "default-value")
+			const result = proxy.getGlobalState("apiProvider", "default-value")
 			expect(result).toBe("default-value")
 		})
 	})
 
 	describe("updateGlobalState", () => {
 		it("should update state directly in original context", async () => {
-			await proxy.updateGlobalState("test-key", "new-value")
+			await proxy.updateGlobalState("apiProvider", "new-value")
 
 			// Should have called original context
-			expect(mockGlobalState.update).toHaveBeenCalledWith("test-key", "new-value")
+			expect(mockGlobalState.update).toHaveBeenCalledWith("apiProvider", "new-value")
 
 			// Should have stored the value in cache
-			const storedValue = await proxy.getGlobalState("test-key")
+			const storedValue = await proxy.getGlobalState("apiProvider")
 			expect(storedValue).toBe("new-value")
 		})
 	})
@@ -123,34 +120,34 @@ describe("ContextProxy", () => {
 	describe("getSecret", () => {
 		it("should return value from cache when it exists", async () => {
 			// Manually set a value in the cache
-			await proxy.storeSecret("api-key", "cached-secret")
+			await proxy.storeSecret("apiKey", "cached-secret")
 
 			// Should return the cached value
-			const result = proxy.getSecret("api-key")
+			const result = proxy.getSecret("apiKey")
 			expect(result).toBe("cached-secret")
 		})
 	})
 
 	describe("storeSecret", () => {
 		it("should store secret directly in original context", async () => {
-			await proxy.storeSecret("api-key", "new-secret")
+			await proxy.storeSecret("apiKey", "new-secret")
 
 			// Should have called original context
-			expect(mockSecrets.store).toHaveBeenCalledWith("api-key", "new-secret")
+			expect(mockSecrets.store).toHaveBeenCalledWith("apiKey", "new-secret")
 
 			// Should have stored the value in cache
-			const storedValue = await proxy.getSecret("api-key")
+			const storedValue = await proxy.getSecret("apiKey")
 			expect(storedValue).toBe("new-secret")
 		})
 
 		it("should handle undefined value for secret deletion", async () => {
-			await proxy.storeSecret("api-key", undefined)
+			await proxy.storeSecret("apiKey", undefined)
 
 			// Should have called delete on original context
-			expect(mockSecrets.delete).toHaveBeenCalledWith("api-key")
+			expect(mockSecrets.delete).toHaveBeenCalledWith("apiKey")
 
 			// Should have stored undefined in cache
-			const storedValue = await proxy.getSecret("api-key")
+			const storedValue = await proxy.getSecret("apiKey")
 			expect(storedValue).toBeUndefined()
 		})
 	})
@@ -194,7 +191,7 @@ describe("ContextProxy", () => {
 			const updateGlobalStateSpy = jest.spyOn(proxy, "updateGlobalState")
 
 			// Test with an unknown key
-			await proxy.setValue("unknownKey", "some-value")
+			await proxy.setValue("unknownKey" as ConfigurationKey, "some-value")
 
 			// Should have logged a warning
 			expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown key: unknownKey"))
@@ -203,7 +200,7 @@ describe("ContextProxy", () => {
 			expect(updateGlobalStateSpy).toHaveBeenCalledWith("unknownKey", "some-value")
 
 			// Should have stored the value in state cache
-			const storedValue = proxy.getGlobalState("unknownKey")
+			const storedValue = proxy.getGlobalState("unknownKey" as GlobalStateKey)
 			expect(storedValue).toBe("some-value")
 		})
 	})
@@ -241,18 +238,15 @@ describe("ContextProxy", () => {
 			await proxy.setValues({
 				apiModelId: "gpt-4", // global state
 				openAiApiKey: "test-api-key", // secret
-				unknownKey: "some-value", // unknown
 			})
 
 			// Should have called appropriate methods
 			expect(storeSecretSpy).toHaveBeenCalledWith("openAiApiKey", "test-api-key")
 			expect(updateGlobalStateSpy).toHaveBeenCalledWith("apiModelId", "gpt-4")
-			expect(updateGlobalStateSpy).toHaveBeenCalledWith("unknownKey", "some-value")
 
 			// Should have stored values in appropriate caches
 			expect(proxy.getSecret("openAiApiKey")).toBe("test-api-key")
 			expect(proxy.getGlobalState("apiModelId")).toBe("gpt-4")
-			expect(proxy.getGlobalState("unknownKey")).toBe("some-value")
 		})
 	})
 
@@ -262,13 +256,11 @@ describe("ContextProxy", () => {
 			await proxy.setValues({
 				apiModelId: "gpt-4", // global state
 				openAiApiKey: "test-api-key", // secret
-				unknownKey: "some-value", // unknown
 			})
 
 			// Verify initial state
 			expect(proxy.getGlobalState("apiModelId")).toBe("gpt-4")
 			expect(proxy.getSecret("openAiApiKey")).toBe("test-api-key")
-			expect(proxy.getGlobalState("unknownKey")).toBe("some-value")
 
 			// Reset all state
 			await proxy.resetAllState()
@@ -277,7 +269,6 @@ describe("ContextProxy", () => {
 			// Since our mock globalState.get returns undefined by default,
 			// the cache should now contain undefined values
 			expect(proxy.getGlobalState("apiModelId")).toBeUndefined()
-			expect(proxy.getGlobalState("unknownKey")).toBeUndefined()
 		})
 
 		it("should update all global state keys to undefined", async () => {
@@ -317,15 +308,13 @@ describe("ContextProxy", () => {
 
 		it("should reinitialize caches after reset", async () => {
 			// Spy on initialization methods
-			const initStateCache = jest.spyOn(proxy as any, "initializeStateCache")
-			const initSecretCache = jest.spyOn(proxy as any, "initializeSecretCache")
+			const initializeSpy = jest.spyOn(proxy as any, "initialize")
 
 			// Reset all state
 			await proxy.resetAllState()
 
 			// Should reinitialize caches
-			expect(initStateCache).toHaveBeenCalledTimes(1)
-			expect(initSecretCache).toHaveBeenCalledTimes(1)
+			expect(initializeSpy).toHaveBeenCalledTimes(1)
 		})
 	})
 })

+ 69 - 64
src/core/contextProxy.ts

@@ -1,97 +1,105 @@
 import * as vscode from "vscode"
+
 import { logger } from "../utils/logging"
-import { GLOBAL_STATE_KEYS, SECRET_KEYS } from "../shared/globalState"
+import {
+	GLOBAL_STATE_KEYS,
+	SECRET_KEYS,
+	GlobalStateKey,
+	SecretKey,
+	ConfigurationKey,
+	ConfigurationValues,
+	isSecretKey,
+	isGlobalStateKey,
+} from "../shared/globalState"
 
 export class ContextProxy {
 	private readonly originalContext: vscode.ExtensionContext
-	private stateCache: Map<string, any>
-	private secretCache: Map<string, string | undefined>
+
+	private stateCache: Map<GlobalStateKey, any>
+	private secretCache: Map<SecretKey, string | undefined>
+	private _isInitialized = false
 
 	constructor(context: vscode.ExtensionContext) {
-		// Initialize properties first
 		this.originalContext = context
 		this.stateCache = new Map()
 		this.secretCache = new Map()
+		this._isInitialized = false
+	}
 
-		// Initialize state cache with all defined global state keys
-		this.initializeStateCache()
-
-		// Initialize secret cache with all defined secret keys
-		this.initializeSecretCache()
-
-		logger.debug("ContextProxy created")
+	public get isInitialized() {
+		return this._isInitialized
 	}
 
-	// Helper method to initialize state cache
-	private initializeStateCache(): void {
+	public async initialize() {
 		for (const key of GLOBAL_STATE_KEYS) {
 			try {
-				const value = this.originalContext.globalState.get(key)
-				this.stateCache.set(key, value)
+				this.stateCache.set(key, this.originalContext.globalState.get(key))
 			} catch (error) {
 				logger.error(`Error loading global ${key}: ${error instanceof Error ? error.message : String(error)}`)
 			}
 		}
-	}
 
-	// Helper method to initialize secret cache
-	private initializeSecretCache(): void {
-		for (const key of SECRET_KEYS) {
-			// Get actual value and update cache when promise resolves
-			;(this.originalContext.secrets.get(key) as Promise<string | undefined>)
-				.then((value) => {
-					this.secretCache.set(key, value)
-				})
-				.catch((error: Error) => {
-					logger.error(`Error loading secret ${key}: ${error.message}`)
-				})
-		}
+		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(): vscode.Uri {
+	get extensionUri() {
 		return this.originalContext.extensionUri
 	}
-	get extensionPath(): string {
+
+	get extensionPath() {
 		return this.originalContext.extensionPath
 	}
-	get globalStorageUri(): vscode.Uri {
+
+	get globalStorageUri() {
 		return this.originalContext.globalStorageUri
 	}
-	get logUri(): vscode.Uri {
+
+	get logUri() {
 		return this.originalContext.logUri
 	}
-	get extension(): vscode.Extension<any> | undefined {
+
+	get extension() {
 		return this.originalContext.extension
 	}
-	get extensionMode(): vscode.ExtensionMode {
+
+	get extensionMode() {
 		return this.originalContext.extensionMode
 	}
 
-	getGlobalState<T>(key: string): T | undefined
-	getGlobalState<T>(key: string, defaultValue: T): T
-	getGlobalState<T>(key: string, defaultValue?: T): T | undefined {
+	getGlobalState<T>(key: GlobalStateKey): T | undefined
+	getGlobalState<T>(key: GlobalStateKey, defaultValue: T): T
+	getGlobalState<T>(key: GlobalStateKey, defaultValue?: T): T | undefined {
 		const value = this.stateCache.get(key) as T | undefined
 		return value !== undefined ? value : (defaultValue as T | undefined)
 	}
 
-	updateGlobalState<T>(key: string, value: T): Thenable<void> {
+	updateGlobalState<T>(key: GlobalStateKey, value: T) {
 		this.stateCache.set(key, value)
 		return this.originalContext.globalState.update(key, value)
 	}
 
-	getSecret(key: string): string | undefined {
+	getSecret(key: SecretKey) {
 		return this.secretCache.get(key)
 	}
 
-	storeSecret(key: string, value?: string): Thenable<void> {
-		// Update cache
+	storeSecret(key: SecretKey, value?: string) {
+		// Update cache.
 		this.secretCache.set(key, value)
-		// Write directly to context
-		if (value === undefined) {
-			return this.originalContext.secrets.delete(key)
-		} else {
-			return this.originalContext.secrets.store(key, value)
-		}
+
+		// 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.
@@ -101,17 +109,15 @@ export class ContextProxy {
 	 * @param value The value to set
 	 * @returns A promise that resolves when the operation completes
 	 */
-	setValue(key: string, value: any): Thenable<void> {
-		if (SECRET_KEYS.includes(key as any)) {
+	setValue(key: ConfigurationKey, value: any) {
+		if (isSecretKey(key)) {
 			return this.storeSecret(key, value)
-		}
-
-		if (GLOBAL_STATE_KEYS.includes(key as any)) {
+		} else if (isGlobalStateKey(key)) {
+			return this.updateGlobalState(key, value)
+		} else {
+			logger.warn(`Unknown key: ${key}. Storing as global state.`)
 			return this.updateGlobalState(key, value)
 		}
-
-		logger.warn(`Unknown key: ${key}. Storing as global state.`)
-		return this.updateGlobalState(key, value)
 	}
 
 	/**
@@ -120,14 +126,14 @@ export class ContextProxy {
 	 * @param values An object containing key-value pairs to set
 	 * @returns A promise that resolves when all operations complete
 	 */
-	async setValues(values: Record<string, any>): Promise<void[]> {
+	async setValues(values: Partial<ConfigurationValues>) {
 		const promises: Thenable<void>[] = []
 
 		for (const [key, value] of Object.entries(values)) {
-			promises.push(this.setValue(key, value))
+			promises.push(this.setValue(key as ConfigurationKey, value))
 		}
 
-		return Promise.all(promises)
+		await Promise.all(promises)
 	}
 
 	/**
@@ -135,23 +141,22 @@ export class ContextProxy {
 	 * 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(): Promise<void> {
+	async resetAllState() {
 		// Clear in-memory caches
 		this.stateCache.clear()
 		this.secretCache.clear()
 
-		// Reset all global state values to undefined
+		// Reset all global state values to undefined.
 		const stateResetPromises = GLOBAL_STATE_KEYS.map((key) =>
 			this.originalContext.globalState.update(key, undefined),
 		)
 
-		// Delete all secrets
+		// Delete all secrets.
 		const secretResetPromises = SECRET_KEYS.map((key) => this.originalContext.secrets.delete(key))
 
-		// Wait for all reset operations to complete
+		// Wait for all reset operations to complete.
 		await Promise.all([...stateResetPromises, ...secretResetPromises])
 
-		this.initializeStateCache()
-		this.initializeSecretCache()
+		this.initialize()
 	}
 }

Разница между файлами не показана из-за своего большого размера
+ 1144 - 0
src/core/prompts/__tests__/__snapshots__/system.test.ts.snap


+ 33 - 40
src/core/prompts/__tests__/custom-system-prompt.test.ts

@@ -46,8 +46,6 @@ const mockContext = {
 } as unknown as vscode.ExtensionContext
 
 describe("File-Based Custom System Prompt", () => {
-	const experiments = {}
-
 	beforeEach(() => {
 		// Reset mocks before each test
 		jest.clearAllMocks()
@@ -66,18 +64,17 @@ describe("File-Based Custom System Prompt", () => {
 		const prompt = await SYSTEM_PROMPT(
 			mockContext,
 			"test/path", // Using a relative path without leading slash
-			false,
-			undefined,
-			undefined,
-			undefined,
-			defaultModeSlug,
-			customModePrompts,
-			undefined,
-			undefined,
-			undefined,
-			undefined,
-			experiments,
-			true,
+			false, // supportsComputerUse
+			undefined, // mcpHub
+			undefined, // diffStrategy
+			undefined, // browserViewportSize
+			defaultModeSlug, // mode
+			customModePrompts, // customModePrompts
+			undefined, // customModes
+			undefined, // globalCustomInstructions
+			undefined, // diffEnabled
+			undefined, // experiments
+			true, // enableMcpServerCreation
 		)
 
 		// Should contain default sections
@@ -101,18 +98,17 @@ describe("File-Based Custom System Prompt", () => {
 		const prompt = await SYSTEM_PROMPT(
 			mockContext,
 			"test/path", // Using a relative path without leading slash
-			false,
-			undefined,
-			undefined,
-			undefined,
-			defaultModeSlug,
-			undefined,
-			undefined,
-			undefined,
-			undefined,
-			undefined,
-			experiments,
-			true,
+			false, // supportsComputerUse
+			undefined, // mcpHub
+			undefined, // diffStrategy
+			undefined, // browserViewportSize
+			defaultModeSlug, // mode
+			undefined, // customModePrompts
+			undefined, // customModes
+			undefined, // globalCustomInstructions
+			undefined, // diffEnabled
+			undefined, // experiments
+			true, // enableMcpServerCreation
 		)
 
 		// Should contain role definition and file-based system prompt
@@ -120,7 +116,6 @@ describe("File-Based Custom System Prompt", () => {
 		expect(prompt).toContain(fileCustomSystemPrompt)
 
 		// Should not contain any of the default sections
-		expect(prompt).not.toContain("TOOL USE")
 		expect(prompt).not.toContain("CAPABILITIES")
 		expect(prompt).not.toContain("MODES")
 	})
@@ -146,18 +141,17 @@ describe("File-Based Custom System Prompt", () => {
 		const prompt = await SYSTEM_PROMPT(
 			mockContext,
 			"test/path", // Using a relative path without leading slash
-			false,
-			undefined,
-			undefined,
-			undefined,
-			defaultModeSlug,
-			customModePrompts,
-			undefined,
-			undefined,
-			undefined,
-			undefined,
-			experiments,
-			true,
+			false, // supportsComputerUse
+			undefined, // mcpHub
+			undefined, // diffStrategy
+			undefined, // browserViewportSize
+			defaultModeSlug, // mode
+			customModePrompts, // customModePrompts
+			undefined, // customModes
+			undefined, // globalCustomInstructions
+			undefined, // diffEnabled
+			undefined, // experiments
+			true, // enableMcpServerCreation
 		)
 
 		// Should contain custom role definition and file-based system prompt
@@ -165,7 +159,6 @@ describe("File-Based Custom System Prompt", () => {
 		expect(prompt).toContain(fileCustomSystemPrompt)
 
 		// Should not contain any of the default sections
-		expect(prompt).not.toContain("TOOL USE")
 		expect(prompt).not.toContain("CAPABILITIES")
 		expect(prompt).not.toContain("MODES")
 	})

+ 4 - 4
src/core/prompts/__tests__/sections.test.ts

@@ -3,20 +3,20 @@ import { getCapabilitiesSection } from "../sections/capabilities"
 import { DiffStrategy, DiffResult } from "../../diff/types"
 
 describe("addCustomInstructions", () => {
-	test("adds preferred language to custom instructions", async () => {
+	test("adds vscode language to custom instructions", async () => {
 		const result = await addCustomInstructions(
 			"mode instructions",
 			"global instructions",
 			"/test/path",
 			"test-mode",
-			{ preferredLanguage: "French" },
+			{ language: "fr" },
 		)
 
 		expect(result).toContain("Language Preference:")
-		expect(result).toContain("You should always speak and think in the French language")
+		expect(result).toContain('You should always speak and think in the "fr" language')
 	})
 
-	test("works without preferred language", async () => {
+	test("works without vscode language", async () => {
 		const result = await addCustomInstructions(
 			"mode instructions",
 			"global instructions",

+ 154 - 114
src/core/prompts/__tests__/system.test.ts

@@ -6,7 +6,7 @@ import { SearchReplaceDiffStrategy } from "../../../core/diff/strategies/search-
 import * as vscode from "vscode"
 import fs from "fs/promises"
 import os from "os"
-import { defaultModeSlug, modes } from "../../../shared/modes"
+import { defaultModeSlug, modes, Mode, isToolAllowedForMode } from "../../../shared/modes"
 // Import path utils to get access to toPosix string extension
 import "../../../utils/path"
 import { addCustomInstructions } from "../sections/custom-instructions"
@@ -18,46 +18,63 @@ jest.mock("../sections/modes", () => ({
 	getModesSection: jest.fn().mockImplementation(async () => `====\n\nMODES\n\n- Test modes section`),
 }))
 
-jest.mock("../sections/custom-instructions", () => ({
-	addCustomInstructions: jest
-		.fn()
-		.mockImplementation(async (modeCustomInstructions, globalCustomInstructions, cwd, mode, options) => {
-			const sections = []
-
-			// Add language preference if provided
-			if (options?.preferredLanguage) {
-				sections.push(
-					`Language Preference:\nYou should always speak and think in the ${options.preferredLanguage} language.`,
-				)
-			}
+// Mock the custom instructions
+jest.mock("../sections/custom-instructions", () => {
+	const addCustomInstructions = jest.fn()
+	return {
+		addCustomInstructions,
+		__setMockImplementation: (impl: any) => {
+			addCustomInstructions.mockImplementation(impl)
+		},
+	}
+})
 
-			// Add global instructions first
-			if (globalCustomInstructions?.trim()) {
-				sections.push(`Global Instructions:\n${globalCustomInstructions.trim()}`)
-			}
+// Set up default mock implementation
+const { __setMockImplementation } = jest.requireMock("../sections/custom-instructions")
+__setMockImplementation(
+	async (
+		modeCustomInstructions: string,
+		globalCustomInstructions: string,
+		cwd: string,
+		mode: string,
+		options?: { language?: string },
+	) => {
+		const sections = []
+
+		// Add language preference if provided
+		if (options?.language) {
+			sections.push(
+				`Language Preference:\nYou should always speak and think in the "${options.language}" language.`,
+			)
+		}
 
-			// Add mode-specific instructions after
-			if (modeCustomInstructions?.trim()) {
-				sections.push(`Mode-specific Instructions:\n${modeCustomInstructions}`)
-			}
+		// Add global instructions first
+		if (globalCustomInstructions?.trim()) {
+			sections.push(`Global Instructions:\n${globalCustomInstructions.trim()}`)
+		}
 
-			// Add rules
-			const rules = []
-			if (mode) {
-				rules.push(`# Rules from .clinerules-${mode}:\nMock mode-specific rules`)
-			}
-			rules.push(`# Rules from .clinerules:\nMock generic rules`)
+		// Add mode-specific instructions after
+		if (modeCustomInstructions?.trim()) {
+			sections.push(`Mode-specific Instructions:\n${modeCustomInstructions}`)
+		}
 
-			if (rules.length > 0) {
-				sections.push(`Rules:\n${rules.join("\n")}`)
-			}
+		// Add rules
+		const rules = []
+		if (mode) {
+			rules.push(`# Rules from .clinerules-${mode}:\nMock mode-specific rules`)
+		}
+		rules.push(`# Rules from .clinerules:\nMock generic rules`)
 
-			const joinedSections = sections.join("\n\n")
-			return joinedSections
-				? `\n====\n\nUSER'S CUSTOM INSTRUCTIONS\n\nThe following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines.\n\n${joinedSections}`
-				: ""
-		}),
-}))
+		if (rules.length > 0) {
+			sections.push(`Rules:\n${rules.join("\n")}`)
+		}
+
+		const joinedSections = sections.join("\n\n")
+		return joinedSections
+			? `\n====\n\nUSER'S CUSTOM INSTRUCTIONS\n\nThe following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines.\n\n${joinedSections}`
+			: ""
+	},
+)
 
 // Mock environment-specific values for consistent tests
 jest.mock("os", () => ({
@@ -69,6 +86,13 @@ jest.mock("default-shell", () => "/bin/zsh")
 
 jest.mock("os-name", () => () => "Linux")
 
+// Mock vscode language
+jest.mock("vscode", () => ({
+	env: {
+		language: "en",
+	},
+}))
+
 jest.mock("../../../utils/shell", () => ({
 	getShell: () => "/bin/zsh",
 }))
@@ -126,7 +150,7 @@ const createMockMcpHub = (): McpHub =>
 
 describe("SYSTEM_PROMPT", () => {
 	let mockMcpHub: McpHub
-	let experiments: Record<string, boolean>
+	let experiments: Record<string, boolean> | undefined
 
 	beforeAll(() => {
 		// Ensure fs mock is properly initialized
@@ -146,6 +170,10 @@ describe("SYSTEM_PROMPT", () => {
 			"/mock/mcp/path",
 		]
 		dirs.forEach((dir) => mockFs._mockDirectories.add(dir))
+	})
+
+	beforeEach(() => {
+		// Reset experiments before each test to ensure they're disabled by default
 		experiments = {
 			[EXPERIMENT_IDS.SEARCH_AND_REPLACE]: false,
 			[EXPERIMENT_IDS.INSERT_BLOCK]: false,
@@ -175,7 +203,6 @@ describe("SYSTEM_PROMPT", () => {
 			undefined, // customModePrompts
 			undefined, // customModes
 			undefined, // globalCustomInstructions
-			undefined, // preferredLanguage
 			undefined, // diffEnabled
 			experiments,
 			true, // enableMcpServerCreation
@@ -196,7 +223,6 @@ describe("SYSTEM_PROMPT", () => {
 			undefined, // customModePrompts
 			undefined, // customModes,
 			undefined, // globalCustomInstructions
-			undefined, // preferredLanguage
 			undefined, // diffEnabled
 			experiments,
 			true, // enableMcpServerCreation
@@ -219,7 +245,6 @@ describe("SYSTEM_PROMPT", () => {
 			undefined, // customModePrompts
 			undefined, // customModes,
 			undefined, // globalCustomInstructions
-			undefined, // preferredLanguage
 			undefined, // diffEnabled
 			experiments,
 			true, // enableMcpServerCreation
@@ -266,7 +291,6 @@ describe("SYSTEM_PROMPT", () => {
 			undefined,
 			undefined,
 			undefined,
-			undefined,
 			experiments,
 			true,
 		)
@@ -286,7 +310,6 @@ describe("SYSTEM_PROMPT", () => {
 			undefined, // customModePrompts
 			undefined, // customModes,
 			undefined, // globalCustomInstructions
-			undefined, // preferredLanguage
 			undefined, // diffEnabled
 			experiments,
 			true, // enableMcpServerCreation
@@ -310,7 +333,6 @@ describe("SYSTEM_PROMPT", () => {
 			undefined, // customModePrompts
 			undefined, // customModes,
 			undefined, // globalCustomInstructions
-			undefined, // preferredLanguage
 			undefined, // diffEnabled
 			experiments,
 			true, // enableMcpServerCreation
@@ -331,7 +353,6 @@ describe("SYSTEM_PROMPT", () => {
 			undefined, // customModePrompts
 			undefined, // customModes
 			undefined, // globalCustomInstructions
-			undefined, // preferredLanguage
 			true, // diffEnabled
 			experiments,
 			true, // enableMcpServerCreation
@@ -353,7 +374,6 @@ describe("SYSTEM_PROMPT", () => {
 			undefined, // customModePrompts
 			undefined, // customModes
 			undefined, // globalCustomInstructions
-			undefined, // preferredLanguage
 			false, // diffEnabled
 			experiments,
 			true, // enableMcpServerCreation
@@ -375,7 +395,6 @@ describe("SYSTEM_PROMPT", () => {
 			undefined, // customModePrompts
 			undefined, // customModes
 			undefined, // globalCustomInstructions
-			undefined, // preferredLanguage
 			undefined, // diffEnabled
 			experiments,
 			true, // enableMcpServerCreation
@@ -385,7 +404,11 @@ describe("SYSTEM_PROMPT", () => {
 		expect(prompt).toMatchSnapshot()
 	})
 
-	it("should include preferred language in custom instructions", async () => {
+	it("should include vscode language in custom instructions", async () => {
+		// Mock vscode.env.language
+		const vscode = jest.requireMock("vscode")
+		vscode.env = { language: "es" }
+
 		const prompt = await SYSTEM_PROMPT(
 			mockContext,
 			"/test/path",
@@ -397,14 +420,16 @@ describe("SYSTEM_PROMPT", () => {
 			undefined, // customModePrompts
 			undefined, // customModes
 			undefined, // globalCustomInstructions
-			"Spanish", // preferredLanguage
 			undefined, // diffEnabled
-			experiments,
+			undefined, // experiments
 			true, // enableMcpServerCreation
 		)
 
 		expect(prompt).toContain("Language Preference:")
-		expect(prompt).toContain("You should always speak and think in the Spanish language")
+		expect(prompt).toContain('You should always speak and think in the "es" language')
+
+		// Reset mock
+		vscode.env = { language: "en" }
 	})
 
 	it("should include custom mode role definition at top and instructions at bottom", async () => {
@@ -430,7 +455,6 @@ describe("SYSTEM_PROMPT", () => {
 			undefined, // customModePrompts
 			customModes, // customModes
 			"Global instructions", // globalCustomInstructions
-			undefined, // preferredLanguage
 			undefined, // diffEnabled
 			experiments,
 			true, // enableMcpServerCreation
@@ -458,18 +482,17 @@ describe("SYSTEM_PROMPT", () => {
 		const prompt = await SYSTEM_PROMPT(
 			mockContext,
 			"/test/path",
-			false,
-			undefined,
-			undefined,
-			undefined,
-			defaultModeSlug,
-			customModePrompts,
-			undefined,
-			undefined,
-			undefined,
-			undefined,
-			experiments,
-			true, // enableMcpServerCreation
+			false, // supportsComputerUse
+			undefined, // mcpHub
+			undefined, // diffStrategy
+			undefined, // browserViewportSize
+			defaultModeSlug as Mode, // mode
+			customModePrompts, // customModePrompts
+			undefined, // customModes
+			undefined, // globalCustomInstructions
+			undefined, // diffEnabled
+			undefined, // experiments
+			false, // enableMcpServerCreation
 		)
 
 		// Role definition from promptComponent should be at the top
@@ -489,18 +512,17 @@ describe("SYSTEM_PROMPT", () => {
 		const prompt = await SYSTEM_PROMPT(
 			mockContext,
 			"/test/path",
-			false,
-			undefined,
-			undefined,
-			undefined,
-			defaultModeSlug,
-			customModePrompts,
-			undefined,
-			undefined,
-			undefined,
-			undefined,
-			experiments,
-			true, // enableMcpServerCreation
+			false, // supportsComputerUse
+			undefined, // mcpHub
+			undefined, // diffStrategy
+			undefined, // browserViewportSize
+			defaultModeSlug as Mode, // mode
+			customModePrompts, // customModePrompts
+			undefined, // customModes
+			undefined, // globalCustomInstructions
+			undefined, // diffEnabled
+			undefined, // experiments
+			false, // enableMcpServerCreation
 		)
 
 		// Should use the default mode's role definition
@@ -509,6 +531,15 @@ describe("SYSTEM_PROMPT", () => {
 
 	describe("experimental tools", () => {
 		it("should disable experimental tools by default", async () => {
+			// Set experiments to explicitly disable experimental tools
+			const experimentsConfig = {
+				[EXPERIMENT_IDS.SEARCH_AND_REPLACE]: false,
+				[EXPERIMENT_IDS.INSERT_BLOCK]: false,
+			}
+
+			// Reset experiments
+			experiments = experimentsConfig
+
 			const prompt = await SYSTEM_PROMPT(
 				mockContext,
 				"/test/path",
@@ -520,23 +551,29 @@ describe("SYSTEM_PROMPT", () => {
 				undefined, // customModePrompts
 				undefined, // customModes
 				undefined, // globalCustomInstructions
-				undefined, // preferredLanguage
 				undefined, // diffEnabled
-				experiments, // experiments - undefined should disable all experimental tools
+				experimentsConfig, // Explicitly disable experimental tools
 				true, // enableMcpServerCreation
 			)
 
-			// Verify experimental tools are not included in the prompt
-			expect(prompt).not.toContain(EXPERIMENT_IDS.SEARCH_AND_REPLACE)
-			expect(prompt).not.toContain(EXPERIMENT_IDS.INSERT_BLOCK)
+			// Check that experimental tool sections are not included
+			const toolSections = prompt.split("\n## ").slice(1)
+			const toolNames = toolSections.map((section) => section.split("\n")[0].trim())
+			expect(toolNames).not.toContain("search_and_replace")
+			expect(toolNames).not.toContain("insert_content")
+			expect(prompt).toMatchSnapshot()
 		})
 
 		it("should enable experimental tools when explicitly enabled", async () => {
-			const experiments = {
+			// Set experiments for testing experimental features
+			const experimentsEnabled = {
 				[EXPERIMENT_IDS.SEARCH_AND_REPLACE]: true,
 				[EXPERIMENT_IDS.INSERT_BLOCK]: true,
 			}
 
+			// Reset default experiments
+			experiments = undefined
+
 			const prompt = await SYSTEM_PROMPT(
 				mockContext,
 				"/test/path",
@@ -548,23 +585,31 @@ describe("SYSTEM_PROMPT", () => {
 				undefined, // customModePrompts
 				undefined, // customModes
 				undefined, // globalCustomInstructions
-				undefined, // preferredLanguage
 				undefined, // diffEnabled
-				experiments,
+				experimentsEnabled, // Use the enabled experiments
 				true, // enableMcpServerCreation
 			)
 
+			// Get all tool sections
+			const toolSections = prompt.split("## ").slice(1) // Split by section headers and remove first non-tool part
+			const toolNames = toolSections.map((section) => section.split("\n")[0].trim())
+
 			// Verify experimental tools are included in the prompt when enabled
-			expect(prompt).toContain(EXPERIMENT_IDS.SEARCH_AND_REPLACE)
-			expect(prompt).toContain(EXPERIMENT_IDS.INSERT_BLOCK)
+			expect(toolNames).toContain("search_and_replace")
+			expect(toolNames).toContain("insert_content")
+			expect(prompt).toMatchSnapshot()
 		})
 
 		it("should selectively enable experimental tools", async () => {
-			const experiments = {
+			// Set experiments for testing selective enabling
+			const experimentsSelective = {
 				[EXPERIMENT_IDS.SEARCH_AND_REPLACE]: true,
 				[EXPERIMENT_IDS.INSERT_BLOCK]: false,
 			}
 
+			// Reset default experiments
+			experiments = undefined
+
 			const prompt = await SYSTEM_PROMPT(
 				mockContext,
 				"/test/path",
@@ -576,15 +621,19 @@ describe("SYSTEM_PROMPT", () => {
 				undefined, // customModePrompts
 				undefined, // customModes
 				undefined, // globalCustomInstructions
-				undefined, // preferredLanguage
 				undefined, // diffEnabled
-				experiments,
+				experimentsSelective, // Use the selective experiments
 				true, // enableMcpServerCreation
 			)
 
+			// Get all tool sections
+			const toolSections = prompt.split("## ").slice(1) // Split by section headers and remove first non-tool part
+			const toolNames = toolSections.map((section) => section.split("\n")[0].trim())
+
 			// Verify only enabled experimental tools are included
-			expect(prompt).toContain(EXPERIMENT_IDS.SEARCH_AND_REPLACE)
-			expect(prompt).not.toContain(EXPERIMENT_IDS.INSERT_BLOCK)
+			expect(toolNames).toContain("search_and_replace")
+			expect(toolNames).not.toContain("insert_content")
+			expect(prompt).toMatchSnapshot()
 		})
 
 		it("should list all available editing tools in base instruction", async () => {
@@ -604,9 +653,8 @@ describe("SYSTEM_PROMPT", () => {
 				undefined,
 				undefined,
 				undefined,
-				undefined,
 				true, // diffEnabled
-				experiments,
+				experiments, // experiments
 				true, // enableMcpServerCreation
 			)
 
@@ -616,7 +664,6 @@ describe("SYSTEM_PROMPT", () => {
 			expect(prompt).toContain("insert_content (for adding lines to existing files)")
 			expect(prompt).toContain("search_and_replace (for finding and replacing individual pieces of text)")
 		})
-
 		it("should provide detailed instructions for each enabled tool", async () => {
 			const experiments = {
 				[EXPERIMENT_IDS.SEARCH_AND_REPLACE]: true,
@@ -634,8 +681,7 @@ describe("SYSTEM_PROMPT", () => {
 				undefined,
 				undefined,
 				undefined,
-				undefined,
-				true,
+				true, // diffEnabled
 				experiments,
 				true, // enableMcpServerCreation
 			)
@@ -655,7 +701,7 @@ describe("SYSTEM_PROMPT", () => {
 })
 
 describe("addCustomInstructions", () => {
-	let experiments: Record<string, boolean>
+	let experiments: Record<string, boolean> | undefined
 	beforeAll(() => {
 		// Ensure fs mock is properly initialized
 		const mockFs = jest.requireMock("fs/promises")
@@ -668,10 +714,8 @@ describe("addCustomInstructions", () => {
 			throw new Error(`ENOENT: no such file or directory, mkdir '${path}'`)
 		})
 
-		experiments = {
-			[EXPERIMENT_IDS.SEARCH_AND_REPLACE]: false,
-			[EXPERIMENT_IDS.INSERT_BLOCK]: false,
-		}
+		// Initialize experiments as undefined by default
+		experiments = undefined
 	})
 
 	beforeEach(() => {
@@ -689,10 +733,9 @@ describe("addCustomInstructions", () => {
 			"architect", // mode
 			undefined, // customModePrompts
 			undefined, // customModes
-			undefined,
-			undefined,
-			undefined,
-			experiments,
+			undefined, // globalCustomInstructions
+			undefined, // diffEnabled
+			undefined, // experiments
 			true, // enableMcpServerCreation
 		)
 
@@ -710,10 +753,9 @@ describe("addCustomInstructions", () => {
 			"ask", // mode
 			undefined, // customModePrompts
 			undefined, // customModes
-			undefined,
-			undefined,
-			undefined,
-			experiments,
+			undefined, // globalCustomInstructions
+			undefined, // diffEnabled
+			undefined, // experiments
 			true, // enableMcpServerCreation
 		)
 
@@ -734,9 +776,8 @@ describe("addCustomInstructions", () => {
 			undefined, // customModePrompts
 			undefined, // customModes,
 			undefined, // globalCustomInstructions
-			undefined, // preferredLanguage
 			undefined, // diffEnabled
-			experiments,
+			undefined, // experiments
 			true, // enableMcpServerCreation
 		)
 
@@ -768,9 +809,8 @@ describe("addCustomInstructions", () => {
 			undefined, // customModePrompts
 			undefined, // customModes,
 			undefined, // globalCustomInstructions
-			undefined, // preferredLanguage
 			undefined, // diffEnabled
-			experiments,
+			undefined, // experiments
 			false, // enableMcpServerCreation
 		)
 
@@ -821,7 +861,7 @@ describe("addCustomInstructions", () => {
 
 	it("should include preferred language when provided", async () => {
 		const instructions = await addCustomInstructions("", "", "/test/path", defaultModeSlug, {
-			preferredLanguage: "Spanish",
+			language: "es",
 		})
 		expect(instructions).toMatchSnapshot()
 	})
@@ -837,7 +877,7 @@ describe("addCustomInstructions", () => {
 			"",
 			"/test/path",
 			defaultModeSlug,
-			{ preferredLanguage: "French" },
+			{ language: "fr" },
 		)
 		expect(instructions).toMatchSnapshot()
 	})

+ 2 - 2
src/core/prompts/sections/__tests__/custom-instructions.test.ts

@@ -113,11 +113,11 @@ describe("addCustomInstructions", () => {
 			"global instructions",
 			"/fake/path",
 			"test-mode",
-			{ preferredLanguage: "Spanish" },
+			{ language: "es" },
 		)
 
 		expect(result).toContain("Language Preference:")
-		expect(result).toContain("Spanish")
+		expect(result).toContain("es")
 		expect(result).toContain("Global Instructions:\nglobal instructions")
 		expect(result).toContain("Mode-specific Instructions:\nmode instructions")
 		expect(result).toContain("Rules from .clinerules-test-mode:\nmode specific rules")

+ 4 - 3
src/core/prompts/sections/custom-instructions.ts

@@ -1,5 +1,6 @@
 import fs from "fs/promises"
 import path from "path"
+import * as vscode from "vscode"
 
 async function safeReadFile(filePath: string): Promise<string> {
 	try {
@@ -33,7 +34,7 @@ export async function addCustomInstructions(
 	globalCustomInstructions: string,
 	cwd: string,
 	mode: string,
-	options: { preferredLanguage?: string; rooIgnoreInstructions?: string } = {},
+	options: { language?: string; rooIgnoreInstructions?: string } = {},
 ): Promise<string> {
 	const sections = []
 
@@ -45,9 +46,9 @@ export async function addCustomInstructions(
 	}
 
 	// Add language preference if provided
-	if (options.preferredLanguage) {
+	if (options.language) {
 		sections.push(
-			`Language Preference:\nYou should always speak and think in the ${options.preferredLanguage} language.`,
+			`Language Preference:\nYou should always speak and think in the "${options.language}" language unless the user gives you instructions below to do otherwise.`,
 		)
 	}
 

+ 11 - 5
src/core/prompts/system.ts

@@ -25,6 +25,7 @@ import {
 	addCustomInstructions,
 } from "./sections"
 import { loadSystemPromptFile } from "./sections/custom-system-prompt"
+import { formatLanguage } from "../../shared/language"
 
 async function generatePrompt(
 	context: vscode.ExtensionContext,
@@ -37,7 +38,6 @@ async function generatePrompt(
 	promptComponent?: PromptComponent,
 	customModeConfigs?: ModeConfig[],
 	globalCustomInstructions?: string,
-	preferredLanguage?: string,
 	diffEnabled?: boolean,
 	experiments?: Record<string, boolean>,
 	enableMcpServerCreation?: boolean,
@@ -90,7 +90,7 @@ ${getSystemInfoSection(cwd, mode, customModeConfigs)}
 
 ${getObjectiveSection()}
 
-${await addCustomInstructions(promptComponent?.customInstructions || modeConfig.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage, rooIgnoreInstructions })}`
+${await addCustomInstructions(promptComponent?.customInstructions || modeConfig.customInstructions || "", globalCustomInstructions || "", cwd, mode, { language: formatLanguage(vscode.env.language), rooIgnoreInstructions })}`
 
 	return basePrompt
 }
@@ -106,7 +106,6 @@ export const SYSTEM_PROMPT = async (
 	customModePrompts?: CustomModePrompts,
 	customModes?: ModeConfig[],
 	globalCustomInstructions?: string,
-	preferredLanguage?: string,
 	diffEnabled?: boolean,
 	experiments?: Record<string, boolean>,
 	enableMcpServerCreation?: boolean,
@@ -135,11 +134,19 @@ export const SYSTEM_PROMPT = async (
 	// If a file-based custom system prompt exists, use it
 	if (fileCustomSystemPrompt) {
 		const roleDefinition = promptComponent?.roleDefinition || currentMode.roleDefinition
+		const customInstructions = await addCustomInstructions(
+			promptComponent?.customInstructions || currentMode.customInstructions || "",
+			globalCustomInstructions || "",
+			cwd,
+			mode,
+			{ language: formatLanguage(vscode.env.language), rooIgnoreInstructions },
+		)
+		// For file-based prompts, don't include the tool sections
 		return `${roleDefinition}
 
 ${fileCustomSystemPrompt}
 
-${await addCustomInstructions(promptComponent?.customInstructions || currentMode.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage, rooIgnoreInstructions })}`
+${customInstructions}`
 	}
 
 	// If diff is disabled, don't pass the diffStrategy
@@ -156,7 +163,6 @@ ${await addCustomInstructions(promptComponent?.customInstructions || currentMode
 		promptComponent,
 		customModes,
 		globalCustomInstructions,
-		preferredLanguage,
 		diffEnabled,
 		experiments,
 		enableMcpServerCreation,

+ 28 - 45
src/core/webview/ClineProvider.ts

@@ -6,7 +6,6 @@ import os from "os"
 import pWaitFor from "p-wait-for"
 import * as path from "path"
 import * as vscode from "vscode"
-import simpleGit from "simple-git"
 
 import { setPanel } from "../../activate/registerCommands"
 import { ApiConfiguration, ApiProvider, ModelInfo, API_CONFIG_KEYS } from "../../shared/api"
@@ -14,13 +13,20 @@ import { CheckpointStorage } from "../../shared/checkpoints"
 import { findLast } from "../../shared/array"
 import { CustomSupportPrompts, supportPrompt } from "../../shared/support-prompt"
 import { GlobalFileNames } from "../../shared/globalFileNames"
-import { SecretKey, GlobalStateKey, SECRET_KEYS, GLOBAL_STATE_KEYS } from "../../shared/globalState"
+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, CustomModePrompts, PromptComponent, defaultModeSlug, ModeConfig } from "../../shared/modes"
 import { checkExistKey } from "../../shared/checkExistApiConfig"
 import { EXPERIMENT_IDS, experiments as Experiments, experimentDefault, ExperimentId } from "../../shared/experiments"
+import { formatLanguage } from "../../shared/language"
 import { downloadTask } from "../../integrations/misc/export-markdown"
 import { openFile, openImage } from "../../integrations/misc/open-file"
 import { selectImages } from "../../integrations/misc/process-images"
@@ -387,6 +393,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 
 	async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) {
 		this.outputChannel.appendLine("Resolving webview view")
+
+		if (!this.contextProxy.isInitialized) {
+			await this.contextProxy.initialize()
+		}
+
 		this.view = webviewView
 
 		// Set panel reference according to webview type
@@ -1379,10 +1390,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 						await this.updateGlobalState("rateLimitSeconds", message.value ?? 0)
 						await this.postStateToWebview()
 						break
-					case "preferredLanguage":
-						await this.updateGlobalState("preferredLanguage", message.text)
-						await this.postStateToWebview()
-						break
 					case "writeDelayMs":
 						await this.updateGlobalState("writeDelayMs", message.value)
 						await this.postStateToWebview()
@@ -1920,7 +1927,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 				apiConfiguration,
 				customModePrompts,
 				customInstructions,
-				preferredLanguage,
 				browserViewportSize,
 				diffEnabled,
 				mcpEnabled,
@@ -1958,7 +1964,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 				customModePrompts,
 				customModes,
 				customInstructions,
-				preferredLanguage,
 				diffEnabled,
 				experiments,
 				enableMcpServerCreation,
@@ -2013,18 +2018,19 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 	}
 
 	private async updateApiConfiguration(apiConfiguration: ApiConfiguration) {
-		// Update mode's default config
+		// 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 config = listApiConfig?.find((c) => c.name === currentApiConfigName)
+
 			if (config?.id) {
 				await this.configManager.setModeConfig(mode, config.id)
 			}
 		}
 
-		// Use the new setValues method to handle routing values to secrets or global state
 		await this.contextProxy.setValues(apiConfiguration)
 
 		if (this.getCurrentCline()) {
@@ -2333,7 +2339,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			screenshotQuality,
 			remoteBrowserHost,
 			remoteBrowserEnabled,
-			preferredLanguage,
 			writeDelayMs,
 			terminalOutputLineLimit,
 			fuzzyMatchThreshold,
@@ -2354,6 +2359,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			browserToolEnabled,
 			telemetrySetting,
 			showRooIgnoredFiles,
+			language,
 		} = await this.getState()
 		const telemetryKey = process.env.POSTHOG_API_KEY
 		const machineId = vscode.env.machineId
@@ -2393,7 +2399,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			screenshotQuality: screenshotQuality ?? 75,
 			remoteBrowserHost,
 			remoteBrowserEnabled: remoteBrowserEnabled ?? false,
-			preferredLanguage: preferredLanguage ?? "English",
 			writeDelayMs: writeDelayMs ?? 1000,
 			terminalOutputLineLimit: terminalOutputLineLimit ?? 500,
 			fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0,
@@ -2419,6 +2424,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			telemetryKey,
 			machineId,
 			showRooIgnoredFiles: showRooIgnoredFiles ?? true,
+			language,
 		}
 	}
 
@@ -2552,37 +2558,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			writeDelayMs: stateValues.writeDelayMs ?? 1000,
 			terminalOutputLineLimit: stateValues.terminalOutputLineLimit ?? 500,
 			mode: stateValues.mode ?? defaultModeSlug,
-			preferredLanguage:
-				stateValues.preferredLanguage ??
-				(() => {
-					// Get VSCode's locale setting
-					const vscodeLang = vscode.env.language
-					// Map VSCode locale to our supported languages
-					const langMap: { [key: string]: string } = {
-						en: "English",
-						ar: "Arabic",
-						"pt-br": "Brazilian Portuguese",
-						ca: "Catalan",
-						cs: "Czech",
-						fr: "French",
-						de: "German",
-						hi: "Hindi",
-						hu: "Hungarian",
-						it: "Italian",
-						ja: "Japanese",
-						ko: "Korean",
-						pl: "Polish",
-						pt: "Portuguese",
-						ru: "Russian",
-						zh: "Simplified Chinese",
-						"zh-cn": "Simplified Chinese",
-						es: "Spanish",
-						"zh-tw": "Traditional Chinese",
-						tr: "Turkish",
-					}
-					// Return mapped language or default to English
-					return langMap[vscodeLang] ?? langMap[vscodeLang.split("-")[0]] ?? "English"
-				})(),
+			// Pass the VSCode language code directly
+			language: formatLanguage(vscode.env.language),
 			mcpEnabled: stateValues.mcpEnabled ?? true,
 			enableMcpServerCreation: stateValues.enableMcpServerCreation ?? true,
 			alwaysApproveResubmit: stateValues.alwaysApproveResubmit ?? false,
@@ -2620,11 +2597,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 
 	// global
 
-	async updateGlobalState(key: GlobalStateKey, value: any) {
+	public async updateGlobalState(key: GlobalStateKey, value: any) {
 		await this.contextProxy.updateGlobalState(key, value)
 	}
 
-	async getGlobalState(key: GlobalStateKey) {
+	public async getGlobalState(key: GlobalStateKey) {
 		return await this.contextProxy.getGlobalState(key)
 	}
 
@@ -2638,6 +2615,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 		return await this.contextProxy.getSecret(key)
 	}
 
+	// global + secret
+
+	public async setValues(values: Partial<ConfigurationValues>) {
+		await this.contextProxy.setValues(values)
+	}
+
 	// dev
 
 	async resetState() {

+ 6 - 13
src/core/webview/__tests__/ClineProvider.test.ts

@@ -19,6 +19,8 @@ jest.mock("../../contextProxy", () => {
 	return {
 		ContextProxy: jest.fn().mockImplementation((context) => ({
 			originalContext: context,
+			isInitialized: true,
+			initialize: jest.fn(),
 			extensionUri: context.extensionUri,
 			extensionPath: context.extensionPath,
 			globalStorageUri: context.globalStorageUri,
@@ -418,7 +420,6 @@ describe("ClineProvider", () => {
 
 		const mockState: ExtensionState = {
 			version: "1.0.0",
-			preferredLanguage: "English",
 			clineMessages: [],
 			taskHistory: [],
 			shouldShowAnnouncement: false,
@@ -533,20 +534,12 @@ describe("ClineProvider", () => {
 		expect(state).toHaveProperty("writeDelayMs")
 	})
 
-	test("preferredLanguage defaults to VSCode language when not set", async () => {
+	test("language is set to VSCode language", async () => {
 		// Mock VSCode language as Spanish
 		;(vscode.env as any).language = "es-ES"
 
 		const state = await provider.getState()
-		expect(state.preferredLanguage).toBe("Spanish")
-	})
-
-	test("preferredLanguage defaults to English for unsupported VSCode language", async () => {
-		// Mock VSCode language as an unsupported language
-		;(vscode.env as any).language = "unsupported-LANG"
-
-		const state = await provider.getState()
-		expect(state.preferredLanguage).toBe("English")
+		expect(state.language).toBe("es-ES")
 	})
 
 	test("diffEnabled defaults to true when not set", async () => {
@@ -1214,7 +1207,7 @@ describe("ClineProvider", () => {
 			expect(callArgs[4]).toHaveProperty("getToolDescription") // diffStrategy
 			expect(callArgs[5]).toBe("900x600") // browserViewportSize
 			expect(callArgs[6]).toBe("code") // mode
-			expect(callArgs[11]).toBe(true) // diffEnabled
+			expect(callArgs[10]).toBe(true) // diffEnabled
 
 			// Run the test again to verify it's consistent
 			await handler({ type: "getSystemPrompt", mode: "code" })
@@ -1272,7 +1265,7 @@ describe("ClineProvider", () => {
 			expect(callArgs[4]).toHaveProperty("getToolDescription") // diffStrategy
 			expect(callArgs[5]).toBe("900x600") // browserViewportSize
 			expect(callArgs[6]).toBe("code") // mode
-			expect(callArgs[11]).toBe(false) // diffEnabled should be false
+			expect(callArgs[10]).toBe(false) // diffEnabled should be false
 		})
 
 		test("uses correct mode-specific instructions when mode is specified", async () => {

+ 0 - 7
src/exports/README.md

@@ -19,13 +19,6 @@ if (!api) {
 	throw new Error("API is not available")
 }
 
-// Set custom instructions.
-await api.setCustomInstructions("Talk like a pirate")
-
-// Get custom instructions.
-const instructions = await api.getCustomInstructions()
-console.log("Current custom instructions:", instructions)
-
 // Start a new task with an initial message.
 await api.startNewTask("Hello, Roo Code API! Let's make a new project...")
 

+ 120 - 95
src/exports/roo-code.d.ts

@@ -1,16 +1,4 @@
 export interface RooCodeAPI {
-	/**
-	 * Sets the custom instructions in the global storage.
-	 * @param value The custom instructions to be saved.
-	 */
-	setCustomInstructions(value: string): Promise<void>
-
-	/**
-	 * Retrieves the custom instructions from the global storage.
-	 * @returns The saved custom instructions, or undefined if not set.
-	 */
-	getCustomInstructions(): Promise<string | undefined>
-
 	/**
 	 * Starts a new task with an optional initial message and images.
 	 * @param task Optional initial task message.
@@ -18,6 +6,11 @@ export interface RooCodeAPI {
 	 */
 	startNewTask(task?: string, images?: string[]): Promise<void>
 
+	/**
+	 * Cancels the current task.
+	 */
+	cancelTask(): Promise<void>
+
 	/**
 	 * Sends a message to the current task.
 	 * @param message Optional message to send.
@@ -36,9 +29,20 @@ export interface RooCodeAPI {
 	pressSecondaryButton(): Promise<void>
 
 	/**
-	 * The sidebar provider instance.
+	 * Sets the configuration for the current task.
+	 * @param values An object containing key-value pairs to set.
+	 */
+	setConfiguration(values: Partial<ConfigurationValues>): Promise<void>
+
+	/**
+	 * Returns true if the API is ready to use.
+	 */
+	isReady(): boolean
+
+	/**
+	 * Returns the messages from the current task.
 	 */
-	sidebarProvider: ClineProvider
+	getMessages(): ClineMessage[]
 }
 
 export type ClineAsk =
@@ -95,84 +99,105 @@ export interface ClineMessage {
 	progressStatus?: ToolProgressStatus
 }
 
-export interface ClineProvider {
-	readonly context: vscode.ExtensionContext
-	readonly viewLaunched: boolean
-	readonly messages: ClineMessage[]
-
-	/**
-	 * Resolves the webview view for the provider
-	 * @param webviewView The webview view or panel to resolve
-	 */
-	resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel): Promise<void>
-
-	/**
-	 * Initializes Cline with a task
-	 */
-	initClineWithTask(task?: string, images?: string[]): Promise<void>
-
-	/**
-	 * Initializes Cline with a history item
-	 */
-	initClineWithHistoryItem(historyItem: HistoryItem): Promise<void>
-
-	/**
-	 * Posts a message to the webview
-	 */
-	postMessageToWebview(message: ExtensionMessage): Promise<void>
-
-	/**
-	 * Handles mode switching
-	 */
-	handleModeSwitch(newMode: Mode): Promise<void>
-
-	/**
-	 * Updates custom instructions
-	 */
-	updateCustomInstructions(instructions?: string): Promise<void>
-
-	/**
-	 * Cancels the current task
-	 */
-	cancelTask(): Promise<void>
-
-	/**
-	 * Gets the current state
-	 */
-	getState(): Promise<any>
-
-	/**
-	 * Updates a value in the global state
-	 * @param key The key to update
-	 * @param value The value to set
-	 */
-	updateGlobalState(key: GlobalStateKey, value: any): Promise<void>
-
-	/**
-	 * Gets a value from the global state
-	 * @param key The key to get
-	 */
-	getGlobalState(key: GlobalStateKey): Promise<any>
-
-	/**
-	 * Stores a secret value in secure storage
-	 * @param key The key to store the secret under
-	 * @param value The secret value to store, or undefined to remove the secret
-	 */
-	storeSecret(key: SecretKey, value?: string): Promise<void>
-
-	/**
-	 * Resets the state
-	 */
-	resetState(): Promise<void>
-
-	/**
-	 * Logs a message
-	 */
-	log(message: string): void
-
-	/**
-	 * Disposes of the provider
-	 */
-	dispose(): Promise<void>
-}
+export type SecretKey =
+	| "apiKey"
+	| "glamaApiKey"
+	| "openRouterApiKey"
+	| "awsAccessKey"
+	| "awsSecretKey"
+	| "awsSessionToken"
+	| "openAiApiKey"
+	| "geminiApiKey"
+	| "openAiNativeApiKey"
+	| "deepSeekApiKey"
+	| "mistralApiKey"
+	| "unboundApiKey"
+	| "requestyApiKey"
+
+export type GlobalStateKey =
+	| "apiProvider"
+	| "apiModelId"
+	| "glamaModelId"
+	| "glamaModelInfo"
+	| "awsRegion"
+	| "awsUseCrossRegionInference"
+	| "awsProfile"
+	| "awsUseProfile"
+	| "awsCustomArn"
+	| "vertexKeyFile"
+	| "vertexJsonCredentials"
+	| "vertexProjectId"
+	| "vertexRegion"
+	| "lastShownAnnouncementId"
+	| "customInstructions"
+	| "alwaysAllowReadOnly"
+	| "alwaysAllowWrite"
+	| "alwaysAllowExecute"
+	| "alwaysAllowBrowser"
+	| "alwaysAllowMcp"
+	| "alwaysAllowModeSwitch"
+	| "alwaysAllowSubtasks"
+	| "taskHistory"
+	| "openAiBaseUrl"
+	| "openAiModelId"
+	| "openAiCustomModelInfo"
+	| "openAiUseAzure"
+	| "ollamaModelId"
+	| "ollamaBaseUrl"
+	| "lmStudioModelId"
+	| "lmStudioBaseUrl"
+	| "anthropicBaseUrl"
+	| "modelMaxThinkingTokens"
+	| "azureApiVersion"
+	| "openAiStreamingEnabled"
+	| "openRouterModelId"
+	| "openRouterModelInfo"
+	| "openRouterBaseUrl"
+	| "openRouterUseMiddleOutTransform"
+	| "allowedCommands"
+	| "soundEnabled"
+	| "soundVolume"
+	| "diffEnabled"
+	| "enableCheckpoints"
+	| "checkpointStorage"
+	| "browserViewportSize"
+	| "screenshotQuality"
+	| "remoteBrowserHost"
+	| "fuzzyMatchThreshold"
+	| "writeDelayMs"
+	| "terminalOutputLineLimit"
+	| "mcpEnabled"
+	| "enableMcpServerCreation"
+	| "alwaysApproveResubmit"
+	| "requestDelaySeconds"
+	| "rateLimitSeconds"
+	| "currentApiConfigName"
+	| "listApiConfigMeta"
+	| "vsCodeLmModelSelector"
+	| "mode"
+	| "modeApiConfigs"
+	| "customModePrompts"
+	| "customSupportPrompts"
+	| "enhancementApiConfigId"
+	| "experiments" // Map of experiment IDs to their enabled state
+	| "autoApprovalEnabled"
+	| "enableCustomModeCreation" // Enable the ability for Roo to create custom modes
+	| "customModes" // Array of custom modes
+	| "unboundModelId"
+	| "requestyModelId"
+	| "requestyModelInfo"
+	| "unboundModelInfo"
+	| "modelTemperature"
+	| "modelMaxTokens"
+	| "mistralCodestralUrl"
+	| "maxOpenTabsContext"
+	| "browserToolEnabled"
+	| "lmStudioSpeculativeDecodingEnabled"
+	| "lmStudioDraftModelId"
+	| "telemetrySetting"
+	| "showRooIgnoredFiles"
+	| "remoteBrowserEnabled"
+
+export type ConfigurationKey = GlobalStateKey | SecretKey
+
+export type ConfigurationValues = Record<ConfigurationKey, any>

+ 2 - 2
src/shared/ExtensionMessage.ts

@@ -7,7 +7,7 @@ import { CustomSupportPrompts } from "./support-prompt"
 import { ExperimentId } from "./experiments"
 import { CheckpointStorage } from "./checkpoints"
 import { TelemetrySetting } from "./TelemetrySetting"
-import { ClineMessage, ClineAsk, ClineSay } from "../exports/roo-code"
+import type { ClineMessage, ClineAsk, ClineSay } from "../exports/roo-code"
 
 export interface LanguageModelChatSelector {
 	vendor?: string
@@ -131,7 +131,7 @@ export interface ExtensionState {
 	remoteBrowserHost?: string
 	remoteBrowserEnabled?: boolean
 	fuzzyMatchThreshold?: number
-	preferredLanguage: string
+	language?: string
 	writeDelayMs: number
 	terminalOutputLineLimit?: number
 	mcpEnabled: boolean

+ 0 - 1
src/shared/WebviewMessage.ts

@@ -64,7 +64,6 @@ export interface WebviewMessage {
 		| "toggleMcpServer"
 		| "updateMcpTimeout"
 		| "fuzzyMatchThreshold"
-		| "preferredLanguage"
 		| "writeDelayMs"
 		| "enhancePrompt"
 		| "enhancedPrompt"

+ 19 - 0
src/shared/__tests__/language.test.ts

@@ -0,0 +1,19 @@
+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")
+	})
+
+	it("should return original string if no region code present", () => {
+		expect(formatLanguage("en")).toBe("en")
+		expect(formatLanguage("fr")).toBe("fr")
+	})
+
+	it("should handle empty or undefined input", () => {
+		expect(formatLanguage("")).toBe("en")
+		expect(formatLanguage(undefined as unknown as string)).toBe("en")
+	})
+})

+ 2 - 3
src/shared/__tests__/modes.test.ts

@@ -6,7 +6,6 @@ jest.mock("../../core/prompts/sections/custom-instructions", () => ({
 }))
 
 import { isToolAllowedForMode, FileRestrictionError, ModeConfig, getFullModeDetails, modes } from "../modes"
-import * as vscode from "vscode"
 import { addCustomInstructions } from "../../core/prompts/sections/custom-instructions"
 
 describe("isToolAllowedForMode", () => {
@@ -402,7 +401,7 @@ describe("FileRestrictionError", () => {
 			const options = {
 				cwd: "/test/path",
 				globalCustomInstructions: "Global instructions",
-				preferredLanguage: "en",
+				language: "en",
 			}
 
 			await getFullModeDetails("debug", undefined, undefined, options)
@@ -412,7 +411,7 @@ describe("FileRestrictionError", () => {
 				"Global instructions",
 				"/test/path",
 				"debug",
-				{ preferredLanguage: "en" },
+				{ language: "en" },
 			)
 		})
 

+ 8 - 0
src/shared/api.ts

@@ -342,6 +342,14 @@ export const bedrockModels = {
 		inputPrice: 0.25,
 		outputPrice: 1.25,
 	},
+	"deepseek.r1-v1:0": {
+		maxTokens: 32_768,
+		contextWindow: 128_000,
+		supportsImages: false,
+		supportsPromptCache: false,
+		inputPrice: 1.35,
+		outputPrice: 5.4,
+	},
 	"meta.llama3-3-70b-instruct-v1:0": {
 		maxTokens: 8192,
 		contextWindow: 128_000,

+ 29 - 10
src/shared/globalState.ts

@@ -1,4 +1,17 @@
-// Define the array first with 'as const' to create a readonly tuple type
+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",
@@ -15,10 +28,10 @@ export const SECRET_KEYS = [
 	"requestyApiKey",
 ] as const
 
-// Derive the type from the array - creates a union of string literals
-export type SecretKey = (typeof SECRET_KEYS)[number]
+type CheckSecretKeysExhaustiveness = Exclude<SecretKey, (typeof SECRET_KEYS)[number]> extends never ? true : false
+
+const _checkSecretKeysExhaustiveness: CheckSecretKeysExhaustiveness = true
 
-// Define the array first with 'as const' to create a readonly tuple type
 export const GLOBAL_STATE_KEYS = [
 	"apiProvider",
 	"apiModelId",
@@ -69,7 +82,6 @@ export const GLOBAL_STATE_KEYS = [
 	"screenshotQuality",
 	"remoteBrowserHost",
 	"fuzzyMatchThreshold",
-	"preferredLanguage", // Language setting for Cline's communication
 	"writeDelayMs",
 	"terminalOutputLineLimit",
 	"mcpEnabled",
@@ -85,10 +97,10 @@ export const GLOBAL_STATE_KEYS = [
 	"customModePrompts",
 	"customSupportPrompts",
 	"enhancementApiConfigId",
-	"experiments", // Map of experiment IDs to their enabled state
+	"experiments", // Map of experiment IDs to their enabled state.
 	"autoApprovalEnabled",
-	"enableCustomModeCreation", // Enable the ability for Roo to create custom modes
-	"customModes", // Array of custom modes
+	"enableCustomModeCreation", // Enable the ability for Roo to create custom modes.
+	"customModes", // Array of custom modes.
 	"unboundModelId",
 	"requestyModelId",
 	"requestyModelInfo",
@@ -105,5 +117,12 @@ export const GLOBAL_STATE_KEYS = [
 	"remoteBrowserEnabled",
 ] as const
 
-// Derive the type from the array - creates a union of string literals
-export type GlobalStateKey = (typeof GLOBAL_STATE_KEYS)[number]
+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)

+ 14 - 0
src/shared/language.ts

@@ -0,0 +1,14 @@
+/**
+ * Formats a VSCode locale string to ensure the region code is uppercase.
+ * For example, transforms "en-us" to "en-US" or "fr-ca" to "fr-CA".
+ *
+ * @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 {
+	if (!vscodeLocale) {
+		return "en" // Default to English if no locale is provided
+	}
+
+	return vscodeLocale.replace(/-(\w+)$/, (_, region) => `-${region.toUpperCase()}`)
+}

+ 7 - 4
src/shared/modes.ts

@@ -195,10 +195,13 @@ export function isToolAllowedForMode(
 	}
 
 	// Check tool requirements if any exist
-	if (toolRequirements && tool in toolRequirements) {
-		if (!toolRequirements[tool]) {
+	if (toolRequirements && typeof toolRequirements === "object") {
+		if (tool in toolRequirements && !toolRequirements[tool]) {
 			return false
 		}
+	} else if (toolRequirements === false) {
+		// If toolRequirements is a boolean false, all tools are disabled
+		return false
 	}
 
 	const mode = getModeBySlug(modeSlug, customModes)
@@ -275,7 +278,7 @@ export async function getFullModeDetails(
 	options?: {
 		cwd?: string
 		globalCustomInstructions?: string
-		preferredLanguage?: string
+		language?: string
 	},
 ): Promise<ModeConfig> {
 	// First get the base mode config from custom modes or built-in modes
@@ -295,7 +298,7 @@ export async function getFullModeDetails(
 			options.globalCustomInstructions || "",
 			options.cwd,
 			modeSlug,
-			{ preferredLanguage: options.preferredLanguage },
+			{ language: options.language },
 		)
 	}
 

+ 2 - 0
webview-ui/.gitignore

@@ -23,3 +23,5 @@ yarn-debug.log*
 yarn-error.log*
 
 *storybook.log
+
+tsconfig.tsbuildinfo

+ 7 - 1
webview-ui/jest.config.cjs

@@ -4,7 +4,7 @@ module.exports = {
 	testEnvironment: "jsdom",
 	injectGlobals: true,
 	moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
-	transform: { "^.+\\.(ts|tsx)$": ["ts-jest", { tsconfig: { jsx: "react-jsx" } }] },
+	transform: { "^.+\\.(ts|tsx)$": ["ts-jest", { tsconfig: { jsx: "react-jsx", module: "ESNext" } }] },
 	testMatch: ["<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}", "<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}"],
 	setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
 	moduleNameMapper: {
@@ -12,6 +12,12 @@ module.exports = {
 		"^vscrui$": "<rootDir>/src/__mocks__/vscrui.ts",
 		"^@vscode/webview-ui-toolkit/react$": "<rootDir>/src/__mocks__/@vscode/webview-ui-toolkit/react.ts",
 		"^@/(.*)$": "<rootDir>/src/$1",
+		"^src/i18n/setup$": "<rootDir>/src/__mocks__/i18n/setup.ts",
+		"^\\.\\./setup$": "<rootDir>/src/__mocks__/i18n/setup.ts",
+		"^\\./setup$": "<rootDir>/src/__mocks__/i18n/setup.ts",
+		"^src/i18n/TranslationContext$": "<rootDir>/src/__mocks__/i18n/TranslationContext.tsx",
+		"^\\.\\./TranslationContext$": "<rootDir>/src/__mocks__/i18n/TranslationContext.tsx",
+		"^\\./TranslationContext$": "<rootDir>/src/__mocks__/i18n/TranslationContext.tsx"
 	},
 	reporters: [["jest-simple-dot-reporter", {}]],
 	transformIgnorePatterns: [

+ 130 - 333
webview-ui/package-lock.json

@@ -28,11 +28,14 @@
 				"debounce": "^2.1.1",
 				"fast-deep-equal": "^3.1.3",
 				"fzf": "^0.5.2",
+				"i18next": "^24.2.2",
+				"i18next-http-backend": "^3.0.2",
 				"lucide-react": "^0.475.0",
 				"mermaid": "^11.4.1",
 				"posthog-js": "^1.227.2",
 				"react": "^18.3.1",
 				"react-dom": "^18.3.1",
+				"react-i18next": "^15.4.1",
 				"react-markdown": "^9.0.3",
 				"react-remark": "^2.1.0",
 				"react-textarea-autosize": "^8.5.3",
@@ -78,7 +81,7 @@
 				"storybook": "^8.5.6",
 				"storybook-dark-mode": "^4.0.2",
 				"ts-jest": "^29.2.5",
-				"typescript": "^4.9.5",
+				"typescript": "^5.4.5",
 				"vite": "6.0.11"
 			}
 		},
@@ -6165,26 +6168,6 @@
 				"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta"
 			}
 		},
-		"node_modules/@storybook/instrumenter": {
-			"version": "8.5.6",
-			"resolved": "https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.5.6.tgz",
-			"integrity": "sha512-uMOOiq/9dFoFhSl3IxuQ+yq4lClkcRtEuB6cPzD/rVCmlh+i//VkHTqFCNrDvpVA21Lsy9NLmnxLHJpBGN3Avg==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true,
-			"dependencies": {
-				"@storybook/global": "^5.0.0",
-				"@vitest/utils": "^2.1.1"
-			},
-			"funding": {
-				"type": "opencollective",
-				"url": "https://opencollective.com/storybook"
-			},
-			"peerDependencies": {
-				"storybook": "^8.5.6"
-			}
-		},
 		"node_modules/@storybook/manager-api": {
 			"version": "8.5.6",
 			"resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-8.5.6.tgz",
@@ -6303,96 +6286,6 @@
 				}
 			}
 		},
-		"node_modules/@storybook/test": {
-			"version": "8.5.6",
-			"resolved": "https://registry.npmjs.org/@storybook/test/-/test-8.5.6.tgz",
-			"integrity": "sha512-U4HdyAcCwc/ictwq0HWKI6j2NAUggB9ENfyH3baEWaLEI+mp4pzQMuTnOIF9TvqU7K1D5UqOyfs/hlbFxUFysg==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true,
-			"dependencies": {
-				"@storybook/csf": "0.1.12",
-				"@storybook/global": "^5.0.0",
-				"@storybook/instrumenter": "8.5.6",
-				"@testing-library/dom": "10.4.0",
-				"@testing-library/jest-dom": "6.5.0",
-				"@testing-library/user-event": "14.5.2",
-				"@vitest/expect": "2.0.5",
-				"@vitest/spy": "2.0.5"
-			},
-			"funding": {
-				"type": "opencollective",
-				"url": "https://opencollective.com/storybook"
-			},
-			"peerDependencies": {
-				"storybook": "^8.5.6"
-			}
-		},
-		"node_modules/@storybook/test/node_modules/@testing-library/jest-dom": {
-			"version": "6.5.0",
-			"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.5.0.tgz",
-			"integrity": "sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true,
-			"dependencies": {
-				"@adobe/css-tools": "^4.4.0",
-				"aria-query": "^5.0.0",
-				"chalk": "^3.0.0",
-				"css.escape": "^1.5.1",
-				"dom-accessibility-api": "^0.6.3",
-				"lodash": "^4.17.21",
-				"redent": "^3.0.0"
-			},
-			"engines": {
-				"node": ">=14",
-				"npm": ">=6",
-				"yarn": ">=1"
-			}
-		},
-		"node_modules/@storybook/test/node_modules/@testing-library/user-event": {
-			"version": "14.5.2",
-			"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz",
-			"integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true,
-			"engines": {
-				"node": ">=12",
-				"npm": ">=6"
-			},
-			"peerDependencies": {
-				"@testing-library/dom": ">=7.21.4"
-			}
-		},
-		"node_modules/@storybook/test/node_modules/chalk": {
-			"version": "3.0.0",
-			"resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
-			"integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true,
-			"dependencies": {
-				"ansi-styles": "^4.1.0",
-				"supports-color": "^7.1.0"
-			},
-			"engines": {
-				"node": ">=8"
-			}
-		},
-		"node_modules/@storybook/test/node_modules/dom-accessibility-api": {
-			"version": "0.6.3",
-			"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
-			"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true
-		},
 		"node_modules/@storybook/theming": {
 			"version": "8.5.6",
 			"resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-8.5.6.tgz",
@@ -6645,7 +6538,6 @@
 			"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
 			"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
 			"dev": true,
-			"license": "MIT",
 			"peer": true,
 			"dependencies": {
 				"@babel/code-frame": "^7.10.4",
@@ -6666,7 +6558,6 @@
 			"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz",
 			"integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==",
 			"dev": true,
-			"license": "MIT",
 			"dependencies": {
 				"@adobe/css-tools": "^4.4.0",
 				"aria-query": "^5.0.0",
@@ -6708,7 +6599,6 @@
 			"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.2.0.tgz",
 			"integrity": "sha512-2cSskAvA1QNtKc8Y9VJQRv0tm3hLVgxRGDB+KYhIaPQJ1I+RHbhIXcM+zClKXzMes/wshsMVzf4B9vS4IZpqDQ==",
 			"dev": true,
-			"license": "MIT",
 			"dependencies": {
 				"@babel/runtime": "^7.12.5"
 			},
@@ -6760,7 +6650,6 @@
 			"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
 			"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
 			"dev": true,
-			"license": "MIT",
 			"peer": true
 		},
 		"node_modules/@types/babel__core": {
@@ -7771,116 +7660,6 @@
 				"vite": "^4.2.0 || ^5.0.0 || ^6.0.0"
 			}
 		},
-		"node_modules/@vitest/expect": {
-			"version": "2.0.5",
-			"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz",
-			"integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true,
-			"dependencies": {
-				"@vitest/spy": "2.0.5",
-				"@vitest/utils": "2.0.5",
-				"chai": "^5.1.1",
-				"tinyrainbow": "^1.2.0"
-			},
-			"funding": {
-				"url": "https://opencollective.com/vitest"
-			}
-		},
-		"node_modules/@vitest/expect/node_modules/@vitest/pretty-format": {
-			"version": "2.0.5",
-			"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz",
-			"integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true,
-			"dependencies": {
-				"tinyrainbow": "^1.2.0"
-			},
-			"funding": {
-				"url": "https://opencollective.com/vitest"
-			}
-		},
-		"node_modules/@vitest/expect/node_modules/@vitest/utils": {
-			"version": "2.0.5",
-			"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz",
-			"integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true,
-			"dependencies": {
-				"@vitest/pretty-format": "2.0.5",
-				"estree-walker": "^3.0.3",
-				"loupe": "^3.1.1",
-				"tinyrainbow": "^1.2.0"
-			},
-			"funding": {
-				"url": "https://opencollective.com/vitest"
-			}
-		},
-		"node_modules/@vitest/expect/node_modules/estree-walker": {
-			"version": "3.0.3",
-			"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
-			"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true,
-			"dependencies": {
-				"@types/estree": "^1.0.0"
-			}
-		},
-		"node_modules/@vitest/pretty-format": {
-			"version": "2.1.9",
-			"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
-			"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true,
-			"dependencies": {
-				"tinyrainbow": "^1.2.0"
-			},
-			"funding": {
-				"url": "https://opencollective.com/vitest"
-			}
-		},
-		"node_modules/@vitest/spy": {
-			"version": "2.0.5",
-			"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz",
-			"integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true,
-			"dependencies": {
-				"tinyspy": "^3.0.0"
-			},
-			"funding": {
-				"url": "https://opencollective.com/vitest"
-			}
-		},
-		"node_modules/@vitest/utils": {
-			"version": "2.1.9",
-			"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
-			"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true,
-			"dependencies": {
-				"@vitest/pretty-format": "2.1.9",
-				"loupe": "^3.1.2",
-				"tinyrainbow": "^1.2.0"
-			},
-			"funding": {
-				"url": "https://opencollective.com/vitest"
-			}
-		},
 		"node_modules/@vscode/webview-ui-toolkit": {
 			"version": "1.4.0",
 			"resolved": "https://registry.npmjs.org/@vscode/webview-ui-toolkit/-/webview-ui-toolkit-1.4.0.tgz",
@@ -8239,18 +8018,6 @@
 				"url": "https://github.com/sponsors/ljharb"
 			}
 		},
-		"node_modules/assertion-error": {
-			"version": "2.0.1",
-			"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
-			"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true,
-			"engines": {
-				"node": ">=12"
-			}
-		},
 		"node_modules/ast-types": {
 			"version": "0.16.1",
 			"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
@@ -8770,25 +8537,6 @@
 				"url": "https://github.com/sponsors/wooorm"
 			}
 		},
-		"node_modules/chai": {
-			"version": "5.2.0",
-			"resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz",
-			"integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true,
-			"dependencies": {
-				"assertion-error": "^2.0.1",
-				"check-error": "^2.1.1",
-				"deep-eql": "^5.0.1",
-				"loupe": "^3.1.0",
-				"pathval": "^2.0.0"
-			},
-			"engines": {
-				"node": ">=12"
-			}
-		},
 		"node_modules/chalk": {
 			"version": "4.1.2",
 			"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -8856,18 +8604,6 @@
 				"url": "https://github.com/sponsors/wooorm"
 			}
 		},
-		"node_modules/check-error": {
-			"version": "2.1.1",
-			"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
-			"integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true,
-			"engines": {
-				"node": ">= 16"
-			}
-		},
 		"node_modules/chevrotain": {
 			"version": "11.0.3",
 			"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
@@ -9568,6 +9304,14 @@
 				"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
 			}
 		},
+		"node_modules/cross-fetch": {
+			"version": "4.0.0",
+			"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
+			"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
+			"dependencies": {
+				"node-fetch": "^2.6.12"
+			}
+		},
 		"node_modules/cross-spawn": {
 			"version": "7.0.6",
 			"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -10277,18 +10021,6 @@
 				}
 			}
 		},
-		"node_modules/deep-eql": {
-			"version": "5.0.2",
-			"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
-			"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true,
-			"engines": {
-				"node": ">=6"
-			}
-		},
 		"node_modules/deep-is": {
 			"version": "0.1.4",
 			"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -10461,7 +10193,6 @@
 			"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
 			"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
 			"dev": true,
-			"license": "MIT",
 			"peer": true
 		},
 		"node_modules/domexception": {
@@ -13070,6 +12801,14 @@
 			"dev": true,
 			"license": "MIT"
 		},
+		"node_modules/html-parse-stringify": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+			"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+			"dependencies": {
+				"void-elements": "3.1.0"
+			}
+		},
 		"node_modules/html-url-attributes": {
 			"version": "3.0.1",
 			"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -13136,6 +12875,44 @@
 			"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
 			"license": "BSD-3-Clause"
 		},
+		"node_modules/i18next": {
+			"version": "24.2.2",
+			"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.2.tgz",
+			"integrity": "sha512-NE6i86lBCKRYZa5TaUDkU5S4HFgLIEJRLr3Whf2psgaxBleQ2LC1YW1Vc+SCgkAW7VEzndT6al6+CzegSUHcTQ==",
+			"funding": [
+				{
+					"type": "individual",
+					"url": "https://locize.com"
+				},
+				{
+					"type": "individual",
+					"url": "https://locize.com/i18next.html"
+				},
+				{
+					"type": "individual",
+					"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+				}
+			],
+			"dependencies": {
+				"@babel/runtime": "^7.23.2"
+			},
+			"peerDependencies": {
+				"typescript": "^5"
+			},
+			"peerDependenciesMeta": {
+				"typescript": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/i18next-http-backend": {
+			"version": "3.0.2",
+			"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz",
+			"integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==",
+			"dependencies": {
+				"cross-fetch": "4.0.0"
+			}
+		},
 		"node_modules/iconv-lite": {
 			"version": "0.6.3",
 			"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -15602,15 +15379,6 @@
 				"loose-envify": "cli.js"
 			}
 		},
-		"node_modules/loupe": {
-			"version": "3.1.3",
-			"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz",
-			"integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true
-		},
 		"node_modules/lowlight": {
 			"version": "3.3.0",
 			"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
@@ -15650,7 +15418,6 @@
 			"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
 			"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
 			"dev": true,
-			"license": "MIT",
 			"peer": true,
 			"bin": {
 				"lz-string": "bin/bin.js"
@@ -17793,6 +17560,44 @@
 			"dev": true,
 			"license": "MIT"
 		},
+		"node_modules/node-fetch": {
+			"version": "2.7.0",
+			"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+			"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+			"dependencies": {
+				"whatwg-url": "^5.0.0"
+			},
+			"engines": {
+				"node": "4.x || >=6.0.0"
+			},
+			"peerDependencies": {
+				"encoding": "^0.1.0"
+			},
+			"peerDependenciesMeta": {
+				"encoding": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/node-fetch/node_modules/tr46": {
+			"version": "0.0.3",
+			"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+			"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
+		},
+		"node_modules/node-fetch/node_modules/webidl-conversions": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+			"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
+		},
+		"node_modules/node-fetch/node_modules/whatwg-url": {
+			"version": "5.0.0",
+			"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+			"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+			"dependencies": {
+				"tr46": "~0.0.3",
+				"webidl-conversions": "^3.0.0"
+			}
+		},
 		"node_modules/node-int64": {
 			"version": "0.4.0",
 			"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -18252,18 +18057,6 @@
 			"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
 			"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
 		},
-		"node_modules/pathval": {
-			"version": "2.0.0",
-			"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz",
-			"integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true,
-			"engines": {
-				"node": ">= 14.16"
-			}
-		},
 		"node_modules/picocolors": {
 			"version": "1.1.1",
 			"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -18725,6 +18518,27 @@
 				"react": "^18.3.1"
 			}
 		},
+		"node_modules/react-i18next": {
+			"version": "15.4.1",
+			"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.4.1.tgz",
+			"integrity": "sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw==",
+			"dependencies": {
+				"@babel/runtime": "^7.25.0",
+				"html-parse-stringify": "^3.0.1"
+			},
+			"peerDependencies": {
+				"i18next": ">= 23.2.3",
+				"react": ">= 16.8.0"
+			},
+			"peerDependenciesMeta": {
+				"react-dom": {
+					"optional": true
+				},
+				"react-native": {
+					"optional": true
+				}
+			}
+		},
 		"node_modules/react-is": {
 			"version": "17.0.2",
 			"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -20783,30 +20597,6 @@
 			"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
 			"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="
 		},
-		"node_modules/tinyrainbow": {
-			"version": "1.2.0",
-			"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
-			"integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true,
-			"engines": {
-				"node": ">=14.0.0"
-			}
-		},
-		"node_modules/tinyspy": {
-			"version": "3.0.2",
-			"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
-			"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
-			"dev": true,
-			"license": "MIT",
-			"optional": true,
-			"peer": true,
-			"engines": {
-				"node": ">=14.0.0"
-			}
-		},
 		"node_modules/tmpl": {
 			"version": "1.0.5",
 			"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -21141,17 +20931,16 @@
 			}
 		},
 		"node_modules/typescript": {
-			"version": "4.9.5",
-			"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
-			"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
-			"dev": true,
-			"license": "Apache-2.0",
+			"version": "5.7.3",
+			"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
+			"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
+			"devOptional": true,
 			"bin": {
 				"tsc": "bin/tsc",
 				"tsserver": "bin/tsserver"
 			},
 			"engines": {
-				"node": ">=4.2.0"
+				"node": ">=14.17"
 			}
 		},
 		"node_modules/ufo": {
@@ -21742,6 +21531,14 @@
 				}
 			}
 		},
+		"node_modules/void-elements": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+			"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+			"engines": {
+				"node": ">=0.10.0"
+			}
+		},
 		"node_modules/vscode-jsonrpc": {
 			"version": "8.2.0",
 			"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",

+ 4 - 1
webview-ui/package.json

@@ -35,11 +35,14 @@
 		"debounce": "^2.1.1",
 		"fast-deep-equal": "^3.1.3",
 		"fzf": "^0.5.2",
+		"i18next": "^24.2.2",
+		"i18next-http-backend": "^3.0.2",
 		"lucide-react": "^0.475.0",
 		"mermaid": "^11.4.1",
 		"posthog-js": "^1.227.2",
 		"react": "^18.3.1",
 		"react-dom": "^18.3.1",
+		"react-i18next": "^15.4.1",
 		"react-markdown": "^9.0.3",
 		"react-remark": "^2.1.0",
 		"react-textarea-autosize": "^8.5.3",
@@ -85,7 +88,7 @@
 		"storybook": "^8.5.6",
 		"storybook-dark-mode": "^4.0.2",
 		"ts-jest": "^29.2.5",
-		"typescript": "^4.9.5",
+		"typescript": "^5.4.5",
 		"vite": "6.0.11"
 	}
 }

+ 4 - 2
webview-ui/src/App.tsx

@@ -1,8 +1,8 @@
 import { useCallback, useEffect, useRef, useState } from "react"
 import { useEvent } from "react-use"
-
 import { ExtensionMessage } from "../../src/shared/ExtensionMessage"
 import { ShowHumanRelayDialogMessage } from "../../src/shared/ExtensionMessage"
+import TranslationProvider from "./i18n/TranslationContext"
 
 import { vscode } from "./utils/vscode"
 import { telemetryClient } from "./utils/TelemetryClient"
@@ -131,7 +131,9 @@ const App = () => {
 
 const AppWithProviders = () => (
 	<ExtensionStateContextProvider>
-		<App />
+		<TranslationProvider>
+			<App />
+		</TranslationProvider>
 	</ExtensionStateContextProvider>
 )
 

+ 47 - 0
webview-ui/src/__mocks__/i18n/TranslationContext.tsx

@@ -0,0 +1,47 @@
+import React, { ReactNode } from "react"
+import i18next from "./setup"
+
+// Create a mock context
+export const TranslationContext = React.createContext<{
+	t: (key: string, options?: Record<string, any>) => string
+	i18n: typeof i18next
+}>({
+	t: (key: string, options?: Record<string, any>) => {
+		// Handle specific test cases
+		if (key === "settings.autoApprove.title") {
+			return "Auto-Approve"
+		}
+		if (key === "notifications.error" && options?.message) {
+			return `Operation failed: ${options.message}`
+		}
+		return key // Default fallback
+	},
+	i18n: i18next,
+})
+
+// Mock translation provider
+export const TranslationProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
+	return (
+		<TranslationContext.Provider
+			value={{
+				t: (key: string, options?: Record<string, any>) => {
+					// Handle specific test cases
+					if (key === "settings.autoApprove.title") {
+						return "Auto-Approve"
+					}
+					if (key === "notifications.error" && options?.message) {
+						return `Operation failed: ${options.message}`
+					}
+					return key // Default fallback
+				},
+				i18n: i18next,
+			}}>
+			{children}
+		</TranslationContext.Provider>
+	)
+}
+
+// Custom hook for easy translations
+export const useAppTranslation = () => React.useContext(TranslationContext)
+
+export default TranslationProvider

+ 62 - 0
webview-ui/src/__mocks__/i18n/setup.ts

@@ -0,0 +1,62 @@
+import i18next from "i18next"
+import { initReactI18next } from "react-i18next"
+
+// Mock translations for testing
+const translations: Record<string, Record<string, any>> = {
+	en: {
+		chat: {
+			greeting: "What can Roo do for you?",
+		},
+		settings: {
+			autoApprove: {
+				title: "Auto-Approve",
+			},
+		},
+		common: {
+			notifications: {
+				error: "Operation failed: {{message}}",
+			},
+		},
+	},
+	es: {
+		chat: {
+			greeting: "¿Qué puede hacer Roo por ti?",
+		},
+	},
+}
+
+// Initialize i18next for React
+i18next.use(initReactI18next).init({
+	lng: "en",
+	fallbackLng: "en",
+	debug: false,
+	interpolation: {
+		escapeValue: false,
+	},
+	resources: {
+		en: {
+			chat: translations.en.chat,
+			settings: translations.en.settings,
+			common: translations.en.common,
+		},
+		es: {
+			chat: translations.es.chat,
+		},
+	},
+})
+
+export function loadTranslations() {
+	// Translations are already loaded in the mock
+}
+
+export function addTranslation(language: string, namespace: string, resources: any) {
+	if (!translations[language]) {
+		translations[language] = {}
+	}
+	translations[language][namespace] = resources
+
+	// Also add to i18next
+	i18next.addResourceBundle(language, namespace, resources, true, true)
+}
+
+export default i18next

+ 4 - 1
webview-ui/src/components/chat/ChatView.tsx

@@ -30,6 +30,7 @@ import { AudioType } from "../../../../src/shared/WebviewMessage"
 import { validateCommand } from "../../utils/command-validation"
 import { getAllModes } from "../../../../src/shared/modes"
 import TelemetryBanner from "../common/TelemetryBanner"
+import { useAppTranslation } from "@/i18n/TranslationContext"
 
 interface ChatViewProps {
 	isHidden: boolean
@@ -66,6 +67,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 		telemetrySetting,
 	} = useExtensionState()
 
+	const { t } = useAppTranslation()
+
 	//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
 	const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort)
 	const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages])
@@ -1100,7 +1103,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 					{telemetrySetting === "unset" && <TelemetryBanner />}
 					{showAnnouncement && <Announcement version={version} hideAnnouncement={hideAnnouncement} />}
 					<div style={{ padding: "0 20px", flexShrink: 0 }}>
-						<h2>What can Roo do for you?</h2>
+						<h2>{t("chat:greeting")}</h2>
 						<p>
 							Thanks to the latest breakthroughs in agentic coding capabilities, I can handle complex
 							software development tasks step-by-step. With tools that let me create & edit files, explore

+ 79 - 211
webview-ui/src/components/prompts/PromptsView.tsx

@@ -9,19 +9,6 @@ import {
 	VSCodeRadioGroup,
 	VSCodeRadio,
 } from "@vscode/webview-ui-toolkit/react"
-import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"
-import {
-	Button,
-	Command,
-	CommandGroup,
-	CommandInput,
-	CommandItem,
-	CommandList,
-	Popover,
-	PopoverContent,
-	PopoverTrigger,
-} from "@/components/ui"
-import { cn } from "@/lib/utils"
 import { useExtensionState } from "../../context/ExtensionStateContext"
 import {
 	Mode,
@@ -43,6 +30,7 @@ import {
 import { TOOL_GROUPS, GROUP_DISPLAY_NAMES, ToolGroup } from "../../../../src/shared/tool-groups"
 import { vscode } from "../../utils/vscode"
 import { Tab, TabContent, TabHeader } from "../common/Tab"
+import i18next from "i18next"
 
 // Get all available groups that should show in prompts view
 const availableGroups = (Object.keys(TOOL_GROUPS) as ToolGroup[]).filter((group) => !TOOL_GROUPS[group].alwaysAvailable)
@@ -69,8 +57,6 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 		mode,
 		customInstructions,
 		setCustomInstructions,
-		preferredLanguage,
-		setPreferredLanguage,
 		customModes,
 		enableCustomModeCreation,
 		setEnableCustomModeCreation,
@@ -82,9 +68,6 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 	const [testPrompt, setTestPrompt] = useState("")
 	const [isEnhancing, setIsEnhancing] = useState(false)
 	const [isDialogOpen, setIsDialogOpen] = useState(false)
-	const [open, setOpen] = useState(false)
-	const [isCustomLanguage, setIsCustomLanguage] = useState(false)
-	const [customLanguage, setCustomLanguage] = useState("")
 	const [selectedPromptContent, setSelectedPromptContent] = useState("")
 	const [selectedPromptTitle, setSelectedPromptTitle] = useState("")
 	const [isToolsEditMode, setIsToolsEditMode] = useState(false)
@@ -427,136 +410,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 			</TabHeader>
 
 			<TabContent>
-				<div className="pb-5 border-b border-vscode-input-border">
-					<div className="mb-5">
-						<div className="font-bold mb-1">Preferred Language</div>
-						<Popover open={open} onOpenChange={setOpen}>
-							<PopoverTrigger asChild>
-								<Button
-									variant="combobox"
-									role="combobox"
-									aria-expanded={open}
-									className="w-full justify-between">
-									{preferredLanguage ?? "Select language..."}
-									<CaretSortIcon className="opacity-50" />
-								</Button>
-							</PopoverTrigger>
-							<PopoverContent align="start" className="p-0">
-								<Command>
-									<CommandInput placeholder="Search language..." className="h-9" />
-									<CommandList>
-										<CommandGroup>
-											{[
-												{ value: "English", label: "English" },
-												{ value: "Arabic", label: "Arabic - العربية" },
-												{
-													value: "Brazilian Portuguese",
-													label: "Portuguese - Português (Brasil)",
-												},
-												{ value: "Catalan", label: "Catalan - Català" },
-												{ value: "Czech", label: "Czech - Čeština" },
-												{ value: "French", label: "French - Français" },
-												{ value: "German", label: "German - Deutsch" },
-												{ value: "Hindi", label: "Hindi - हिन्दी" },
-												{ value: "Hungarian", label: "Hungarian - Magyar" },
-												{ value: "Italian", label: "Italian - Italiano" },
-												{ value: "Japanese", label: "Japanese - 日本語" },
-												{ value: "Korean", label: "Korean - 한국어" },
-												{ value: "Polish", label: "Polish - Polski" },
-												{ value: "Portuguese", label: "Portuguese - Português (Portugal)" },
-												{ value: "Russian", label: "Russian - Русский" },
-												{ value: "Simplified Chinese", label: "Simplified Chinese - 简体中文" },
-												{ value: "Spanish", label: "Spanish - Español" },
-												{
-													value: "Traditional Chinese",
-													label: "Traditional Chinese - 繁體中文",
-												},
-												{ value: "Turkish", label: "Turkish - Türkçe" },
-											].map((language) => (
-												<CommandItem
-													key={language.value}
-													value={language.value}
-													onSelect={(value) => {
-														setPreferredLanguage(value)
-														vscode.postMessage({
-															type: "preferredLanguage",
-															text: value,
-														})
-														setOpen(false)
-													}}>
-													{language.label}
-													<CheckIcon
-														className={cn(
-															"ml-auto",
-															preferredLanguage === language.value
-																? "opacity-100"
-																: "opacity-0",
-														)}
-													/>
-												</CommandItem>
-											))}
-										</CommandGroup>
-									</CommandList>
-								</Command>
-								<div className="border-t border-[var(--vscode-input-border)]">
-									<button
-										className="w-full px-2 py-1.5 text-sm text-left hover:bg-[var(--vscode-list-hoverBackground)]"
-										onClick={() => {
-											setIsCustomLanguage(true)
-											setOpen(false)
-										}}>
-										+ Choose another language
-									</button>
-								</div>
-							</PopoverContent>
-						</Popover>
-						<p className="text-xs mt-1.5 text-vscode-descriptionForeground">
-							Select the language that Roo should use for communication.
-						</p>
-					</div>
-
-					<div className="font-bold mb-1">Custom Instructions for All Modes</div>
-					<div className="text-sm text-vscode-descriptionForeground mb-2">
-						These instructions apply to all modes. They provide a base set of behaviors that can be enhanced
-						by mode-specific instructions below.
-					</div>
-					<VSCodeTextArea
-						value={customInstructions ?? ""}
-						onChange={(e) => {
-							const value =
-								(e as CustomEvent)?.detail?.target?.value ||
-								((e as any).target as HTMLTextAreaElement).value
-							setCustomInstructions(value || undefined)
-							vscode.postMessage({
-								type: "customInstructions",
-								text: value.trim() || undefined,
-							})
-						}}
-						rows={4}
-						resize="vertical"
-						className="w-full"
-						data-testid="global-custom-instructions-textarea"
-					/>
-					<div className="text-xs text-vscode-descriptionForeground mt-1.5 mb-10">
-						Instructions can also be loaded from{" "}
-						<span
-							className="text-vscode-textLink-foreground cursor-pointer underline"
-							onClick={() =>
-								vscode.postMessage({
-									type: "openFile",
-									text: "./.clinerules",
-									values: {
-										create: true,
-										content: "",
-									},
-								})
-							}>
-							.clinerules
-						</span>{" "}
-						in your workspace.
-					</div>
-				</div>
-				<div className="mt-5">
+				<div>
 					<div onClick={(e) => e.stopPropagation()} className="flex justify-between items-center mb-3">
 						<h3 className="text-vscode-foreground m-0">Modes</h3>
 						<div className="flex gap-2">
@@ -987,8 +841,37 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 						</VSCodeButton>
 					</div>
 
+					{/*
+						NOTE: This setting is placed in PromptsView rather than SettingsView since it
+						directly affects the functionality related to modes and custom mode creation,
+						which are managed in this component. This is an intentional deviation from
+						the standard pattern described in cline_docs/settings.md.
+					*/}
+					<div className="mt-12">
+						<VSCodeCheckbox
+							checked={enableCustomModeCreation ?? true}
+							onChange={(e: any) => {
+								// Just update the local state through React context
+								// The React context will update the global state
+								setEnableCustomModeCreation(e.target.checked)
+							}}>
+							<span style={{ fontWeight: "500" }}>Enable Custom Mode Creation Through Prompts</span>
+						</VSCodeCheckbox>
+						<p
+							style={{
+								fontSize: "12px",
+								marginTop: "5px",
+								color: "var(--vscode-descriptionForeground)",
+							}}>
+							When enabled, Roo allows you to create custom modes using prompts like ‘Make me a custom
+							mode that…’. Disabling this reduces your system prompt by about 700 tokens when this feature
+							isn’t needed. When disabled you can still manually create custom modes using the + button
+							above or by editing the related config JSON.
+						</p>
+					</div>
+
 					{/* Custom System Prompt Disclosure */}
-					<div className="mb-3 mt-12">
+					<div className="mt-12">
 						<button
 							onClick={() => setIsSystemPromptDisclosureOpen(!isSystemPromptDisclosureOpen)}
 							className="flex items-center text-xs text-vscode-foreground hover:text-vscode-textLink-foreground focus:outline-none"
@@ -1025,34 +908,54 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 							</div>
 						)}
 					</div>
+				</div>
 
-					{/*
-			NOTE: This setting is placed in PromptsView rather than SettingsView since it
-			directly affects the functionality related to modes and custom mode creation,
-			which are managed in this component. This is an intentional deviation from
-			the standard pattern described in cline_docs/settings.md.
-	*/}
-					<div className="mb-4 mt-4">
-						<VSCodeCheckbox
-							checked={enableCustomModeCreation ?? true}
-							onChange={(e: any) => {
-								// Just update the local state through React context
-								// The React context will update the global state
-								setEnableCustomModeCreation(e.target.checked)
-							}}>
-							<span style={{ fontWeight: "500" }}>Enable Custom Mode Creation Through Prompts</span>
-						</VSCodeCheckbox>
-						<p
-							style={{
-								fontSize: "12px",
-								marginTop: "5px",
-								color: "var(--vscode-descriptionForeground)",
-							}}>
-							When enabled, Roo allows you to create custom modes using prompts like ‘Make me a custom
-							mode that…’. Disabling this reduces your system prompt by about 700 tokens when this feature
-							isn’t needed. When disabled you can still manually create custom modes using the + button
-							above or by editing the related config JSON.
-						</p>
+				<div className="pb-5 border-b border-vscode-input-border">
+					<h3 style={{ color: "var(--vscode-foreground)", marginBottom: "12px" }}>
+						Custom Instructions for All Modes
+					</h3>
+
+					<div className="text-sm text-vscode-descriptionForeground mb-2">
+						These instructions apply to all modes. They provide a base set of behaviors that can be enhanced
+						by mode-specific instructions below.
+						<br />
+						If you would like Roo to think and speak in a different language than your editor display
+						language ({i18next.language}), you can specify it here.
+					</div>
+					<VSCodeTextArea
+						value={customInstructions ?? ""}
+						onChange={(e) => {
+							const value =
+								(e as CustomEvent)?.detail?.target?.value ||
+								((e as any).target as HTMLTextAreaElement).value
+							setCustomInstructions(value || undefined)
+							vscode.postMessage({
+								type: "customInstructions",
+								text: value.trim() || undefined,
+							})
+						}}
+						rows={4}
+						resize="vertical"
+						className="w-full"
+						data-testid="global-custom-instructions-textarea"
+					/>
+					<div className="text-xs text-vscode-descriptionForeground mt-1.5 mb-10">
+						Instructions can also be loaded from{" "}
+						<span
+							className="text-vscode-textLink-foreground cursor-pointer underline"
+							onClick={() =>
+								vscode.postMessage({
+									type: "openFile",
+									text: "./.clinerules",
+									values: {
+										create: true,
+										content: "",
+									},
+								})
+							}>
+							.clinerules
+						</span>{" "}
+						in your workspace.
 					</div>
 				</div>
 
@@ -1509,41 +1412,6 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 					</div>
 				</div>
 			)}
-
-			{isCustomLanguage && (
-				<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
-					<div className="bg-[var(--vscode-editor-background)] p-6 rounded-lg w-96">
-						<h3 className="text-lg font-semibold mb-4">Add Custom Language</h3>
-						<input
-							type="text"
-							className="w-full p-2 mb-4 bg-[var(--vscode-input-background)] text-[var(--vscode-input-foreground)] border border-[var(--vscode-input-border)] rounded"
-							placeholder="Enter language name"
-							value={customLanguage}
-							onChange={(e) => setCustomLanguage(e.target.value)}
-						/>
-						<div className="flex justify-end gap-2">
-							<Button variant="secondary" onClick={() => setIsCustomLanguage(false)}>
-								Cancel
-							</Button>
-							<Button
-								onClick={() => {
-									if (customLanguage.trim()) {
-										setPreferredLanguage(customLanguage.trim())
-										vscode.postMessage({
-											type: "preferredLanguage",
-											text: customLanguage.trim(),
-										})
-										setIsCustomLanguage(false)
-										setCustomLanguage("")
-									}
-								}}
-								disabled={!customLanguage.trim()}>
-								Add
-							</Button>
-						</div>
-					</div>
-				</div>
-			)}
 		</Tab>
 	)
 }

+ 112 - 2
webview-ui/src/components/settings/ApiOptions.tsx

@@ -819,7 +819,7 @@ const ApiOptions = ({
 									style={{ fontSize: "12px" }}
 								/>
 							</div>
-							<div className="text-sm text-vscode-descriptionForeground">
+							<div className="text-sm text-vscode-descriptionForeground pt-1">
 								Is this model capable of processing and understanding images?
 							</div>
 						</div>
@@ -842,11 +842,34 @@ const ApiOptions = ({
 									style={{ fontSize: "12px" }}
 								/>
 							</div>
-							<div className="text-sm text-vscode-descriptionForeground [pt">
+							<div className="text-sm text-vscode-descriptionForeground pt-1">
 								Is this model capable of interacting with a browser? (e.g. Claude 3.7 Sonnet).
 							</div>
 						</div>
 
+						<div>
+							<div className="flex items-center gap-1">
+								<Checkbox
+									checked={apiConfiguration?.openAiCustomModelInfo?.supportsPromptCache ?? false}
+									onChange={handleInputChange("openAiCustomModelInfo", (checked) => {
+										return {
+											...(apiConfiguration?.openAiCustomModelInfo || openAiModelInfoSaneDefaults),
+											supportsPromptCache: checked,
+										}
+									})}>
+									<span className="font-medium">Prompt Caching</span>
+								</Checkbox>
+								<i
+									className="codicon codicon-info text-vscode-descriptionForeground"
+									title="Enable if the model supports prompt caching. This can improve performance and reduce costs."
+									style={{ fontSize: "12px" }}
+								/>
+							</div>
+							<div className="text-sm text-vscode-descriptionForeground pt-1">
+								Is this model capable of caching prompts?
+							</div>
+						</div>
+
 						<div>
 							<VSCodeTextField
 								value={
@@ -933,6 +956,93 @@ const ApiOptions = ({
 							</VSCodeTextField>
 						</div>
 
+						{apiConfiguration?.openAiCustomModelInfo?.supportsPromptCache && (
+							<>
+								<div>
+									<VSCodeTextField
+										value={
+											apiConfiguration?.openAiCustomModelInfo?.cacheReadsPrice?.toString() ?? "0"
+										}
+										type="text"
+										style={{
+											borderColor: (() => {
+												const value = apiConfiguration?.openAiCustomModelInfo?.cacheReadsPrice
+
+												if (!value && value !== 0) {
+													return "var(--vscode-input-border)"
+												}
+
+												return value >= 0
+													? "var(--vscode-charts-green)"
+													: "var(--vscode-errorForeground)"
+											})(),
+										}}
+										onChange={handleInputChange("openAiCustomModelInfo", (e) => {
+											const value = (e.target as HTMLInputElement).value
+											const parsed = parseFloat(value)
+
+											return {
+												...(apiConfiguration?.openAiCustomModelInfo ??
+													openAiModelInfoSaneDefaults),
+												cacheReadsPrice: isNaN(parsed) ? 0 : parsed,
+											}
+										})}
+										placeholder="e.g. 0.0001"
+										className="w-full">
+										<div className="flex items-center gap-1">
+											<span className="font-medium">Cache Reads Price</span>
+											<i
+												className="codicon codicon-info text-vscode-descriptionForeground"
+												title="Cost per million tokens for reading from the cache. This is the price charged when a cached response is retrieved."
+												style={{ fontSize: "12px" }}
+											/>
+										</div>
+									</VSCodeTextField>
+								</div>
+								<div>
+									<VSCodeTextField
+										value={
+											apiConfiguration?.openAiCustomModelInfo?.cacheWritesPrice?.toString() ?? "0"
+										}
+										type="text"
+										style={{
+											borderColor: (() => {
+												const value = apiConfiguration?.openAiCustomModelInfo?.cacheWritesPrice
+
+												if (!value && value !== 0) {
+													return "var(--vscode-input-border)"
+												}
+
+												return value >= 0
+													? "var(--vscode-charts-green)"
+													: "var(--vscode-errorForeground)"
+											})(),
+										}}
+										onChange={handleInputChange("openAiCustomModelInfo", (e) => {
+											const value = (e.target as HTMLInputElement).value
+											const parsed = parseFloat(value)
+
+											return {
+												...(apiConfiguration?.openAiCustomModelInfo ??
+													openAiModelInfoSaneDefaults),
+												cacheWritesPrice: isNaN(parsed) ? 0 : parsed,
+											}
+										})}
+										placeholder="e.g. 0.00005"
+										className="w-full">
+										<div className="flex items-center gap-1">
+											<span className="font-medium">Cache Writes Price</span>
+											<i
+												className="codicon codicon-info text-vscode-descriptionForeground"
+												title="Cost per million tokens for writing to the cache. This is the price charged when a prompt is cached for the first time."
+												style={{ fontSize: "12px" }}
+											/>
+										</div>
+									</VSCodeTextField>
+								</div>
+							</>
+						)}
+
 						<Button
 							variant="secondary"
 							onClick={() =>

+ 1 - 1
webview-ui/src/components/ui/combobox-primitive.tsx

@@ -50,7 +50,7 @@ export type ComboboxType = "single" | "multiple"
 
 export interface ComboboxBaseProps
 	extends React.ComponentProps<typeof PopoverPrimitive.Root>,
-		Omit<React.ComponentProps<typeof CommandPrimitive>, "value" | "defaultValue" | "onValueChange"> {
+		Omit<React.ComponentProps<typeof CommandPrimitive>, "value" | "defaultValue" | "onValueChange" | "children"> {
 	type?: ComboboxType | undefined
 	inputValue?: string
 	defaultInputValue?: string

+ 1 - 4
webview-ui/src/context/ExtensionStateContext.tsx

@@ -39,8 +39,6 @@ export interface ExtensionStateContextType extends ExtensionState {
 	setEnableCheckpoints: (value: boolean) => void
 	setBrowserViewportSize: (value: string) => void
 	setFuzzyMatchThreshold: (value: number) => void
-	preferredLanguage: string
-	setPreferredLanguage: (value: string) => void
 	setWriteDelayMs: (value: number) => void
 	screenshotQuality?: number
 	setScreenshotQuality: (value: number) => void
@@ -118,7 +116,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		enableCheckpoints: true,
 		checkpointStorage: "task",
 		fuzzyMatchThreshold: 1.0,
-		preferredLanguage: "English",
+		language: "en", // Default language code
 		enableCustomModeCreation: true,
 		writeDelayMs: 1000,
 		browserViewportSize: "900x600",
@@ -260,7 +258,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		setBrowserViewportSize: (value: string) =>
 			setState((prevState) => ({ ...prevState, browserViewportSize: value })),
 		setFuzzyMatchThreshold: (value) => setState((prevState) => ({ ...prevState, fuzzyMatchThreshold: value })),
-		setPreferredLanguage: (value) => setState((prevState) => ({ ...prevState, preferredLanguage: value })),
 		setWriteDelayMs: (value) => setState((prevState) => ({ ...prevState, writeDelayMs: value })),
 		setScreenshotQuality: (value) => setState((prevState) => ({ ...prevState, screenshotQuality: value })),
 		setTerminalOutputLineLimit: (value) =>

+ 0 - 1
webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx

@@ -109,7 +109,6 @@ describe("mergeExtensionState", () => {
 			shouldShowAnnouncement: false,
 			enableCheckpoints: true,
 			checkpointStorage: "task",
-			preferredLanguage: "English",
 			writeDelayMs: 1000,
 			requestDelaySeconds: 5,
 			rateLimitSeconds: 0,

+ 57 - 0
webview-ui/src/i18n/TranslationContext.tsx

@@ -0,0 +1,57 @@
+import React, { createContext, useContext, ReactNode, useEffect, useCallback } from "react"
+import { useTranslation } from "react-i18next"
+import i18next, { loadTranslations } from "./setup"
+import { useExtensionState } from "@/context/ExtensionStateContext"
+
+// Create context for translations
+export const TranslationContext = createContext<{
+	t: (key: string, options?: Record<string, any>) => string
+	i18n: typeof i18next
+}>({
+	t: (key: string) => key,
+	i18n: i18next,
+})
+
+// Translation provider component
+export const TranslationProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
+	// Initialize with default configuration
+	const { i18n } = useTranslation()
+	// Get the extension state directly - it already contains all state properties
+	const extensionState = useExtensionState()
+
+	// Load translations once when the component mounts
+	useEffect(() => {
+		try {
+			loadTranslations()
+		} catch (error) {
+			console.error("Failed to load translations:", error)
+		}
+	}, [])
+
+	useEffect(() => {
+		i18n.changeLanguage(extensionState.language)
+	}, [i18n, extensionState.language])
+
+	// Memoize the translation function to prevent unnecessary re-renders
+	const translate = useCallback(
+		(key: string, options?: Record<string, any>) => {
+			return i18n.t(key, options)
+		},
+		[i18n],
+	)
+
+	return (
+		<TranslationContext.Provider
+			value={{
+				t: translate,
+				i18n,
+			}}>
+			{children}
+		</TranslationContext.Provider>
+	)
+}
+
+// Custom hook for easy translations
+export const useAppTranslation = () => useContext(TranslationContext)
+
+export default TranslationProvider

+ 52 - 0
webview-ui/src/i18n/__tests__/TranslationContext.test.tsx

@@ -0,0 +1,52 @@
+import React from "react"
+import { render } from "@testing-library/react"
+import "@testing-library/jest-dom"
+import TranslationProvider, { useAppTranslation } from "../TranslationContext"
+import { setupI18nForTests } from "../test-utils"
+
+// Mock the useExtensionState hook
+jest.mock("@/context/ExtensionStateContext", () => ({
+	useExtensionState: () => ({
+		language: "en",
+	}),
+}))
+
+// Mock component that uses the translation context
+const TestComponent = () => {
+	const { t } = useAppTranslation()
+	return (
+		<div>
+			<h1 data-testid="translation-test">{t("settings.autoApprove.title")}</h1>
+			<p data-testid="translation-interpolation">{t("notifications.error", { message: "Test error" })}</p>
+		</div>
+	)
+}
+
+describe("TranslationContext", () => {
+	beforeAll(() => {
+		// Initialize i18next with test translations
+		setupI18nForTests()
+	})
+
+	it("should provide translations via context", () => {
+		const { getByTestId } = render(
+			<TranslationProvider>
+				<TestComponent />
+			</TranslationProvider>,
+		)
+
+		// Check if translation is provided correctly
+		expect(getByTestId("translation-test")).toHaveTextContent("Auto-Approve")
+	})
+
+	it("should handle interpolation correctly", () => {
+		const { getByTestId } = render(
+			<TranslationProvider>
+				<TestComponent />
+			</TranslationProvider>,
+		)
+
+		// Check if interpolation works
+		expect(getByTestId("translation-interpolation")).toHaveTextContent("Operation failed: Test error")
+	})
+})

+ 0 - 0
webview-ui/src/i18n/locales/ar/.gitkeep


+ 0 - 0
webview-ui/src/i18n/locales/ca/.gitkeep


+ 0 - 0
webview-ui/src/i18n/locales/cs/.gitkeep


+ 0 - 0
webview-ui/src/i18n/locales/de/.gitkeep


+ 0 - 0
webview-ui/src/i18n/locales/en/.gitkeep


+ 3 - 0
webview-ui/src/i18n/locales/en/chat.json

@@ -0,0 +1,3 @@
+{
+	"greeting": "What can Roo do for you?"
+}

+ 0 - 0
webview-ui/src/i18n/locales/es/.gitkeep


+ 0 - 0
webview-ui/src/i18n/locales/fr/.gitkeep


+ 0 - 0
webview-ui/src/i18n/locales/hi/.gitkeep


+ 0 - 0
webview-ui/src/i18n/locales/hu/.gitkeep


+ 0 - 0
webview-ui/src/i18n/locales/it/.gitkeep


+ 0 - 0
webview-ui/src/i18n/locales/ja/.gitkeep


+ 0 - 0
webview-ui/src/i18n/locales/ko/.gitkeep


+ 0 - 0
webview-ui/src/i18n/locales/pl/.gitkeep


+ 0 - 0
webview-ui/src/i18n/locales/pt-BR/.gitkeep


+ 0 - 0
webview-ui/src/i18n/locales/pt/.gitkeep


+ 0 - 0
webview-ui/src/i18n/locales/ru/.gitkeep


+ 0 - 0
webview-ui/src/i18n/locales/tr/.gitkeep


+ 0 - 0
webview-ui/src/i18n/locales/zh-CN/.gitkeep


+ 0 - 0
webview-ui/src/i18n/locales/zh-TW/.gitkeep


+ 54 - 0
webview-ui/src/i18n/setup.ts

@@ -0,0 +1,54 @@
+import i18next from "i18next"
+import { initReactI18next } from "react-i18next"
+
+// Build translations object
+const translations: Record<string, Record<string, any>> = {}
+
+// Dynamically load locale files
+const localeFiles = import.meta.glob("./locales/**/*.json", { eager: true })
+
+// Process all locale files
+Object.entries(localeFiles).forEach(([path, module]) => {
+	// Extract language and namespace from path
+	// Example path: './locales/en/common.json' -> language: 'en', namespace: 'common'
+	const match = path.match(/\.\/locales\/([^/]+)\/([^/]+)\.json/)
+
+	if (match) {
+		const [, language, namespace] = match
+
+		// Initialize language object if it doesn't exist
+		if (!translations[language]) {
+			translations[language] = {}
+		}
+
+		// Add namespace resources to language
+		translations[language][namespace] = (module as any).default || module
+	}
+})
+
+console.log("Dynamically loaded translations:", Object.keys(translations))
+
+// Initialize i18next for React
+// This will be initialized with the VSCode language in TranslationProvider
+i18next.use(initReactI18next).init({
+	lng: "en", // Default language (will be overridden)
+	fallbackLng: "en",
+	debug: false,
+	interpolation: {
+		escapeValue: false, // React already escapes by default
+	},
+})
+
+export function loadTranslations() {
+	Object.entries(translations).forEach(([lang, namespaces]) => {
+		try {
+			Object.entries(namespaces).forEach(([namespace, resources]) => {
+				i18next.addResourceBundle(lang, namespace, resources, true, true)
+			})
+		} catch (error) {
+			console.warn(`Could not load ${lang} translations:`, error)
+		}
+	})
+}
+
+export default i18next

+ 37 - 0
webview-ui/src/i18n/test-utils.ts

@@ -0,0 +1,37 @@
+import i18next from "i18next"
+import { initReactI18next } from "react-i18next"
+
+/**
+ * Sets up i18next for testing with pre-defined translations.
+ * Use this in test files to ensure consistent translation handling.
+ */
+export const setupI18nForTests = () => {
+	i18next.use(initReactI18next).init({
+		lng: "en",
+		fallbackLng: "en",
+		debug: false,
+		interpolation: {
+			escapeValue: false,
+		},
+		// Pre-define all translations needed for tests
+		resources: {
+			en: {
+				settings: {
+					autoApprove: {
+						title: "Auto-Approve",
+					},
+				},
+				common: {
+					notifications: {
+						error: "Operation failed: {{message}}",
+					},
+				},
+				chat: {
+					test: "Test",
+				},
+			},
+		},
+	})
+
+	return i18next
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов