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

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

aheizi пре 9 месеци
родитељ
комит
29aa464ce3
41 измењених фајлова са 2373 додато и 377 уклоњено
  1. 5 0
      .changeset/tidy-queens-pay.md
  2. 1 1
      .eslintrc.json
  3. 1 1
      .github/workflows/changeset-release.yml
  4. 5 5
      .github/workflows/code-qa.yml
  5. 3 7
      .github/workflows/marketplace-publish.yml
  6. 30 27
      README.md
  7. 2 2
      e2e/VSCODE_INTEGRATION_TESTS.md
  8. 3 3
      e2e/src/suite/index.ts
  9. 1 1
      e2e/tsconfig.json
  10. 5 3
      package.json
  11. 9 13
      src/activate/createRooCodeAPI.ts
  12. 26 0
      src/activate/humanRelay.ts
  13. 1 0
      src/activate/index.ts
  14. 16 16
      src/activate/registerCommands.ts
  15. 75 0
      src/api/providers/__tests__/bedrock-custom-arn.test.ts
  16. 29 0
      src/api/providers/__tests__/bedrock.test.ts
  17. 235 0
      src/api/providers/__tests__/openai-usage-tracking.test.ts
  18. 460 29
      src/api/providers/bedrock.ts
  19. 7 1
      src/api/providers/openai.ts
  20. 29 4
      src/core/Cline.ts
  21. 25 0
      src/core/diff/strategies/multi-search-replace.ts
  22. 5 0
      src/core/diff/types.ts
  23. 128 3
      src/core/webview/ClineProvider.ts
  24. 429 68
      src/core/webview/__tests__/ClineProvider.test.ts
  25. 34 38
      src/exports/README.md
  26. 56 13
      src/exports/roo-code.d.ts
  27. 5 50
      src/extension.ts
  28. 139 7
      src/services/browser/BrowserSession.ts
  29. 246 0
      src/services/browser/browserDiscovery.ts
  30. 18 57
      src/shared/ExtensionMessage.ts
  31. 5 0
      src/shared/WebviewMessage.ts
  32. 10 0
      src/shared/api.ts
  33. 3 0
      src/shared/globalState.ts
  34. 1 0
      webview-ui/src/components/chat/ChatRow.tsx
  35. 11 0
      webview-ui/src/components/common/CodeAccordian.tsx
  36. 85 4
      webview-ui/src/components/settings/ApiOptions.tsx
  37. 136 3
      webview-ui/src/components/settings/BrowserSettings.tsx
  38. 25 8
      webview-ui/src/components/settings/SettingsView.tsx
  39. 28 13
      webview-ui/src/components/ui/alert-dialog.tsx
  40. 3 0
      webview-ui/src/context/ExtensionStateContext.tsx
  41. 38 0
      webview-ui/src/utils/validate.ts

+ 5 - 0
.changeset/tidy-queens-pay.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Fix usage tracking for SiliconFlow etc

+ 1 - 1
.eslintrc.json

@@ -19,5 +19,5 @@
 		"no-throw-literal": "warn",
 		"no-throw-literal": "warn",
 		"semi": "off"
 		"semi": "off"
 	},
 	},
-	"ignorePatterns": ["out", "dist", "**/*.d.ts"]
+	"ignorePatterns": ["out", "dist", "**/*.d.ts", "!roo-code.d.ts"]
 }
 }

+ 1 - 1
.github/workflows/changeset-release.yml

@@ -37,7 +37,7 @@ jobs:
           cache: 'npm'
           cache: 'npm'
             
             
       - name: Install Dependencies
       - name: Install Dependencies
-        run: npm run install:ci
+        run: npm run install:all
 
 
       # Check if there are any new changesets to process
       # Check if there are any new changesets to process
       - name: Check for changesets
       - name: Check for changesets

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

@@ -20,7 +20,7 @@ jobs:
           node-version: '18'
           node-version: '18'
           cache: 'npm'
           cache: 'npm'
       - name: Install dependencies
       - name: Install dependencies
-        run: npm run install:ci
+        run: npm run install:all
       - name: Compile
       - name: Compile
         run: npm run compile
         run: npm run compile
       - name: Check types
       - name: Check types
@@ -39,7 +39,7 @@ jobs:
           node-version: '18'
           node-version: '18'
           cache: 'npm'
           cache: 'npm'
       - name: Install dependencies
       - name: Install dependencies
-        run: npm run install:ci
+        run: npm run install:all
       - name: Run knip checks
       - name: Run knip checks
         run: npm run knip
         run: npm run knip
 
 
@@ -54,7 +54,7 @@ jobs:
           node-version: '18'
           node-version: '18'
           cache: 'npm'
           cache: 'npm'
       - name: Install dependencies
       - name: Install dependencies
-        run: npm run install:ci
+        run: npm run install:all
       - name: Run unit tests
       - name: Run unit tests
         run: npx jest --silent
         run: npx jest --silent
 
 
@@ -69,7 +69,7 @@ jobs:
           node-version: '18'
           node-version: '18'
           cache: 'npm'
           cache: 'npm'
       - name: Install dependencies
       - name: Install dependencies
-        run: npm run install:ci
+        run: npm run install:all
       - name: Run unit tests
       - name: Run unit tests
         working-directory: webview-ui
         working-directory: webview-ui
         run: npx jest --silent
         run: npx jest --silent
@@ -109,7 +109,7 @@ jobs:
           node-version: '18'
           node-version: '18'
           cache: 'npm'
           cache: 'npm'
       - name: Install dependencies
       - name: Install dependencies
-        run: npm run install:ci
+        run: npm run install:all
       - name: Create env.integration file
       - name: Create env.integration file
         working-directory: e2e
         working-directory: e2e
         run: echo "OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}" > .env.integration
         run: echo "OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}" > .env.integration

+ 3 - 7
.github/workflows/marketplace-publish.yml

@@ -11,7 +11,7 @@ jobs:
   publish-extension:
   publish-extension:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     permissions:
     permissions:
-      contents: write  # Required for pushing tags
+      contents: write # Required for pushing tags.
     if: >
     if: >
         ( github.event_name == 'pull_request' &&
         ( github.event_name == 'pull_request' &&
         github.event.pull_request.base.ref == 'main' &&
         github.event.pull_request.base.ref == 'main' &&
@@ -33,13 +33,9 @@ jobs:
       - name: Install Dependencies
       - name: Install Dependencies
         run: |
         run: |
           npm install -g vsce ovsx
           npm install -g vsce ovsx
-          npm run install:ci
-
+          npm run install:all
       - name: Create .env file
       - name: Create .env file
-        run: |
-          echo "# PostHog API Keys for telemetry" > .env
-          echo "POSTHOG_API_KEY=${{ secrets.POSTHOG_API_KEY }}" >> .env
-
+        run: echo "POSTHOG_API_KEY=${{ secrets.POSTHOG_API_KEY }}" >> .env
       - name: Package Extension
       - name: Package Extension
         run: |
         run: |
           current_package_version=$(node -p "require('./package.json').version")
           current_package_version=$(node -p "require('./package.json').version")

+ 30 - 27
README.md

@@ -115,37 +115,40 @@ Make Roo Code work your way with:
 ## Local Setup & Development
 ## Local Setup & Development
 
 
 1. **Clone** the repo:
 1. **Clone** the repo:
-    ```bash
-    git clone https://github.com/RooVetGit/Roo-Code.git
-    ```
+
+```sh
+git clone https://github.com/RooVetGit/Roo-Code.git
+```
+
 2. **Install dependencies**:
 2. **Install dependencies**:
-    ```bash
-    npm run install:all
-    ```
-
-if that fails, try:
-    ```bash
-    npm run install:ci
-    ```
-
-3. **Build** the extension:
-    ```bash
-    npm run build
-    ```
-    - A `.vsix` file will appear in the `bin/` directory.
-4. **Install** the `.vsix` manually if desired:
-    ```bash
-    code --install-extension bin/roo-code-4.0.0.vsix
-    ```
-5. **Start the webview (Vite/React app with HMR)**:
-    ```bash
-    npm run dev
-    ```
-6. **Debug**:
-    - Press `F5` (or **Run** → **Start Debugging**) in VSCode to open a new session with Roo Code loaded.
+
+```sh
+npm run install:all
+```
+
+3. **Start the webview (Vite/React app with HMR)**:
+
+```sh
+npm run dev
+```
+
+4. **Debug**:
+   Press `F5` (or **Run** → **Start Debugging**) in VSCode to open a new session with Roo Code loaded.
 
 
 Changes to the webview will appear immediately. Changes to the core extension will require a restart of the extension host.
 Changes to the webview will appear immediately. Changes to the core extension will require a restart of the extension host.
 
 
+Alternatively you can build a .vsix and install it directly in VSCode:
+
+```sh
+npm run build
+```
+
+A `.vsix` file will appear in the `bin/` directory which can be installed with:
+
+```sh
+code --install-extension bin/roo-cline-<version>.vsix
+```
+
 We use [changesets](https://github.com/changesets/changesets) for versioning and publishing. Check our `CHANGELOG.md` for release notes.
 We use [changesets](https://github.com/changesets/changesets) for versioning and publishing. Check our `CHANGELOG.md` for release notes.
 
 
 ---
 ---

+ 2 - 2
e2e/VSCODE_INTEGRATION_TESTS.md

@@ -58,9 +58,9 @@ The following global objects are available in tests:
 
 
 ```typescript
 ```typescript
 declare global {
 declare global {
-	var api: ClineAPI
+	var api: RooCodeAPI
 	var provider: ClineProvider
 	var provider: ClineProvider
-	var extension: vscode.Extension<ClineAPI>
+	var extension: vscode.Extension<RooCodeAPI>
 	var panel: vscode.WebviewPanel
 	var panel: vscode.WebviewPanel
 }
 }
 ```
 ```

+ 3 - 3
e2e/src/suite/index.ts

@@ -1,13 +1,13 @@
 import * as path from "path"
 import * as path from "path"
 import Mocha from "mocha"
 import Mocha from "mocha"
 import { glob } from "glob"
 import { glob } from "glob"
-import { ClineAPI, ClineProvider } from "../../../src/exports/cline"
+import { RooCodeAPI, ClineProvider } from "../../../src/exports/roo-code"
 import * as vscode from "vscode"
 import * as vscode from "vscode"
 
 
 declare global {
 declare global {
-	var api: ClineAPI
+	var api: RooCodeAPI
 	var provider: ClineProvider
 	var provider: ClineProvider
-	var extension: vscode.Extension<ClineAPI> | undefined
+	var extension: vscode.Extension<RooCodeAPI> | undefined
 	var panel: vscode.WebviewPanel | undefined
 	var panel: vscode.WebviewPanel | undefined
 }
 }
 
 

+ 1 - 1
e2e/tsconfig.json

@@ -11,6 +11,6 @@
 		"useUnknownInCatchVariables": false,
 		"useUnknownInCatchVariables": false,
 		"outDir": "out"
 		"outDir": "out"
 	},
 	},
-	"include": ["src", "../src/exports/cline.d.ts"],
+	"include": ["src", "../src/exports/roo-code.d.ts"],
 	"exclude": [".vscode-test", "**/node_modules/**", "out"]
 	"exclude": [".vscode-test", "**/node_modules/**", "out"]
 }
 }

+ 5 - 3
package.json

@@ -230,8 +230,8 @@
 		"build": "npm run build:webview && npm run vsix",
 		"build": "npm run build:webview && npm run vsix",
 		"build:webview": "cd webview-ui && npm run build",
 		"build:webview": "cd webview-ui && npm run build",
 		"compile": "tsc -p . --outDir out && node esbuild.js",
 		"compile": "tsc -p . --outDir out && node esbuild.js",
-		"install:all": "npm-run-all -p install-*",
-		"install:ci": "npm install npm-run-all && npm run install:all",
+		"install:all": "npm install npm-run-all && npm run install:_all",
+		"install:_all": "npm-run-all -p install-*",
 		"install-extension": "npm install",
 		"install-extension": "npm install",
 		"install-webview-ui": "cd webview-ui && npm install",
 		"install-webview-ui": "cd webview-ui && npm install",
 		"install-e2e": "cd e2e && npm install",
 		"install-e2e": "cd e2e && npm install",
@@ -246,7 +246,9 @@
 		"package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production",
 		"package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production",
 		"pretest": "npm run compile",
 		"pretest": "npm run compile",
 		"dev": "cd webview-ui && npm run dev",
 		"dev": "cd webview-ui && npm run dev",
-		"test": "jest && cd webview-ui && npm run test",
+		"test": "npm-run-all -p test:*",
+		"test:extension": "jest",
+		"test:webview": "cd webview-ui && npm run test",
 		"prepare": "husky",
 		"prepare": "husky",
 		"publish:marketplace": "vsce publish && ovsx publish",
 		"publish:marketplace": "vsce publish && ovsx publish",
 		"publish": "npm run build && changeset publish && npm install --package-lock-only",
 		"publish": "npm run build && changeset publish && npm install --package-lock-only",

+ 9 - 13
src/exports/index.ts → src/activate/createRooCodeAPI.ts

@@ -1,9 +1,11 @@
 import * as vscode from "vscode"
 import * as vscode from "vscode"
+
 import { ClineProvider } from "../core/webview/ClineProvider"
 import { ClineProvider } from "../core/webview/ClineProvider"
-import { ClineAPI } from "./cline"
 
 
-export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvider: ClineProvider): ClineAPI {
-	const api: ClineAPI = {
+import { RooCodeAPI } from "../exports/roo-code"
+
+export function createRooCodeAPI(outputChannel: vscode.OutputChannel, sidebarProvider: ClineProvider): RooCodeAPI {
+	return {
 		setCustomInstructions: async (value: string) => {
 		setCustomInstructions: async (value: string) => {
 			await sidebarProvider.updateCustomInstructions(value)
 			await sidebarProvider.updateCustomInstructions(value)
 			outputChannel.appendLine("Custom instructions set")
 			outputChannel.appendLine("Custom instructions set")
@@ -24,6 +26,7 @@ export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvi
 				text: task,
 				text: task,
 				images: images,
 				images: images,
 			})
 			})
+
 			outputChannel.appendLine(
 			outputChannel.appendLine(
 				`Task started with message: ${task ? `"${task}"` : "undefined"} and ${images?.length || 0} image(s)`,
 				`Task started with message: ${task ? `"${task}"` : "undefined"} and ${images?.length || 0} image(s)`,
 			)
 			)
@@ -33,6 +36,7 @@ export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvi
 			outputChannel.appendLine(
 			outputChannel.appendLine(
 				`Sending message: ${message ? `"${message}"` : "undefined"} with ${images?.length || 0} image(s)`,
 				`Sending message: ${message ? `"${message}"` : "undefined"} with ${images?.length || 0} image(s)`,
 			)
 			)
+
 			await sidebarProvider.postMessageToWebview({
 			await sidebarProvider.postMessageToWebview({
 				type: "invoke",
 				type: "invoke",
 				invoke: "sendMessage",
 				invoke: "sendMessage",
@@ -43,22 +47,14 @@ export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvi
 
 
 		pressPrimaryButton: async () => {
 		pressPrimaryButton: async () => {
 			outputChannel.appendLine("Pressing primary button")
 			outputChannel.appendLine("Pressing primary button")
-			await sidebarProvider.postMessageToWebview({
-				type: "invoke",
-				invoke: "primaryButtonClick",
-			})
+			await sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "primaryButtonClick" })
 		},
 		},
 
 
 		pressSecondaryButton: async () => {
 		pressSecondaryButton: async () => {
 			outputChannel.appendLine("Pressing secondary button")
 			outputChannel.appendLine("Pressing secondary button")
-			await sidebarProvider.postMessageToWebview({
-				type: "invoke",
-				invoke: "secondaryButtonClick",
-			})
+			await sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "secondaryButtonClick" })
 		},
 		},
 
 
 		sidebarProvider: sidebarProvider,
 		sidebarProvider: sidebarProvider,
 	}
 	}
-
-	return api
 }
 }

+ 26 - 0
src/activate/humanRelay.ts

@@ -0,0 +1,26 @@
+// Callback mapping of human relay response.
+const humanRelayCallbacks = new Map<string, (response: string | undefined) => void>()
+
+/**
+ * Register a callback function for human relay response.
+ * @param requestId
+ * @param callback
+ */
+export const registerHumanRelayCallback = (requestId: string, callback: (response: string | undefined) => void) =>
+	humanRelayCallbacks.set(requestId, callback)
+
+export const unregisterHumanRelayCallback = (requestId: string) => humanRelayCallbacks.delete(requestId)
+
+export const handleHumanRelayResponse = (response: { requestId: string; text?: string; cancelled?: boolean }) => {
+	const callback = humanRelayCallbacks.get(response.requestId)
+
+	if (callback) {
+		if (response.cancelled) {
+			callback(undefined)
+		} else {
+			callback(response.text)
+		}
+
+		humanRelayCallbacks.delete(response.requestId)
+	}
+}

+ 1 - 0
src/activate/index.ts

@@ -1,3 +1,4 @@
 export { handleUri } from "./handleUri"
 export { handleUri } from "./handleUri"
 export { registerCommands } from "./registerCommands"
 export { registerCommands } from "./registerCommands"
 export { registerCodeActions } from "./registerCodeActions"
 export { registerCodeActions } from "./registerCodeActions"
+export { createRooCodeAPI } from "./createRooCodeAPI"

+ 16 - 16
src/activate/registerCommands.ts

@@ -3,6 +3,8 @@ import delay from "delay"
 
 
 import { ClineProvider } from "../core/webview/ClineProvider"
 import { ClineProvider } from "../core/webview/ClineProvider"
 
 
+import { registerHumanRelayCallback, unregisterHumanRelayCallback, handleHumanRelayResponse } from "./humanRelay"
+
 // Store panel references in both modes
 // Store panel references in both modes
 let sidebarPanel: vscode.WebviewView | undefined = undefined
 let sidebarPanel: vscode.WebviewView | undefined = undefined
 let tabPanel: vscode.WebviewPanel | undefined = undefined
 let tabPanel: vscode.WebviewPanel | undefined = undefined
@@ -43,22 +45,6 @@ export const registerCommands = (options: RegisterCommandOptions) => {
 	for (const [command, callback] of Object.entries(getCommandsMap(options))) {
 	for (const [command, callback] of Object.entries(getCommandsMap(options))) {
 		context.subscriptions.push(vscode.commands.registerCommand(command, callback))
 		context.subscriptions.push(vscode.commands.registerCommand(command, callback))
 	}
 	}
-
-	// Human Relay Dialog Command
-	context.subscriptions.push(
-		vscode.commands.registerCommand(
-			"roo-cline.showHumanRelayDialog",
-			(params: { requestId: string; promptText: string }) => {
-				if (getPanel()) {
-					getPanel()?.webview.postMessage({
-						type: "showHumanRelayDialog",
-						requestId: params.requestId,
-						promptText: params.promptText,
-					})
-				}
-			},
-		),
-	)
 }
 }
 
 
 const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOptions) => {
 const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOptions) => {
@@ -85,6 +71,20 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt
 		"roo-cline.helpButtonClicked": () => {
 		"roo-cline.helpButtonClicked": () => {
 			vscode.env.openExternal(vscode.Uri.parse("https://docs.roocode.com"))
 			vscode.env.openExternal(vscode.Uri.parse("https://docs.roocode.com"))
 		},
 		},
+		"roo-cline.showHumanRelayDialog": (params: { requestId: string; promptText: string }) => {
+			const panel = getPanel()
+
+			if (panel) {
+				panel?.webview.postMessage({
+					type: "showHumanRelayDialog",
+					requestId: params.requestId,
+					promptText: params.promptText,
+				})
+			}
+		},
+		"roo-cline.registerHumanRelayCallback": registerHumanRelayCallback,
+		"roo-cline.unregisterHumanRelayCallback": unregisterHumanRelayCallback,
+		"roo-cline.handleHumanRelayResponse": handleHumanRelayResponse,
 	}
 	}
 }
 }
 
 

+ 75 - 0
src/api/providers/__tests__/bedrock-custom-arn.test.ts

@@ -0,0 +1,75 @@
+import { AwsBedrockHandler } from "../bedrock"
+import { ApiHandlerOptions } from "../../../shared/api"
+
+// Mock the AWS SDK
+jest.mock("@aws-sdk/client-bedrock-runtime", () => {
+	const mockSend = jest.fn().mockImplementation(() => {
+		return Promise.resolve({
+			output: new TextEncoder().encode(JSON.stringify({ content: "Test response" })),
+		})
+	})
+
+	return {
+		BedrockRuntimeClient: jest.fn().mockImplementation(() => ({
+			send: mockSend,
+			config: {
+				region: "us-east-1",
+			},
+		})),
+		ConverseCommand: jest.fn(),
+		ConverseStreamCommand: jest.fn(),
+	}
+})
+
+describe("AwsBedrockHandler with custom ARN", () => {
+	const mockOptions: ApiHandlerOptions = {
+		apiModelId: "custom-arn",
+		awsCustomArn: "arn:aws:bedrock:us-east-1:123456789012:foundation-model/anthropic.claude-3-sonnet-20240229-v1:0",
+		awsRegion: "us-east-1",
+	}
+
+	it("should use the custom ARN as the model ID", async () => {
+		const handler = new AwsBedrockHandler(mockOptions)
+		const model = handler.getModel()
+
+		expect(model.id).toBe(mockOptions.awsCustomArn)
+		expect(model.info).toHaveProperty("maxTokens")
+		expect(model.info).toHaveProperty("contextWindow")
+		expect(model.info).toHaveProperty("supportsPromptCache")
+	})
+
+	it("should extract region from ARN and use it for client configuration", () => {
+		// Test with matching region
+		const handler1 = new AwsBedrockHandler(mockOptions)
+		expect((handler1 as any).client.config.region).toBe("us-east-1")
+
+		// Test with mismatched region
+		const mismatchOptions = {
+			...mockOptions,
+			awsRegion: "us-west-2",
+		}
+		const handler2 = new AwsBedrockHandler(mismatchOptions)
+		// Should use the ARN region, not the provided region
+		expect((handler2 as any).client.config.region).toBe("us-east-1")
+	})
+
+	it("should validate ARN format", async () => {
+		// Invalid ARN format
+		const invalidOptions = {
+			...mockOptions,
+			awsCustomArn: "invalid-arn-format",
+		}
+
+		const handler = new AwsBedrockHandler(invalidOptions)
+
+		// completePrompt should throw an error for invalid ARN
+		await expect(handler.completePrompt("test")).rejects.toThrow("Invalid ARN format")
+	})
+
+	it("should complete a prompt successfully with valid ARN", async () => {
+		const handler = new AwsBedrockHandler(mockOptions)
+		const response = await handler.completePrompt("test prompt")
+
+		expect(response).toBe("Test response")
+	})
+})

+ 29 - 0
src/api/providers/__tests__/bedrock.test.ts

@@ -315,5 +315,34 @@ describe("AwsBedrockHandler", () => {
 			expect(modelInfo.info.maxTokens).toBe(5000)
 			expect(modelInfo.info.maxTokens).toBe(5000)
 			expect(modelInfo.info.contextWindow).toBe(128_000)
 			expect(modelInfo.info.contextWindow).toBe(128_000)
 		})
 		})
+
+		it("should use custom ARN when provided", () => {
+			const customArnHandler = new AwsBedrockHandler({
+				apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
+				awsAccessKey: "test-access-key",
+				awsSecretKey: "test-secret-key",
+				awsRegion: "us-east-1",
+				awsCustomArn: "arn:aws:bedrock:us-east-1::foundation-model/custom-model",
+			})
+			const modelInfo = customArnHandler.getModel()
+			expect(modelInfo.id).toBe("arn:aws:bedrock:us-east-1::foundation-model/custom-model")
+			expect(modelInfo.info.maxTokens).toBe(4096)
+			expect(modelInfo.info.contextWindow).toBe(128_000)
+			expect(modelInfo.info.supportsPromptCache).toBe(false)
+		})
+
+		it("should use default model when custom-arn is selected but no ARN is provided", () => {
+			const customArnHandler = new AwsBedrockHandler({
+				apiModelId: "custom-arn",
+				awsAccessKey: "test-access-key",
+				awsSecretKey: "test-secret-key",
+				awsRegion: "us-east-1",
+				// No awsCustomArn provided
+			})
+			const modelInfo = customArnHandler.getModel()
+			// Should fall back to default model
+			expect(modelInfo.id).not.toBe("custom-arn")
+			expect(modelInfo.info).toBeDefined()
+		})
 	})
 	})
 })
 })

+ 235 - 0
src/api/providers/__tests__/openai-usage-tracking.test.ts

@@ -0,0 +1,235 @@
+import { OpenAiHandler } from "../openai"
+import { ApiHandlerOptions } from "../../../shared/api"
+import { Anthropic } from "@anthropic-ai/sdk"
+
+// Mock OpenAI client with multiple chunks that contain usage data
+const mockCreate = jest.fn()
+jest.mock("openai", () => {
+	return {
+		__esModule: true,
+		default: jest.fn().mockImplementation(() => ({
+			chat: {
+				completions: {
+					create: mockCreate.mockImplementation(async (options) => {
+						if (!options.stream) {
+							return {
+								id: "test-completion",
+								choices: [
+									{
+										message: { role: "assistant", content: "Test response", refusal: null },
+										finish_reason: "stop",
+										index: 0,
+									},
+								],
+								usage: {
+									prompt_tokens: 10,
+									completion_tokens: 5,
+									total_tokens: 15,
+								},
+							}
+						}
+
+						// Return a stream with multiple chunks that include usage metrics
+						return {
+							[Symbol.asyncIterator]: async function* () {
+								// First chunk with partial usage
+								yield {
+									choices: [
+										{
+											delta: { content: "Test " },
+											index: 0,
+										},
+									],
+									usage: {
+										prompt_tokens: 10,
+										completion_tokens: 2,
+										total_tokens: 12,
+									},
+								}
+
+								// Second chunk with updated usage
+								yield {
+									choices: [
+										{
+											delta: { content: "response" },
+											index: 0,
+										},
+									],
+									usage: {
+										prompt_tokens: 10,
+										completion_tokens: 4,
+										total_tokens: 14,
+									},
+								}
+
+								// Final chunk with complete usage
+								yield {
+									choices: [
+										{
+											delta: {},
+											index: 0,
+										},
+									],
+									usage: {
+										prompt_tokens: 10,
+										completion_tokens: 5,
+										total_tokens: 15,
+									},
+								}
+							},
+						}
+					}),
+				},
+			},
+		})),
+	}
+})
+
+describe("OpenAiHandler with usage tracking fix", () => {
+	let handler: OpenAiHandler
+	let mockOptions: ApiHandlerOptions
+
+	beforeEach(() => {
+		mockOptions = {
+			openAiApiKey: "test-api-key",
+			openAiModelId: "gpt-4",
+			openAiBaseUrl: "https://api.openai.com/v1",
+		}
+		handler = new OpenAiHandler(mockOptions)
+		mockCreate.mockClear()
+	})
+
+	describe("usage metrics with streaming", () => {
+		const systemPrompt = "You are a helpful assistant."
+		const messages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "user",
+				content: [
+					{
+						type: "text" as const,
+						text: "Hello!",
+					},
+				],
+			},
+		]
+
+		it("should only yield usage metrics once at the end of the stream", async () => {
+			const stream = handler.createMessage(systemPrompt, messages)
+			const chunks: any[] = []
+			for await (const chunk of stream) {
+				chunks.push(chunk)
+			}
+
+			// Check we have text chunks
+			const textChunks = chunks.filter((chunk) => chunk.type === "text")
+			expect(textChunks).toHaveLength(2)
+			expect(textChunks[0].text).toBe("Test ")
+			expect(textChunks[1].text).toBe("response")
+
+			// Check we only have one usage chunk and it's the last one
+			const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
+			expect(usageChunks).toHaveLength(1)
+			expect(usageChunks[0]).toEqual({
+				type: "usage",
+				inputTokens: 10,
+				outputTokens: 5,
+			})
+
+			// Check the usage chunk is the last one reported from the API
+			const lastChunk = chunks[chunks.length - 1]
+			expect(lastChunk.type).toBe("usage")
+			expect(lastChunk.inputTokens).toBe(10)
+			expect(lastChunk.outputTokens).toBe(5)
+		})
+
+		it("should handle case where usage is only in the final chunk", async () => {
+			// Override the mock for this specific test
+			mockCreate.mockImplementationOnce(async (options) => {
+				if (!options.stream) {
+					return {
+						id: "test-completion",
+						choices: [{ message: { role: "assistant", content: "Test response" } }],
+						usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
+					}
+				}
+
+				return {
+					[Symbol.asyncIterator]: async function* () {
+						// First chunk with no usage
+						yield {
+							choices: [{ delta: { content: "Test " }, index: 0 }],
+							usage: null,
+						}
+
+						// Second chunk with no usage
+						yield {
+							choices: [{ delta: { content: "response" }, index: 0 }],
+							usage: null,
+						}
+
+						// Final chunk with usage data
+						yield {
+							choices: [{ delta: {}, index: 0 }],
+							usage: {
+								prompt_tokens: 10,
+								completion_tokens: 5,
+								total_tokens: 15,
+							},
+						}
+					},
+				}
+			})
+
+			const stream = handler.createMessage(systemPrompt, messages)
+			const chunks: any[] = []
+			for await (const chunk of stream) {
+				chunks.push(chunk)
+			}
+
+			// Check usage metrics
+			const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
+			expect(usageChunks).toHaveLength(1)
+			expect(usageChunks[0]).toEqual({
+				type: "usage",
+				inputTokens: 10,
+				outputTokens: 5,
+			})
+		})
+
+		it("should handle case where no usage is provided", async () => {
+			// Override the mock for this specific test
+			mockCreate.mockImplementationOnce(async (options) => {
+				if (!options.stream) {
+					return {
+						id: "test-completion",
+						choices: [{ message: { role: "assistant", content: "Test response" } }],
+						usage: null,
+					}
+				}
+
+				return {
+					[Symbol.asyncIterator]: async function* () {
+						yield {
+							choices: [{ delta: { content: "Test response" }, index: 0 }],
+							usage: null,
+						}
+						yield {
+							choices: [{ delta: {}, index: 0 }],
+							usage: null,
+						}
+					},
+				}
+			})
+
+			const stream = handler.createMessage(systemPrompt, messages)
+			const chunks: any[] = []
+			for await (const chunk of stream) {
+				chunks.push(chunk)
+			}
+
+			// Check we don't have any usage chunks
+			const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
+			expect(usageChunks).toHaveLength(0)
+		})
+	})
+})

+ 460 - 29
src/api/providers/bedrock.ts

@@ -11,6 +11,47 @@ import { ApiHandlerOptions, BedrockModelId, ModelInfo, bedrockDefaultModelId, be
 import { ApiStream } from "../transform/stream"
 import { ApiStream } from "../transform/stream"
 import { convertToBedrockConverseMessages } from "../transform/bedrock-converse-format"
 import { convertToBedrockConverseMessages } from "../transform/bedrock-converse-format"
 import { BaseProvider } from "./base-provider"
 import { BaseProvider } from "./base-provider"
+import { logger } from "../../utils/logging"
+
+/**
+ * Validates an AWS Bedrock ARN format and optionally checks if the region in the ARN matches the provided region
+ * @param arn The ARN string to validate
+ * @param region Optional region to check against the ARN's region
+ * @returns An object with validation results: { isValid, arnRegion, errorMessage }
+ */
+function validateBedrockArn(arn: string, region?: string) {
+	// Validate ARN format
+	const arnRegex = /^arn:aws:bedrock:([^:]+):(\d+):(foundation-model|provisioned-model|default-prompt-router)\/(.+)$/
+	const match = arn.match(arnRegex)
+
+	if (!match) {
+		return {
+			isValid: false,
+			arnRegion: undefined,
+			errorMessage:
+				"Invalid ARN format. ARN should follow the pattern: arn:aws:bedrock:region:account-id:resource-type/resource-name",
+		}
+	}
+
+	// Extract region from ARN
+	const arnRegion = match[1]
+
+	// Check if region in ARN matches provided region (if specified)
+	if (region && arnRegion !== region) {
+		return {
+			isValid: true,
+			arnRegion,
+			errorMessage: `Warning: The region in your ARN (${arnRegion}) does not match your selected region (${region}). This may cause access issues. The provider will use the region from the ARN.`,
+		}
+	}
+
+	// ARN is valid and region matches (or no region was provided to check against)
+	return {
+		isValid: true,
+		arnRegion,
+		errorMessage: undefined,
+	}
+}
 
 
 const BEDROCK_DEFAULT_TEMPERATURE = 0.3
 const BEDROCK_DEFAULT_TEMPERATURE = 0.3
 
 
@@ -55,8 +96,31 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
 		super()
 		super()
 		this.options = options
 		this.options = options
 
 
+		// Extract region from custom ARN if provided
+		let region = this.options.awsRegion || "us-east-1"
+
+		// If using custom ARN, extract region from the ARN
+		if (this.options.awsCustomArn) {
+			const validation = validateBedrockArn(this.options.awsCustomArn, region)
+
+			if (validation.isValid && validation.arnRegion) {
+				// If there's a region mismatch warning, log it and use the ARN region
+				if (validation.errorMessage) {
+					logger.info(
+						`Region mismatch: Selected region is ${region}, but ARN region is ${validation.arnRegion}. Using ARN region.`,
+						{
+							ctx: "bedrock",
+							selectedRegion: region,
+							arnRegion: validation.arnRegion,
+						},
+					)
+					region = validation.arnRegion
+				}
+			}
+		}
+
 		const clientConfig: BedrockRuntimeClientConfig = {
 		const clientConfig: BedrockRuntimeClientConfig = {
-			region: this.options.awsRegion || "us-east-1",
+			region: region,
 		}
 		}
 
 
 		if (this.options.awsUseProfile && this.options.awsProfile) {
 		if (this.options.awsUseProfile && this.options.awsProfile) {
@@ -81,7 +145,41 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
 
 
 		// Handle cross-region inference
 		// Handle cross-region inference
 		let modelId: string
 		let modelId: string
-		if (this.options.awsUseCrossRegionInference) {
+
+		// For custom ARNs, use the ARN directly without modification
+		if (this.options.awsCustomArn) {
+			modelId = modelConfig.id
+
+			// Validate ARN format and check region match
+			const clientRegion = this.client.config.region as string
+			const validation = validateBedrockArn(modelId, clientRegion)
+
+			if (!validation.isValid) {
+				logger.error("Invalid ARN format", {
+					ctx: "bedrock",
+					modelId,
+					errorMessage: validation.errorMessage,
+				})
+				yield {
+					type: "text",
+					text: `Error: ${validation.errorMessage}`,
+				}
+				yield { type: "usage", inputTokens: 0, outputTokens: 0 }
+				throw new Error("Invalid ARN format")
+			}
+
+			// Extract region from ARN
+			const arnRegion = validation.arnRegion!
+
+			// Log warning if there's a region mismatch
+			if (validation.errorMessage) {
+				logger.warn(validation.errorMessage, {
+					ctx: "bedrock",
+					arnRegion,
+					clientRegion,
+				})
+			}
+		} else if (this.options.awsUseCrossRegionInference) {
 			let regionPrefix = (this.options.awsRegion || "").slice(0, 3)
 			let regionPrefix = (this.options.awsRegion || "").slice(0, 3)
 			switch (regionPrefix) {
 			switch (regionPrefix) {
 				case "us-":
 				case "us-":
@@ -107,7 +205,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
 			messages: formattedMessages,
 			messages: formattedMessages,
 			system: [{ text: systemPrompt }],
 			system: [{ text: systemPrompt }],
 			inferenceConfig: {
 			inferenceConfig: {
-				maxTokens: modelConfig.info.maxTokens || 5000,
+				maxTokens: modelConfig.info.maxTokens || 4096,
 				temperature: this.options.modelTemperature ?? BEDROCK_DEFAULT_TEMPERATURE,
 				temperature: this.options.modelTemperature ?? BEDROCK_DEFAULT_TEMPERATURE,
 				topP: 0.1,
 				topP: 0.1,
 				...(this.options.awsUsePromptCache
 				...(this.options.awsUsePromptCache
@@ -121,6 +219,16 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
 		}
 		}
 
 
 		try {
 		try {
+			// Log the payload for debugging custom ARN issues
+			if (this.options.awsCustomArn) {
+				logger.debug("Using custom ARN for Bedrock request", {
+					ctx: "bedrock",
+					customArn: this.options.awsCustomArn,
+					clientRegion: this.client.config.region,
+					payload: JSON.stringify(payload, null, 2),
+				})
+			}
+
 			const command = new ConverseStreamCommand(payload)
 			const command = new ConverseStreamCommand(payload)
 			const response = await this.client.send(command)
 			const response = await this.client.send(command)
 
 
@@ -134,7 +242,11 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
 				try {
 				try {
 					streamEvent = typeof chunk === "string" ? JSON.parse(chunk) : (chunk as unknown as StreamEvent)
 					streamEvent = typeof chunk === "string" ? JSON.parse(chunk) : (chunk as unknown as StreamEvent)
 				} catch (e) {
 				} catch (e) {
-					console.error("Failed to parse stream event:", e)
+					logger.error("Failed to parse stream event", {
+						ctx: "bedrock",
+						error: e instanceof Error ? e : String(e),
+						chunk: typeof chunk === "string" ? chunk : "binary data",
+					})
 					continue
 					continue
 				}
 				}
 
 
@@ -177,39 +289,257 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
 				}
 				}
 			}
 			}
 		} catch (error: unknown) {
 		} catch (error: unknown) {
-			console.error("Bedrock Runtime API Error:", error)
-			// Only access stack if error is an Error object
-			if (error instanceof Error) {
-				console.error("Error stack:", error.stack)
-				yield {
-					type: "text",
-					text: `Error: ${error.message}`,
+			logger.error("Bedrock Runtime API Error", {
+				ctx: "bedrock",
+				error: error instanceof Error ? error : String(error),
+			})
+
+			// Enhanced error handling for custom ARN issues
+			if (this.options.awsCustomArn) {
+				logger.error("Error occurred with custom ARN", {
+					ctx: "bedrock",
+					customArn: this.options.awsCustomArn,
+				})
+
+				// Check for common ARN-related errors
+				if (error instanceof Error) {
+					const errorMessage = error.message.toLowerCase()
+
+					// Access denied errors
+					if (
+						errorMessage.includes("access") &&
+						(errorMessage.includes("model") || errorMessage.includes("denied"))
+					) {
+						logger.error("Permissions issue with custom ARN", {
+							ctx: "bedrock",
+							customArn: this.options.awsCustomArn,
+							errorType: "access_denied",
+							clientRegion: this.client.config.region,
+						})
+						yield {
+							type: "text",
+							text: `Error: You don't have access to the model with the specified ARN. Please verify:
+
+1. The ARN is correct and points to a valid model
+2. Your AWS credentials have permission to access this model (check IAM policies)
+3. The region in the ARN (${this.client.config.region}) matches the region where the model is deployed
+4. If using a provisioned model, ensure it's active and not in a failed state
+5. If using a custom model, ensure your account has been granted access to it`,
+						}
+					}
+					// Model not found errors
+					else if (errorMessage.includes("not found") || errorMessage.includes("does not exist")) {
+						logger.error("Invalid ARN or non-existent model", {
+							ctx: "bedrock",
+							customArn: this.options.awsCustomArn,
+							errorType: "not_found",
+						})
+						yield {
+							type: "text",
+							text: `Error: The specified ARN does not exist or is invalid. Please check:
+
+1. The ARN format is correct (arn:aws:bedrock:region:account-id:resource-type/resource-name)
+2. The model exists in the specified region
+3. The account ID in the ARN is correct
+4. The resource type is one of: foundation-model, provisioned-model, or default-prompt-router`,
+						}
+					}
+					// Throttling errors
+					else if (
+						errorMessage.includes("throttl") ||
+						errorMessage.includes("rate") ||
+						errorMessage.includes("limit")
+					) {
+						logger.error("Throttling or rate limit issue with Bedrock", {
+							ctx: "bedrock",
+							customArn: this.options.awsCustomArn,
+							errorType: "throttling",
+						})
+						yield {
+							type: "text",
+							text: `Error: Request was throttled or rate limited. Please try:
+
+1. Reducing the frequency of requests
+2. If using a provisioned model, check its throughput settings
+3. Contact AWS support to request a quota increase if needed`,
+						}
+					}
+					// Other errors
+					else {
+						logger.error("Unspecified error with custom ARN", {
+							ctx: "bedrock",
+							customArn: this.options.awsCustomArn,
+							errorStack: error.stack,
+							errorMessage: error.message,
+						})
+						yield {
+							type: "text",
+							text: `Error with custom ARN: ${error.message}
+
+Please check:
+1. Your AWS credentials are valid and have the necessary permissions
+2. The ARN format is correct
+3. The region in the ARN matches the region where you're making the request`,
+						}
+					}
+				} else {
+					yield {
+						type: "text",
+						text: `Unknown error occurred with custom ARN. Please check your AWS credentials and ARN format.`,
+					}
 				}
 				}
-				yield {
-					type: "usage",
-					inputTokens: 0,
-					outputTokens: 0,
+			} else {
+				// Standard error handling for non-ARN cases
+				if (error instanceof Error) {
+					logger.error("Standard Bedrock error", {
+						ctx: "bedrock",
+						errorStack: error.stack,
+						errorMessage: error.message,
+					})
+					yield {
+						type: "text",
+						text: `Error: ${error.message}`,
+					}
+				} else {
+					logger.error("Unknown Bedrock error", {
+						ctx: "bedrock",
+						error: String(error),
+					})
+					yield {
+						type: "text",
+						text: "An unknown error occurred",
+					}
 				}
 				}
+			}
+
+			// Always yield usage info
+			yield {
+				type: "usage",
+				inputTokens: 0,
+				outputTokens: 0,
+			}
+
+			// Re-throw the error
+			if (error instanceof Error) {
 				throw error
 				throw error
 			} else {
 			} else {
-				const unknownError = new Error("An unknown error occurred")
-				yield {
-					type: "text",
-					text: unknownError.message,
-				}
-				yield {
-					type: "usage",
-					inputTokens: 0,
-					outputTokens: 0,
-				}
-				throw unknownError
+				throw new Error("An unknown error occurred")
 			}
 			}
 		}
 		}
 	}
 	}
 
 
 	override getModel(): { id: BedrockModelId | string; info: ModelInfo } {
 	override getModel(): { id: BedrockModelId | string; info: ModelInfo } {
+		// If custom ARN is provided, use it
+		if (this.options.awsCustomArn) {
+			// Custom ARNs should not be modified with region prefixes
+			// as they already contain the full resource path
+
+			// Check if the ARN contains information about the model type
+			// This helps set appropriate token limits for models behind prompt routers
+			const arnLower = this.options.awsCustomArn.toLowerCase()
+
+			// Determine model info based on ARN content
+			let modelInfo: ModelInfo
+
+			if (arnLower.includes("claude-3-7-sonnet") || arnLower.includes("claude-3.7-sonnet")) {
+				// Claude 3.7 Sonnet has 8192 tokens in Bedrock
+				modelInfo = {
+					maxTokens: 8192,
+					contextWindow: 200_000,
+					supportsPromptCache: false,
+					supportsImages: true,
+					supportsComputerUse: true,
+				}
+			} else if (arnLower.includes("claude-3-5-sonnet") || arnLower.includes("claude-3.5-sonnet")) {
+				// Claude 3.5 Sonnet has 8192 tokens in Bedrock
+				modelInfo = {
+					maxTokens: 8192,
+					contextWindow: 200_000,
+					supportsPromptCache: false,
+					supportsImages: true,
+					supportsComputerUse: true,
+				}
+			} else if (arnLower.includes("claude-3-opus") || arnLower.includes("claude-3.0-opus")) {
+				// Claude 3 Opus has 4096 tokens in Bedrock
+				modelInfo = {
+					maxTokens: 4096,
+					contextWindow: 200_000,
+					supportsPromptCache: false,
+					supportsImages: true,
+				}
+			} else if (arnLower.includes("claude-3-haiku") || arnLower.includes("claude-3.0-haiku")) {
+				// Claude 3 Haiku has 4096 tokens in Bedrock
+				modelInfo = {
+					maxTokens: 4096,
+					contextWindow: 200_000,
+					supportsPromptCache: false,
+					supportsImages: true,
+				}
+			} else if (arnLower.includes("claude-3-5-haiku") || arnLower.includes("claude-3.5-haiku")) {
+				// Claude 3.5 Haiku has 8192 tokens in Bedrock
+				modelInfo = {
+					maxTokens: 8192,
+					contextWindow: 200_000,
+					supportsPromptCache: false,
+					supportsImages: false,
+				}
+			} else if (arnLower.includes("claude")) {
+				// Generic Claude model with conservative token limit
+				modelInfo = {
+					maxTokens: 4096,
+					contextWindow: 128_000,
+					supportsPromptCache: false,
+					supportsImages: true,
+				}
+			} else if (arnLower.includes("llama3") || arnLower.includes("llama-3")) {
+				// Llama 3 models typically have 8192 tokens in Bedrock
+				modelInfo = {
+					maxTokens: 8192,
+					contextWindow: 128_000,
+					supportsPromptCache: false,
+					supportsImages: arnLower.includes("90b") || arnLower.includes("11b"),
+				}
+			} else if (arnLower.includes("nova-pro")) {
+				// Amazon Nova Pro
+				modelInfo = {
+					maxTokens: 5000,
+					contextWindow: 300_000,
+					supportsPromptCache: false,
+					supportsImages: true,
+				}
+			} else {
+				// Default for unknown models or prompt routers
+				modelInfo = {
+					maxTokens: 4096,
+					contextWindow: 128_000,
+					supportsPromptCache: false,
+					supportsImages: true,
+				}
+			}
+
+			// If modelMaxTokens is explicitly set in options, override the default
+			if (this.options.modelMaxTokens && this.options.modelMaxTokens > 0) {
+				modelInfo.maxTokens = this.options.modelMaxTokens
+			}
+
+			return {
+				id: this.options.awsCustomArn,
+				info: modelInfo,
+			}
+		}
+
 		const modelId = this.options.apiModelId
 		const modelId = this.options.apiModelId
 		if (modelId) {
 		if (modelId) {
+			// Special case for custom ARN option
+			if (modelId === "custom-arn") {
+				// This should not happen as we should have awsCustomArn set
+				// but just in case, return a default model
+				return {
+					id: bedrockDefaultModelId,
+					info: bedrockModels[bedrockDefaultModelId],
+				}
+			}
+
 			// For tests, allow any model ID
 			// For tests, allow any model ID
 			if (process.env.NODE_ENV === "test") {
 			if (process.env.NODE_ENV === "test") {
 				return {
 				return {
@@ -239,7 +569,43 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
 
 
 			// Handle cross-region inference
 			// Handle cross-region inference
 			let modelId: string
 			let modelId: string
-			if (this.options.awsUseCrossRegionInference) {
+
+			// For custom ARNs, use the ARN directly without modification
+			if (this.options.awsCustomArn) {
+				modelId = modelConfig.id
+				logger.debug("Using custom ARN in completePrompt", {
+					ctx: "bedrock",
+					customArn: this.options.awsCustomArn,
+				})
+
+				// Validate ARN format and check region match
+				const clientRegion = this.client.config.region as string
+				const validation = validateBedrockArn(modelId, clientRegion)
+
+				if (!validation.isValid) {
+					logger.error("Invalid ARN format in completePrompt", {
+						ctx: "bedrock",
+						modelId,
+						errorMessage: validation.errorMessage,
+					})
+					throw new Error(
+						validation.errorMessage ||
+							"Invalid ARN format. ARN should follow the pattern: arn:aws:bedrock:region:account-id:resource-type/resource-name",
+					)
+				}
+
+				// Extract region from ARN
+				const arnRegion = validation.arnRegion!
+
+				// Log warning if there's a region mismatch
+				if (validation.errorMessage) {
+					logger.warn(validation.errorMessage, {
+						ctx: "bedrock",
+						arnRegion,
+						clientRegion,
+					})
+				}
+			} else if (this.options.awsUseCrossRegionInference) {
 				let regionPrefix = (this.options.awsRegion || "").slice(0, 3)
 				let regionPrefix = (this.options.awsRegion || "").slice(0, 3)
 				switch (regionPrefix) {
 				switch (regionPrefix) {
 					case "us-":
 					case "us-":
@@ -265,12 +631,21 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
 					},
 					},
 				]),
 				]),
 				inferenceConfig: {
 				inferenceConfig: {
-					maxTokens: modelConfig.info.maxTokens || 5000,
+					maxTokens: modelConfig.info.maxTokens || 4096,
 					temperature: this.options.modelTemperature ?? BEDROCK_DEFAULT_TEMPERATURE,
 					temperature: this.options.modelTemperature ?? BEDROCK_DEFAULT_TEMPERATURE,
 					topP: 0.1,
 					topP: 0.1,
 				},
 				},
 			}
 			}
 
 
+			// Log the payload for debugging custom ARN issues
+			if (this.options.awsCustomArn) {
+				logger.debug("Bedrock completePrompt request details", {
+					ctx: "bedrock",
+					clientRegion: this.client.config.region,
+					payload: JSON.stringify(payload, null, 2),
+				})
+			}
+
 			const command = new ConverseCommand(payload)
 			const command = new ConverseCommand(payload)
 			const response = await this.client.send(command)
 			const response = await this.client.send(command)
 
 
@@ -282,11 +657,67 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
 						return output.content
 						return output.content
 					}
 					}
 				} catch (parseError) {
 				} catch (parseError) {
-					console.error("Failed to parse Bedrock response:", parseError)
+					logger.error("Failed to parse Bedrock response", {
+						ctx: "bedrock",
+						error: parseError instanceof Error ? parseError : String(parseError),
+					})
 				}
 				}
 			}
 			}
 			return ""
 			return ""
 		} catch (error) {
 		} catch (error) {
+			// Enhanced error handling for custom ARN issues
+			if (this.options.awsCustomArn) {
+				logger.error("Error occurred with custom ARN in completePrompt", {
+					ctx: "bedrock",
+					customArn: this.options.awsCustomArn,
+					error: error instanceof Error ? error : String(error),
+				})
+
+				if (error instanceof Error) {
+					const errorMessage = error.message.toLowerCase()
+
+					// Access denied errors
+					if (
+						errorMessage.includes("access") &&
+						(errorMessage.includes("model") || errorMessage.includes("denied"))
+					) {
+						throw new Error(
+							`Bedrock custom ARN error: You don't have access to the model with the specified ARN. Please verify:
+1. The ARN is correct and points to a valid model
+2. Your AWS credentials have permission to access this model (check IAM policies)
+3. The region in the ARN matches the region where the model is deployed
+4. If using a provisioned model, ensure it's active and not in a failed state`,
+						)
+					}
+					// Model not found errors
+					else if (errorMessage.includes("not found") || errorMessage.includes("does not exist")) {
+						throw new Error(
+							`Bedrock custom ARN error: The specified ARN does not exist or is invalid. Please check:
+1. The ARN format is correct (arn:aws:bedrock:region:account-id:resource-type/resource-name)
+2. The model exists in the specified region
+3. The account ID in the ARN is correct
+4. The resource type is one of: foundation-model, provisioned-model, or default-prompt-router`,
+						)
+					}
+					// Throttling errors
+					else if (
+						errorMessage.includes("throttl") ||
+						errorMessage.includes("rate") ||
+						errorMessage.includes("limit")
+					) {
+						throw new Error(
+							`Bedrock custom ARN error: Request was throttled or rate limited. Please try:
+1. Reducing the frequency of requests
+2. If using a provisioned model, check its throughput settings
+3. Contact AWS support to request a quota increase if needed`,
+						)
+					} else {
+						throw new Error(`Bedrock custom ARN error: ${error.message}`)
+					}
+				}
+			}
+
+			// Standard error handling
 			if (error instanceof Error) {
 			if (error instanceof Error) {
 				throw new Error(`Bedrock completion error: ${error.message}`)
 				throw new Error(`Bedrock completion error: ${error.message}`)
 			}
 			}

+ 7 - 1
src/api/providers/openai.ts

@@ -99,6 +99,8 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
 
 
 			const stream = await this.client.chat.completions.create(requestOptions)
 			const stream = await this.client.chat.completions.create(requestOptions)
 
 
+			let lastUsage
+
 			for await (const chunk of stream) {
 			for await (const chunk of stream) {
 				const delta = chunk.choices[0]?.delta ?? {}
 				const delta = chunk.choices[0]?.delta ?? {}
 
 
@@ -116,9 +118,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
 					}
 					}
 				}
 				}
 				if (chunk.usage) {
 				if (chunk.usage) {
-					yield this.processUsageMetrics(chunk.usage, modelInfo)
+					lastUsage = chunk.usage
 				}
 				}
 			}
 			}
+
+			if (lastUsage) {
+				yield this.processUsageMetrics(lastUsage, modelInfo)
+			}
 		} else {
 		} else {
 			// o1 for instance doesnt support streaming, non-1 temp, or system prompt
 			// o1 for instance doesnt support streaming, non-1 temp, or system prompt
 			const systemMessage: OpenAI.Chat.ChatCompletionUserMessageParam = {
 			const systemMessage: OpenAI.Chat.ChatCompletionUserMessageParam = {

+ 29 - 4
src/core/Cline.ts

@@ -48,6 +48,7 @@ import {
 	ClineSay,
 	ClineSay,
 	ClineSayBrowserAction,
 	ClineSayBrowserAction,
 	ClineSayTool,
 	ClineSayTool,
+	ToolProgressStatus,
 } from "../shared/ExtensionMessage"
 } from "../shared/ExtensionMessage"
 import { getApiMetrics } from "../shared/getApiMetrics"
 import { getApiMetrics } from "../shared/getApiMetrics"
 import { HistoryItem } from "../shared/HistoryItem"
 import { HistoryItem } from "../shared/HistoryItem"
@@ -408,6 +409,7 @@ export class Cline {
 		type: ClineAsk,
 		type: ClineAsk,
 		text?: string,
 		text?: string,
 		partial?: boolean,
 		partial?: boolean,
+		progressStatus?: ToolProgressStatus,
 	): Promise<{ response: ClineAskResponse; text?: string; images?: string[] }> {
 	): Promise<{ response: ClineAskResponse; text?: string; images?: string[] }> {
 		// If this Cline instance was aborted by the provider, then the only thing keeping us alive is a promise still running in the background, in which case we don't want to send its result to the webview as it is attached to a new instance of Cline now. So we can safely ignore the result of any active promises, and this class will be deallocated. (Although we set Cline = undefined in provider, that simply removes the reference to this instance, but the instance is still alive until this promise resolves or rejects.)
 		// If this Cline instance was aborted by the provider, then the only thing keeping us alive is a promise still running in the background, in which case we don't want to send its result to the webview as it is attached to a new instance of Cline now. So we can safely ignore the result of any active promises, and this class will be deallocated. (Although we set Cline = undefined in provider, that simply removes the reference to this instance, but the instance is still alive until this promise resolves or rejects.)
 		if (this.abort) {
 		if (this.abort) {
@@ -423,6 +425,7 @@ export class Cline {
 					// existing partial message, so update it
 					// existing partial message, so update it
 					lastMessage.text = text
 					lastMessage.text = text
 					lastMessage.partial = partial
 					lastMessage.partial = partial
+					lastMessage.progressStatus = progressStatus
 					// todo be more efficient about saving and posting only new data or one whole message at a time so ignore partial for saves, and only post parts of partial message instead of whole array in new listener
 					// todo be more efficient about saving and posting only new data or one whole message at a time so ignore partial for saves, and only post parts of partial message instead of whole array in new listener
 					// await this.saveClineMessages()
 					// await this.saveClineMessages()
 					// await this.providerRef.deref()?.postStateToWebview()
 					// await this.providerRef.deref()?.postStateToWebview()
@@ -460,6 +463,8 @@ export class Cline {
 					// lastMessage.ts = askTs
 					// lastMessage.ts = askTs
 					lastMessage.text = text
 					lastMessage.text = text
 					lastMessage.partial = false
 					lastMessage.partial = false
+					lastMessage.progressStatus = progressStatus
+
 					await this.saveClineMessages()
 					await this.saveClineMessages()
 					// await this.providerRef.deref()?.postStateToWebview()
 					// await this.providerRef.deref()?.postStateToWebview()
 					await this.providerRef
 					await this.providerRef
@@ -511,6 +516,7 @@ export class Cline {
 		images?: string[],
 		images?: string[],
 		partial?: boolean,
 		partial?: boolean,
 		checkpoint?: Record<string, unknown>,
 		checkpoint?: Record<string, unknown>,
+		progressStatus?: ToolProgressStatus,
 	): Promise<undefined> {
 	): Promise<undefined> {
 		if (this.abort) {
 		if (this.abort) {
 			throw new Error(`Task: ${this.taskNumber} Roo Code instance aborted (#2)`)
 			throw new Error(`Task: ${this.taskNumber} Roo Code instance aborted (#2)`)
@@ -526,6 +532,7 @@ export class Cline {
 					lastMessage.text = text
 					lastMessage.text = text
 					lastMessage.images = images
 					lastMessage.images = images
 					lastMessage.partial = partial
 					lastMessage.partial = partial
+					lastMessage.progressStatus = progressStatus
 					await this.providerRef
 					await this.providerRef
 						.deref()
 						.deref()
 						?.postMessageToWebview({ type: "partialMessage", partialMessage: lastMessage })
 						?.postMessageToWebview({ type: "partialMessage", partialMessage: lastMessage })
@@ -545,6 +552,7 @@ export class Cline {
 					lastMessage.text = text
 					lastMessage.text = text
 					lastMessage.images = images
 					lastMessage.images = images
 					lastMessage.partial = false
 					lastMessage.partial = false
+					lastMessage.progressStatus = progressStatus
 
 
 					// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
 					// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
 					await this.saveClineMessages()
 					await this.saveClineMessages()
@@ -1394,8 +1402,12 @@ export class Cline {
 					isCheckpointPossible = true
 					isCheckpointPossible = true
 				}
 				}
 
 
-				const askApproval = async (type: ClineAsk, partialMessage?: string) => {
-					const { response, text, images } = await this.ask(type, partialMessage, false)
+				const askApproval = async (
+					type: ClineAsk,
+					partialMessage?: string,
+					progressStatus?: ToolProgressStatus,
+				) => {
+					const { response, text, images } = await this.ask(type, partialMessage, false, progressStatus)
 					if (response !== "yesButtonClicked") {
 					if (response !== "yesButtonClicked") {
 						// Handle both messageResponse and noButtonClicked with text
 						// Handle both messageResponse and noButtonClicked with text
 						if (text) {
 						if (text) {
@@ -1703,8 +1715,16 @@ export class Cline {
 						try {
 						try {
 							if (block.partial) {
 							if (block.partial) {
 								// update gui message
 								// update gui message
+								let toolProgressStatus
+								if (this.diffStrategy && this.diffStrategy.getProgressStatus) {
+									toolProgressStatus = this.diffStrategy.getProgressStatus(block)
+								}
+
 								const partialMessage = JSON.stringify(sharedMessageProps)
 								const partialMessage = JSON.stringify(sharedMessageProps)
-								await this.ask("tool", partialMessage, block.partial).catch(() => {})
+
+								await this.ask("tool", partialMessage, block.partial, toolProgressStatus).catch(
+									() => {},
+								)
 								break
 								break
 							} else {
 							} else {
 								if (!relPath) {
 								if (!relPath) {
@@ -1799,7 +1819,12 @@ export class Cline {
 									diff: diffContent,
 									diff: diffContent,
 								} satisfies ClineSayTool)
 								} satisfies ClineSayTool)
 
 
-								const didApprove = await askApproval("tool", completeMessage)
+								let toolProgressStatus
+								if (this.diffStrategy && this.diffStrategy.getProgressStatus) {
+									toolProgressStatus = this.diffStrategy.getProgressStatus(block, diffResult)
+								}
+
+								const didApprove = await askApproval("tool", completeMessage, toolProgressStatus)
 								if (!didApprove) {
 								if (!didApprove) {
 									await this.diffViewProvider.revertChanges() // This likely handles closing the diff view
 									await this.diffViewProvider.revertChanges() // This likely handles closing the diff view
 									break
 									break

+ 25 - 0
src/core/diff/strategies/multi-search-replace.ts

@@ -1,6 +1,8 @@
 import { DiffStrategy, DiffResult } from "../types"
 import { DiffStrategy, DiffResult } from "../types"
 import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text"
 import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text"
 import { distance } from "fastest-levenshtein"
 import { distance } from "fastest-levenshtein"
+import { ToolProgressStatus } from "../../../shared/ExtensionMessage"
+import { ToolUse } from "../../assistant-message"
 
 
 const BUFFER_LINES = 40 // Number of extra context lines to show before and after matches
 const BUFFER_LINES = 40 // Number of extra context lines to show before and after matches
 
 
@@ -362,4 +364,27 @@ Only use a single line of '=======' between search and replacement content, beca
 			failParts: diffResults,
 			failParts: diffResults,
 		}
 		}
 	}
 	}
+
+	getProgressStatus(toolUse: ToolUse, result?: DiffResult): ToolProgressStatus {
+		const diffContent = toolUse.params.diff
+		if (diffContent) {
+			const icon = "diff-multiple"
+			const searchBlockCount = (diffContent.match(/SEARCH/g) || []).length
+			if (toolUse.partial) {
+				if (diffContent.length < 1000 || (diffContent.length / 50) % 10 === 0) {
+					return { icon, text: `${searchBlockCount}` }
+				}
+			} else if (result) {
+				if (result.failParts?.length) {
+					return {
+						icon,
+						text: `${searchBlockCount - result.failParts.length}/${searchBlockCount}`,
+					}
+				} else {
+					return { icon, text: `${searchBlockCount}` }
+				}
+			}
+		}
+		return {}
+	}
 }
 }

+ 5 - 0
src/core/diff/types.ts

@@ -2,6 +2,9 @@
  * Interface for implementing different diff strategies
  * Interface for implementing different diff strategies
  */
  */
 
 
+import { ToolProgressStatus } from "../../shared/ExtensionMessage"
+import { ToolUse } from "../assistant-message"
+
 export type DiffResult =
 export type DiffResult =
 	| { success: true; content: string; failParts?: DiffResult[] }
 	| { success: true; content: string; failParts?: DiffResult[] }
 	| ({
 	| ({
@@ -34,4 +37,6 @@ export interface DiffStrategy {
 	 * @returns A DiffResult object containing either the successful result or error details
 	 * @returns A DiffResult object containing either the successful result or error details
 	 */
 	 */
 	applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): Promise<DiffResult>
 	applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): Promise<DiffResult>
+
+	getProgressStatus?(toolUse: ToolUse, result?: any): ToolProgressStatus
 }
 }

+ 128 - 3
src/core/webview/ClineProvider.ts

@@ -30,6 +30,8 @@ import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
 import { McpHub } from "../../services/mcp/McpHub"
 import { McpHub } from "../../services/mcp/McpHub"
 import { McpServerManager } from "../../services/mcp/McpServerManager"
 import { McpServerManager } from "../../services/mcp/McpServerManager"
 import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService"
 import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService"
+import { BrowserSession } from "../../services/browser/BrowserSession"
+import { discoverChromeInstances } from "../../services/browser/browserDiscovery"
 import { fileExistsAtPath } from "../../utils/fs"
 import { fileExistsAtPath } from "../../utils/fs"
 import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
 import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
 import { singleCompletionHandler } from "../../utils/single-completion-handler"
 import { singleCompletionHandler } from "../../utils/single-completion-handler"
@@ -1262,6 +1264,105 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 						await this.updateGlobalState("browserViewportSize", browserViewportSize)
 						await this.updateGlobalState("browserViewportSize", browserViewportSize)
 						await this.postStateToWebview()
 						await this.postStateToWebview()
 						break
 						break
+					case "remoteBrowserHost":
+						await this.updateGlobalState("remoteBrowserHost", message.text)
+						await this.postStateToWebview()
+						break
+					case "remoteBrowserEnabled":
+						// Store the preference in global state
+						// remoteBrowserEnabled now means "enable remote browser connection"
+						await this.updateGlobalState("remoteBrowserEnabled", message.bool ?? false)
+						// If disabling remote browser connection, clear the remoteBrowserHost
+						if (!message.bool) {
+							await this.updateGlobalState("remoteBrowserHost", undefined)
+						}
+						await this.postStateToWebview()
+						break
+					case "testBrowserConnection":
+						try {
+							const browserSession = new BrowserSession(this.context)
+							// If no text is provided, try auto-discovery
+							if (!message.text) {
+								try {
+									const discoveredHost = await discoverChromeInstances()
+									if (discoveredHost) {
+										// Test the connection to the discovered host
+										const result = await browserSession.testConnection(discoveredHost)
+										// Send the result back to the webview
+										await this.postMessageToWebview({
+											type: "browserConnectionResult",
+											success: result.success,
+											text: `Auto-discovered and tested connection to Chrome at ${discoveredHost}: ${result.message}`,
+											values: { endpoint: result.endpoint },
+										})
+									} else {
+										await this.postMessageToWebview({
+											type: "browserConnectionResult",
+											success: false,
+											text: "No Chrome instances found on the network. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).",
+										})
+									}
+								} catch (error) {
+									await this.postMessageToWebview({
+										type: "browserConnectionResult",
+										success: false,
+										text: `Error during auto-discovery: ${error instanceof Error ? error.message : String(error)}`,
+									})
+								}
+							} else {
+								// Test the provided URL
+								const result = await browserSession.testConnection(message.text)
+
+								// Send the result back to the webview
+								await this.postMessageToWebview({
+									type: "browserConnectionResult",
+									success: result.success,
+									text: result.message,
+									values: { endpoint: result.endpoint },
+								})
+							}
+						} catch (error) {
+							await this.postMessageToWebview({
+								type: "browserConnectionResult",
+								success: false,
+								text: `Error testing connection: ${error instanceof Error ? error.message : String(error)}`,
+							})
+						}
+						break
+					case "discoverBrowser":
+						try {
+							const discoveredHost = await discoverChromeInstances()
+
+							if (discoveredHost) {
+								// Don't update the remoteBrowserHost state when auto-discovering
+								// This way we don't override the user's preference
+
+								// Test the connection to get the endpoint
+								const browserSession = new BrowserSession(this.context)
+								const result = await browserSession.testConnection(discoveredHost)
+
+								// Send the result back to the webview
+								await this.postMessageToWebview({
+									type: "browserConnectionResult",
+									success: true,
+									text: `Successfully discovered and connected to Chrome at ${discoveredHost}`,
+									values: { endpoint: result.endpoint },
+								})
+							} else {
+								await this.postMessageToWebview({
+									type: "browserConnectionResult",
+									success: false,
+									text: "No Chrome instances found on the network. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).",
+								})
+							}
+						} catch (error) {
+							await this.postMessageToWebview({
+								type: "browserConnectionResult",
+								success: false,
+								text: `Error discovering browser: ${error instanceof Error ? error.message : String(error)}`,
+							})
+						}
+						break
 					case "fuzzyMatchThreshold":
 					case "fuzzyMatchThreshold":
 						await this.updateGlobalState("fuzzyMatchThreshold", message.value)
 						await this.updateGlobalState("fuzzyMatchThreshold", message.value)
 						await this.postStateToWebview()
 						await this.postStateToWebview()
@@ -1826,6 +1927,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 				fuzzyMatchThreshold,
 				fuzzyMatchThreshold,
 				experiments,
 				experiments,
 				enableMcpServerCreation,
 				enableMcpServerCreation,
+				browserToolEnabled,
 			} = await this.getState()
 			} = await this.getState()
 
 
 			// Create diffStrategy based on current model and settings
 			// Create diffStrategy based on current model and settings
@@ -1841,10 +1943,14 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 
 
 			const rooIgnoreInstructions = this.getCurrentCline()?.rooIgnoreController?.getInstructions()
 			const rooIgnoreInstructions = this.getCurrentCline()?.rooIgnoreController?.getInstructions()
 
 
+			// Determine if browser tools can be used based on model support and user settings
+			const modelSupportsComputerUse = this.getCurrentCline()?.api.getModel().info.supportsComputerUse ?? false
+			const canUseBrowserTool = modelSupportsComputerUse && (browserToolEnabled ?? true)
+
 			const systemPrompt = await SYSTEM_PROMPT(
 			const systemPrompt = await SYSTEM_PROMPT(
 				this.context,
 				this.context,
 				cwd,
 				cwd,
-				apiConfiguration.openRouterModelInfo?.supportsComputerUse ?? false,
+				canUseBrowserTool,
 				mcpEnabled ? this.mcpHub : undefined,
 				mcpEnabled ? this.mcpHub : undefined,
 				diffStrategy,
 				diffStrategy,
 				browserViewportSize ?? "900x600",
 				browserViewportSize ?? "900x600",
@@ -1971,11 +2077,24 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 	// MCP
 	// MCP
 
 
 	async ensureMcpServersDirectoryExists(): Promise<string> {
 	async ensureMcpServersDirectoryExists(): Promise<string> {
-		const mcpServersDir = path.join(os.homedir(), "Documents", "Cline", "MCP")
+		// Get platform-specific application data directory
+		let mcpServersDir: string
+		if (process.platform === "win32") {
+			// Windows: %APPDATA%\Roo-Code\MCP
+			mcpServersDir = path.join(os.homedir(), "AppData", "Roaming", "Roo-Code", "MCP")
+		} else if (process.platform === "darwin") {
+			// macOS: ~/Documents/Cline/MCP
+			mcpServersDir = path.join(os.homedir(), "Documents", "Cline", "MCP")
+		} else {
+			// Linux: ~/.local/share/Cline/MCP
+			mcpServersDir = path.join(os.homedir(), ".local", "share", "Roo-Code", "MCP")
+		}
+
 		try {
 		try {
 			await fs.mkdir(mcpServersDir, { recursive: true })
 			await fs.mkdir(mcpServersDir, { recursive: true })
 		} catch (error) {
 		} catch (error) {
-			return "~/Documents/Cline/MCP" // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine since this path is only ever used in the system prompt
+			// Fallback to a relative path if directory creation fails
+			return path.join(os.homedir(), ".roo-code", "mcp")
 		}
 		}
 		return mcpServersDir
 		return mcpServersDir
 	}
 	}
@@ -2192,6 +2311,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			soundVolume,
 			soundVolume,
 			browserViewportSize,
 			browserViewportSize,
 			screenshotQuality,
 			screenshotQuality,
+			remoteBrowserHost,
+			remoteBrowserEnabled,
 			preferredLanguage,
 			preferredLanguage,
 			writeDelayMs,
 			writeDelayMs,
 			terminalOutputLimit,
 			terminalOutputLimit,
@@ -2250,6 +2371,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			soundVolume: soundVolume ?? 0.5,
 			soundVolume: soundVolume ?? 0.5,
 			browserViewportSize: browserViewportSize ?? "900x600",
 			browserViewportSize: browserViewportSize ?? "900x600",
 			screenshotQuality: screenshotQuality ?? 75,
 			screenshotQuality: screenshotQuality ?? 75,
+			remoteBrowserHost,
+			remoteBrowserEnabled: remoteBrowserEnabled ?? false,
 			preferredLanguage: preferredLanguage ?? "English",
 			preferredLanguage: preferredLanguage ?? "English",
 			writeDelayMs: writeDelayMs ?? 1000,
 			writeDelayMs: writeDelayMs ?? 1000,
 			terminalOutputLimit: terminalOutputLimit ?? TERMINAL_OUTPUT_LIMIT,
 			terminalOutputLimit: terminalOutputLimit ?? TERMINAL_OUTPUT_LIMIT,
@@ -2403,6 +2526,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			soundVolume: stateValues.soundVolume,
 			soundVolume: stateValues.soundVolume,
 			browserViewportSize: stateValues.browserViewportSize ?? "900x600",
 			browserViewportSize: stateValues.browserViewportSize ?? "900x600",
 			screenshotQuality: stateValues.screenshotQuality ?? 75,
 			screenshotQuality: stateValues.screenshotQuality ?? 75,
+			remoteBrowserHost: stateValues.remoteBrowserHost,
+			remoteBrowserEnabled: stateValues.remoteBrowserEnabled ?? false,
 			fuzzyMatchThreshold: stateValues.fuzzyMatchThreshold ?? 1.0,
 			fuzzyMatchThreshold: stateValues.fuzzyMatchThreshold ?? 1.0,
 			writeDelayMs: stateValues.writeDelayMs ?? 1000,
 			writeDelayMs: stateValues.writeDelayMs ?? 1000,
 			terminalOutputLimit: stateValues.terminalOutputLimit ?? TERMINAL_OUTPUT_LIMIT,
 			terminalOutputLimit: stateValues.terminalOutputLimit ?? TERMINAL_OUTPUT_LIMIT,

+ 429 - 68
src/core/webview/__tests__/ClineProvider.test.ts

@@ -55,6 +55,34 @@ jest.mock("../../contextProxy", () => {
 // Mock dependencies
 // Mock dependencies
 jest.mock("vscode")
 jest.mock("vscode")
 jest.mock("delay")
 jest.mock("delay")
+
+// Mock BrowserSession
+jest.mock("../../../services/browser/BrowserSession", () => ({
+	BrowserSession: jest.fn().mockImplementation(() => ({
+		testConnection: jest.fn().mockImplementation(async (url) => {
+			if (url === "http://localhost:9222") {
+				return {
+					success: true,
+					message: "Successfully connected to Chrome",
+					endpoint: "ws://localhost:9222/devtools/browser/123",
+				}
+			} else {
+				return {
+					success: false,
+					message: "Failed to connect to Chrome",
+					endpoint: undefined,
+				}
+			}
+		}),
+	})),
+}))
+
+// Mock browserDiscovery
+jest.mock("../../../services/browser/browserDiscovery", () => ({
+	discoverChromeInstances: jest.fn().mockImplementation(async () => {
+		return "http://localhost:9222"
+	}),
+}))
 jest.mock(
 jest.mock(
 	"@modelcontextprotocol/sdk/types.js",
 	"@modelcontextprotocol/sdk/types.js",
 	() => ({
 	() => ({
@@ -94,31 +122,7 @@ jest.mock("delay", () => {
 	return delayFn
 	return delayFn
 })
 })
 
 
-// Mock MCP-related modules
-jest.mock(
-	"@modelcontextprotocol/sdk/types.js",
-	() => ({
-		CallToolResultSchema: {},
-		ListResourcesResultSchema: {},
-		ListResourceTemplatesResultSchema: {},
-		ListToolsResultSchema: {},
-		ReadResourceResultSchema: {},
-		ErrorCode: {
-			InvalidRequest: "InvalidRequest",
-			MethodNotFound: "MethodNotFound",
-			InternalError: "InternalError",
-		},
-		McpError: class McpError extends Error {
-			code: string
-			constructor(code: string, message: string) {
-				super(message)
-				this.code = code
-				this.name = "McpError"
-			}
-		},
-	}),
-	{ virtual: true },
-)
+// MCP-related modules are mocked once above (lines 87-109)
 
 
 jest.mock(
 jest.mock(
 	"@modelcontextprotocol/sdk/client/index.js",
 	"@modelcontextprotocol/sdk/client/index.js",
@@ -598,7 +602,7 @@ describe("ClineProvider", () => {
 		expect(mockPostMessage).toHaveBeenCalled()
 		expect(mockPostMessage).toHaveBeenCalled()
 	})
 	})
 
 
-	test("requestDelaySeconds defaults to 5 seconds", async () => {
+	test("requestDelaySeconds defaults to 10 seconds", async () => {
 		// Mock globalState.get to return undefined for requestDelaySeconds
 		// Mock globalState.get to return undefined for requestDelaySeconds
 		;(mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => {
 		;(mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => {
 			if (key === "requestDelaySeconds") {
 			if (key === "requestDelaySeconds") {
@@ -1160,6 +1164,17 @@ describe("ClineProvider", () => {
 		})
 		})
 
 
 		test("passes diffStrategy and diffEnabled to SYSTEM_PROMPT when previewing", async () => {
 		test("passes diffStrategy and diffEnabled to SYSTEM_PROMPT when previewing", async () => {
+			// Setup Cline instance with mocked api.getModel()
+			const { Cline } = require("../../Cline")
+			const mockCline = new Cline()
+			mockCline.api = {
+				getModel: jest.fn().mockReturnValue({
+					id: "claude-3-sonnet",
+					info: { supportsComputerUse: true },
+				}),
+			}
+			await provider.addClineToStack(mockCline)
+
 			// Mock getState to return experimentalDiffStrategy, diffEnabled and fuzzyMatchThreshold
 			// Mock getState to return experimentalDiffStrategy, diffEnabled and fuzzyMatchThreshold
 			jest.spyOn(provider, "getState").mockResolvedValue({
 			jest.spyOn(provider, "getState").mockResolvedValue({
 				apiConfiguration: {
 				apiConfiguration: {
@@ -1176,6 +1191,7 @@ describe("ClineProvider", () => {
 				diffEnabled: true,
 				diffEnabled: true,
 				fuzzyMatchThreshold: 0.8,
 				fuzzyMatchThreshold: 0.8,
 				experiments: experimentDefault,
 				experiments: experimentDefault,
+				browserToolEnabled: true,
 			} as any)
 			} as any)
 
 
 			// Mock SYSTEM_PROMPT to verify diffStrategy and diffEnabled are passed
 			// Mock SYSTEM_PROMPT to verify diffStrategy and diffEnabled are passed
@@ -1186,27 +1202,19 @@ describe("ClineProvider", () => {
 			const handler = getMessageHandler()
 			const handler = getMessageHandler()
 			await handler({ type: "getSystemPrompt", mode: "code" })
 			await handler({ type: "getSystemPrompt", mode: "code" })
 
 
-			// Verify SYSTEM_PROMPT was called with correct arguments
-			expect(systemPromptSpy).toHaveBeenCalledWith(
-				expect.anything(), // context
-				expect.any(String), // cwd
-				true, // supportsComputerUse
-				undefined, // mcpHub (disabled)
-				expect.objectContaining({
-					// diffStrategy
-					getToolDescription: expect.any(Function),
-				}),
-				"900x600", // browserViewportSize
-				"code", // mode
-				{}, // customModePrompts
-				{ customModes: [] }, // customModes
-				undefined, // effectiveInstructions
-				undefined, // preferredLanguage
-				true, // diffEnabled
-				experimentDefault,
-				true,
-				undefined, // rooIgnoreInstructions
-			)
+			// Verify SYSTEM_PROMPT was called
+			expect(systemPromptSpy).toHaveBeenCalled()
+
+			// Get the actual arguments passed to SYSTEM_PROMPT
+			const callArgs = systemPromptSpy.mock.calls[0]
+
+			// Verify key parameters
+			expect(callArgs[2]).toBe(true) // supportsComputerUse
+			expect(callArgs[3]).toBeUndefined() // mcpHub (disabled)
+			expect(callArgs[4]).toHaveProperty("getToolDescription") // diffStrategy
+			expect(callArgs[5]).toBe("900x600") // browserViewportSize
+			expect(callArgs[6]).toBe("code") // mode
+			expect(callArgs[11]).toBe(true) // diffEnabled
 
 
 			// Run the test again to verify it's consistent
 			// Run the test again to verify it's consistent
 			await handler({ type: "getSystemPrompt", mode: "code" })
 			await handler({ type: "getSystemPrompt", mode: "code" })
@@ -1214,6 +1222,17 @@ describe("ClineProvider", () => {
 		})
 		})
 
 
 		test("passes diffEnabled: false to SYSTEM_PROMPT when diff is disabled", async () => {
 		test("passes diffEnabled: false to SYSTEM_PROMPT when diff is disabled", async () => {
+			// Setup Cline instance with mocked api.getModel()
+			const { Cline } = require("../../Cline")
+			const mockCline = new Cline()
+			mockCline.api = {
+				getModel: jest.fn().mockReturnValue({
+					id: "claude-3-sonnet",
+					info: { supportsComputerUse: true },
+				}),
+			}
+			await provider.addClineToStack(mockCline)
+
 			// Mock getState to return diffEnabled: false
 			// Mock getState to return diffEnabled: false
 			jest.spyOn(provider, "getState").mockResolvedValue({
 			jest.spyOn(provider, "getState").mockResolvedValue({
 				apiConfiguration: {
 				apiConfiguration: {
@@ -1230,6 +1249,7 @@ describe("ClineProvider", () => {
 				fuzzyMatchThreshold: 0.8,
 				fuzzyMatchThreshold: 0.8,
 				experiments: experimentDefault,
 				experiments: experimentDefault,
 				enableMcpServerCreation: true,
 				enableMcpServerCreation: true,
+				browserToolEnabled: true,
 			} as any)
 			} as any)
 
 
 			// Mock SYSTEM_PROMPT to verify diffEnabled is passed as false
 			// Mock SYSTEM_PROMPT to verify diffEnabled is passed as false
@@ -1240,27 +1260,19 @@ describe("ClineProvider", () => {
 			const handler = getMessageHandler()
 			const handler = getMessageHandler()
 			await handler({ type: "getSystemPrompt", mode: "code" })
 			await handler({ type: "getSystemPrompt", mode: "code" })
 
 
-			// Verify SYSTEM_PROMPT was called with diffEnabled: false
-			expect(systemPromptSpy).toHaveBeenCalledWith(
-				expect.anything(), // context
-				expect.any(String), // cwd
-				true, // supportsComputerUse
-				undefined, // mcpHub (disabled)
-				expect.objectContaining({
-					// diffStrategy
-					getToolDescription: expect.any(Function),
-				}),
-				"900x600", // browserViewportSize
-				"code", // mode
-				{}, // customModePrompts
-				{ customModes: [] }, // customModes
-				undefined, // effectiveInstructions
-				undefined, // preferredLanguage
-				false, // diffEnabled
-				experimentDefault,
-				true,
-				undefined, // rooIgnoreInstructions
-			)
+			// Verify SYSTEM_PROMPT was called
+			expect(systemPromptSpy).toHaveBeenCalled()
+
+			// Get the actual arguments passed to SYSTEM_PROMPT
+			const callArgs = systemPromptSpy.mock.calls[0]
+
+			// Verify key parameters
+			expect(callArgs[2]).toBe(true) // supportsComputerUse
+			expect(callArgs[3]).toBeUndefined() // mcpHub (disabled)
+			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
 		})
 		})
 
 
 		test("uses correct mode-specific instructions when mode is specified", async () => {
 		test("uses correct mode-specific instructions when mode is specified", async () => {
@@ -1299,6 +1311,188 @@ describe("ClineProvider", () => {
 				expect.any(String),
 				expect.any(String),
 			)
 			)
 		})
 		})
+
+		// Tests for browser tool support
+		test("correctly extracts modelSupportsComputerUse from Cline instance", async () => {
+			// Setup Cline instance with mocked api.getModel()
+			const { Cline } = require("../../Cline")
+			const mockCline = new Cline()
+			mockCline.api = {
+				getModel: jest.fn().mockReturnValue({
+					id: "claude-3-sonnet",
+					info: { supportsComputerUse: true },
+				}),
+			}
+			await provider.addClineToStack(mockCline)
+
+			// Mock SYSTEM_PROMPT to verify supportsComputerUse is passed correctly
+			const systemPromptModule = require("../../prompts/system")
+			const systemPromptSpy = jest.spyOn(systemPromptModule, "SYSTEM_PROMPT")
+
+			// Mock getState to return browserToolEnabled: true
+			jest.spyOn(provider, "getState").mockResolvedValue({
+				apiConfiguration: {
+					apiProvider: "openrouter",
+				},
+				browserToolEnabled: true,
+				mode: "code",
+				experiments: experimentDefault,
+			} as any)
+
+			// Trigger getSystemPrompt
+			const handler = getMessageHandler()
+			await handler({ type: "getSystemPrompt", mode: "code" })
+
+			// Verify SYSTEM_PROMPT was called
+			expect(systemPromptSpy).toHaveBeenCalled()
+
+			// Get the actual arguments passed to SYSTEM_PROMPT
+			const callArgs = systemPromptSpy.mock.calls[0]
+
+			// Verify the supportsComputerUse parameter (3rd parameter, index 2)
+			expect(callArgs[2]).toBe(true)
+		})
+
+		test("correctly handles when model doesn't support computer use", async () => {
+			// Setup Cline instance with mocked api.getModel() that doesn't support computer use
+			const { Cline } = require("../../Cline")
+			const mockCline = new Cline()
+			mockCline.api = {
+				getModel: jest.fn().mockReturnValue({
+					id: "non-computer-use-model",
+					info: { supportsComputerUse: false },
+				}),
+			}
+			await provider.addClineToStack(mockCline)
+
+			// Mock SYSTEM_PROMPT to verify supportsComputerUse is passed correctly
+			const systemPromptModule = require("../../prompts/system")
+			const systemPromptSpy = jest.spyOn(systemPromptModule, "SYSTEM_PROMPT")
+
+			// Mock getState to return browserToolEnabled: true
+			jest.spyOn(provider, "getState").mockResolvedValue({
+				apiConfiguration: {
+					apiProvider: "openrouter",
+				},
+				browserToolEnabled: true,
+				mode: "code",
+				experiments: experimentDefault,
+			} as any)
+
+			// Trigger getSystemPrompt
+			const handler = getMessageHandler()
+			await handler({ type: "getSystemPrompt", mode: "code" })
+
+			// Verify SYSTEM_PROMPT was called
+			expect(systemPromptSpy).toHaveBeenCalled()
+
+			// Get the actual arguments passed to SYSTEM_PROMPT
+			const callArgs = systemPromptSpy.mock.calls[0]
+
+			// Verify the supportsComputerUse parameter (3rd parameter, index 2)
+			// Even though browserToolEnabled is true, the model doesn't support it
+			expect(callArgs[2]).toBe(false)
+		})
+
+		test("correctly handles when browserToolEnabled is false", async () => {
+			// Setup Cline instance with mocked api.getModel() that supports computer use
+			const { Cline } = require("../../Cline")
+			const mockCline = new Cline()
+			mockCline.api = {
+				getModel: jest.fn().mockReturnValue({
+					id: "claude-3-sonnet",
+					info: { supportsComputerUse: true },
+				}),
+			}
+			await provider.addClineToStack(mockCline)
+
+			// Mock SYSTEM_PROMPT to verify supportsComputerUse is passed correctly
+			const systemPromptModule = require("../../prompts/system")
+			const systemPromptSpy = jest.spyOn(systemPromptModule, "SYSTEM_PROMPT")
+
+			// Mock getState to return browserToolEnabled: false
+			jest.spyOn(provider, "getState").mockResolvedValue({
+				apiConfiguration: {
+					apiProvider: "openrouter",
+				},
+				browserToolEnabled: false,
+				mode: "code",
+				experiments: experimentDefault,
+			} as any)
+
+			// Trigger getSystemPrompt
+			const handler = getMessageHandler()
+			await handler({ type: "getSystemPrompt", mode: "code" })
+
+			// Verify SYSTEM_PROMPT was called
+			expect(systemPromptSpy).toHaveBeenCalled()
+
+			// Get the actual arguments passed to SYSTEM_PROMPT
+			const callArgs = systemPromptSpy.mock.calls[0]
+
+			// Verify the supportsComputerUse parameter (3rd parameter, index 2)
+			// Even though model supports it, browserToolEnabled is false
+			expect(callArgs[2]).toBe(false)
+		})
+
+		test("correctly calculates canUseBrowserTool as combination of model support and setting", async () => {
+			// Setup Cline instance with mocked api.getModel()
+			const { Cline } = require("../../Cline")
+			const mockCline = new Cline()
+			mockCline.api = {
+				getModel: jest.fn().mockReturnValue({
+					id: "claude-3-sonnet",
+					info: { supportsComputerUse: true },
+				}),
+			}
+			await provider.addClineToStack(mockCline)
+
+			// Mock SYSTEM_PROMPT
+			const systemPromptModule = require("../../prompts/system")
+			const systemPromptSpy = jest.spyOn(systemPromptModule, "SYSTEM_PROMPT")
+
+			// Test all combinations of model support and browserToolEnabled
+			const testCases = [
+				{ modelSupports: true, settingEnabled: true, expected: true },
+				{ modelSupports: true, settingEnabled: false, expected: false },
+				{ modelSupports: false, settingEnabled: true, expected: false },
+				{ modelSupports: false, settingEnabled: false, expected: false },
+			]
+
+			for (const testCase of testCases) {
+				// Reset mocks
+				systemPromptSpy.mockClear()
+
+				// Update mock Cline instance
+				mockCline.api.getModel = jest.fn().mockReturnValue({
+					id: "test-model",
+					info: { supportsComputerUse: testCase.modelSupports },
+				})
+
+				// Mock getState
+				jest.spyOn(provider, "getState").mockResolvedValue({
+					apiConfiguration: {
+						apiProvider: "openrouter",
+					},
+					browserToolEnabled: testCase.settingEnabled,
+					mode: "code",
+					experiments: experimentDefault,
+				} as any)
+
+				// Trigger getSystemPrompt
+				const handler = getMessageHandler()
+				await handler({ type: "getSystemPrompt", mode: "code" })
+
+				// Verify SYSTEM_PROMPT was called
+				expect(systemPromptSpy).toHaveBeenCalled()
+
+				// Get the actual arguments passed to SYSTEM_PROMPT
+				const callArgs = systemPromptSpy.mock.calls[0]
+
+				// Verify the supportsComputerUse parameter (3rd parameter, index 2)
+				expect(callArgs[2]).toBe(testCase.expected)
+			}
+		})
 	})
 	})
 
 
 	describe("handleModeSwitch", () => {
 	describe("handleModeSwitch", () => {
@@ -1591,6 +1785,173 @@ describe("ClineProvider", () => {
 			])
 			])
 		})
 		})
 	})
 	})
+
+	describe("browser connection features", () => {
+		beforeEach(async () => {
+			// Reset mocks
+			jest.clearAllMocks()
+			await provider.resolveWebviewView(mockWebviewView)
+		})
+
+		// Mock BrowserSession and discoverChromeInstances
+		jest.mock("../../../services/browser/BrowserSession", () => ({
+			BrowserSession: jest.fn().mockImplementation(() => ({
+				testConnection: jest.fn().mockImplementation(async (url) => {
+					if (url === "http://localhost:9222") {
+						return {
+							success: true,
+							message: "Successfully connected to Chrome",
+							endpoint: "ws://localhost:9222/devtools/browser/123",
+						}
+					} else {
+						return {
+							success: false,
+							message: "Failed to connect to Chrome",
+							endpoint: undefined,
+						}
+					}
+				}),
+			})),
+		}))
+
+		jest.mock("../../../services/browser/browserDiscovery", () => ({
+			discoverChromeInstances: jest.fn().mockImplementation(async () => {
+				return "http://localhost:9222"
+			}),
+		}))
+
+		test("handles testBrowserConnection with provided URL", async () => {
+			// Get the message handler
+			const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
+
+			// Test with valid URL
+			await messageHandler({
+				type: "testBrowserConnection",
+				text: "http://localhost:9222",
+			})
+
+			// Verify postMessage was called with success result
+			expect(mockPostMessage).toHaveBeenCalledWith(
+				expect.objectContaining({
+					type: "browserConnectionResult",
+					success: true,
+					text: expect.stringContaining("Successfully connected to Chrome"),
+				}),
+			)
+
+			// Reset mock
+			mockPostMessage.mockClear()
+
+			// Test with invalid URL
+			await messageHandler({
+				type: "testBrowserConnection",
+				text: "http://inlocalhost:9222",
+			})
+
+			// Verify postMessage was called with failure result
+			expect(mockPostMessage).toHaveBeenCalledWith(
+				expect.objectContaining({
+					type: "browserConnectionResult",
+					success: false,
+					text: expect.stringContaining("Failed to connect to Chrome"),
+				}),
+			)
+		})
+
+		test("handles testBrowserConnection with auto-discovery", async () => {
+			// Get the message handler
+			const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
+
+			// Test auto-discovery (no URL provided)
+			await messageHandler({
+				type: "testBrowserConnection",
+			})
+
+			// Verify discoverChromeInstances was called
+			const { discoverChromeInstances } = require("../../../services/browser/browserDiscovery")
+			expect(discoverChromeInstances).toHaveBeenCalled()
+
+			// Verify postMessage was called with success result
+			expect(mockPostMessage).toHaveBeenCalledWith(
+				expect.objectContaining({
+					type: "browserConnectionResult",
+					success: true,
+					text: expect.stringContaining("Auto-discovered and tested connection to Chrome"),
+				}),
+			)
+		})
+
+		test("handles discoverBrowser message", async () => {
+			// Get the message handler
+			const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
+
+			// Test browser discovery
+			await messageHandler({
+				type: "discoverBrowser",
+			})
+
+			// Verify discoverChromeInstances was called
+			const { discoverChromeInstances } = require("../../../services/browser/browserDiscovery")
+			expect(discoverChromeInstances).toHaveBeenCalled()
+
+			// Verify postMessage was called with success result
+			expect(mockPostMessage).toHaveBeenCalledWith(
+				expect.objectContaining({
+					type: "browserConnectionResult",
+					success: true,
+					text: expect.stringContaining("Successfully discovered and connected to Chrome"),
+				}),
+			)
+		})
+
+		test("handles errors during browser discovery", async () => {
+			// Mock discoverChromeInstances to throw an error
+			const { discoverChromeInstances } = require("../../../services/browser/browserDiscovery")
+			discoverChromeInstances.mockImplementationOnce(() => {
+				throw new Error("Discovery error")
+			})
+
+			// Get the message handler
+			const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
+
+			// Test browser discovery with error
+			await messageHandler({
+				type: "discoverBrowser",
+			})
+
+			// Verify postMessage was called with error result
+			expect(mockPostMessage).toHaveBeenCalledWith(
+				expect.objectContaining({
+					type: "browserConnectionResult",
+					success: false,
+					text: expect.stringContaining("Error discovering browser"),
+				}),
+			)
+		})
+
+		test("handles case when no browsers are discovered", async () => {
+			// Mock discoverChromeInstances to return null (no browsers found)
+			const { discoverChromeInstances } = require("../../../services/browser/browserDiscovery")
+			discoverChromeInstances.mockImplementationOnce(() => null)
+
+			// Get the message handler
+			const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
+
+			// Test browser discovery with no browsers found
+			await messageHandler({
+				type: "discoverBrowser",
+			})
+
+			// Verify postMessage was called with failure result
+			expect(mockPostMessage).toHaveBeenCalledWith(
+				expect.objectContaining({
+					type: "browserConnectionResult",
+					success: false,
+					text: expect.stringContaining("No Chrome instances found"),
+				}),
+			)
+		})
+	})
 })
 })
 
 
 describe("ContextProxy integration", () => {
 describe("ContextProxy integration", () => {

+ 34 - 38
src/exports/README.md

@@ -1,55 +1,51 @@
-# Cline API
+# Roo Code API
 
 
-The Cline extension exposes an API that can be used by other extensions. To use this API in your extension:
+The Roo Code extension exposes an API that can be used by other extensions. To use this API in your extension:
 
 
-1. Copy `src/extension-api/cline.d.ts` to your extension's source directory.
-2. Include `cline.d.ts` in your extension's compilation.
+1. Copy `src/extension-api/roo-code.d.ts` to your extension's source directory.
+2. Include `roo-code.d.ts` in your extension's compilation.
 3. Get access to the API with the following code:
 3. Get access to the API with the following code:
 
 
-    ```ts
-    const clineExtension = vscode.extensions.getExtension<ClineAPI>("rooveterinaryinc.roo-cline")
+```typescript
+const extension = vscode.extensions.getExtension<RooCodeAPI>("rooveterinaryinc.roo-cline")
 
 
-    if (!clineExtension?.isActive) {
-    	throw new Error("Cline extension is not activated")
-    }
+if (!extension?.isActive) {
+	throw new Error("Extension is not activated")
+}
 
 
-    const cline = clineExtension.exports
+const api = extension.exports
 
 
-    if (cline) {
-    	// Now you can use the API
+if (!api) {
+	throw new Error("API is not available")
+}
 
 
-    	// Set custom instructions
-    	await cline.setCustomInstructions("Talk like a pirate")
+// Set custom instructions.
+await api.setCustomInstructions("Talk like a pirate")
 
 
-    	// Get custom instructions
-    	const instructions = await cline.getCustomInstructions()
-    	console.log("Current custom instructions:", instructions)
+// Get custom instructions.
+const instructions = await api.getCustomInstructions()
+console.log("Current custom instructions:", instructions)
 
 
-    	// Start a new task with an initial message
-    	await cline.startNewTask("Hello, Cline! Let's make a new project...")
+// Start a new task with an initial message.
+await api.startNewTask("Hello, Roo Code API! Let's make a new project...")
 
 
-    	// Start a new task with an initial message and images
-    	await cline.startNewTask("Use this design language", ["data:image/webp;base64,..."])
+// Start a new task with an initial message and images.
+await api.startNewTask("Use this design language", ["data:image/webp;base64,..."])
 
 
-    	// Send a message to the current task
-    	await cline.sendMessage("Can you fix the @problems?")
+// Send a message to the current task.
+await api.sendMessage("Can you fix the @problems?")
 
 
-    	// Simulate pressing the primary button in the chat interface (e.g. 'Save' or 'Proceed While Running')
-    	await cline.pressPrimaryButton()
+// Simulate pressing the primary button in the chat interface (e.g. 'Save' or 'Proceed While Running').
+await api.pressPrimaryButton()
 
 
-    	// Simulate pressing the secondary button in the chat interface (e.g. 'Reject')
-    	await cline.pressSecondaryButton()
-    } else {
-    	console.error("Cline API is not available")
-    }
-    ```
+// Simulate pressing the secondary button in the chat interface (e.g. 'Reject').
+await api.pressSecondaryButton()
+```
 
 
-    **Note:** To ensure that the `rooveterinaryinc.roo-cline` extension is activated before your extension, add it to the `extensionDependencies` in your `package.json`:
+**NOTE:** To ensure that the `rooveterinaryinc.roo-cline` extension is activated before your extension, add it to the `extensionDependencies` in your `package.json`:
 
 
-    ```json
-    "extensionDependencies": [
-        "rooveterinaryinc.roo-cline"
-    ]
-    ```
+```json
+"extensionDependencies": ["rooveterinaryinc.roo-cline"]
+```
 
 
-For detailed information on the available methods and their usage, refer to the `cline.d.ts` file.
+For detailed information on the available methods and their usage, refer to the `roo-code.d.ts` file.

+ 56 - 13
src/exports/cline.d.ts → src/exports/roo-code.d.ts

@@ -1,4 +1,4 @@
-export interface ClineAPI {
+export interface RooCodeAPI {
 	/**
 	/**
 	 * Sets the custom instructions in the global storage.
 	 * Sets the custom instructions in the global storage.
 	 * @param value The custom instructions to be saved.
 	 * @param value The custom instructions to be saved.
@@ -38,7 +38,61 @@ export interface ClineAPI {
 	/**
 	/**
 	 * The sidebar provider instance.
 	 * The sidebar provider instance.
 	 */
 	 */
-	sidebarProvider: ClineSidebarProvider
+	sidebarProvider: ClineProvider
+}
+
+export type ClineAsk =
+	| "followup"
+	| "command"
+	| "command_output"
+	| "completion_result"
+	| "tool"
+	| "api_req_failed"
+	| "resume_task"
+	| "resume_completed_task"
+	| "mistake_limit_reached"
+	| "browser_action_launch"
+	| "use_mcp_server"
+	| "finishTask"
+
+export type ClineSay =
+	| "task"
+	| "error"
+	| "api_req_started"
+	| "api_req_finished"
+	| "api_req_retried"
+	| "api_req_retry_delayed"
+	| "api_req_deleted"
+	| "text"
+	| "reasoning"
+	| "completion_result"
+	| "user_feedback"
+	| "user_feedback_diff"
+	| "command_output"
+	| "tool"
+	| "shell_integration_warning"
+	| "browser_action"
+	| "browser_action_result"
+	| "command"
+	| "mcp_server_request_started"
+	| "mcp_server_response"
+	| "new_task_started"
+	| "new_task"
+	| "checkpoint_saved"
+	| "rooignore_error"
+
+export interface ClineMessage {
+	ts: number
+	type: "ask" | "say"
+	ask?: ClineAsk
+	say?: ClineSay
+	text?: string
+	images?: string[]
+	partial?: boolean
+	reasoning?: string
+	conversationHistoryIndex?: number
+	checkpoint?: Record<string, unknown>
+	progressStatus?: ToolProgressStatus
 }
 }
 
 
 export interface ClineProvider {
 export interface ClineProvider {
@@ -82,11 +136,6 @@ export interface ClineProvider {
 	 */
 	 */
 	cancelTask(): Promise<void>
 	cancelTask(): Promise<void>
 
 
-	/**
-	 * Clears the current task
-	 */
-	clearTask(): Promise<void>
-
 	/**
 	/**
 	 * Gets the current state
 	 * Gets the current state
 	 */
 	 */
@@ -112,12 +161,6 @@ export interface ClineProvider {
 	 */
 	 */
 	storeSecret(key: SecretKey, value?: string): Promise<void>
 	storeSecret(key: SecretKey, value?: string): Promise<void>
 
 
-	/**
-	 * Retrieves a secret value from secure storage
-	 * @param key The key of the secret to retrieve
-	 */
-	getSecret(key: SecretKey): Promise<string | undefined>
-
 	/**
 	/**
 	 * Resets the state
 	 * Resets the state
 	 */
 	 */

+ 5 - 50
src/extension.ts

@@ -11,15 +11,16 @@ try {
 	console.warn("Failed to load environment variables:", e)
 	console.warn("Failed to load environment variables:", e)
 }
 }
 
 
-import { ClineProvider } from "./core/webview/ClineProvider"
-import { createClineAPI } from "./exports"
 import "./utils/path" // Necessary to have access to String.prototype.toPosix.
 import "./utils/path" // Necessary to have access to String.prototype.toPosix.
+
+import { ClineProvider } from "./core/webview/ClineProvider"
 import { CodeActionProvider } from "./core/CodeActionProvider"
 import { CodeActionProvider } from "./core/CodeActionProvider"
 import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
 import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
-import { handleUri, registerCommands, registerCodeActions } from "./activate"
 import { McpServerManager } from "./services/mcp/McpServerManager"
 import { McpServerManager } from "./services/mcp/McpServerManager"
 import { telemetryService } from "./services/telemetry/TelemetryService"
 import { telemetryService } from "./services/telemetry/TelemetryService"
 
 
+import { handleUri, registerCommands, registerCodeActions, createRooCodeAPI } from "./activate"
+
 /**
 /**
  * Built using https://github.com/microsoft/vscode-webview-ui-toolkit
  * Built using https://github.com/microsoft/vscode-webview-ui-toolkit
  *
  *
@@ -31,18 +32,6 @@ import { telemetryService } from "./services/telemetry/TelemetryService"
 let outputChannel: vscode.OutputChannel
 let outputChannel: vscode.OutputChannel
 let extensionContext: vscode.ExtensionContext
 let extensionContext: vscode.ExtensionContext
 
 
-// Callback mapping of human relay response
-const humanRelayCallbacks = new Map<string, (response: string | undefined) => void>()
-
-/**
- * Register a callback function for human relay response
- * @param requestId
- * @param callback
- */
-export function registerHumanRelayCallback(requestId: string, callback: (response: string | undefined) => void): void {
-	humanRelayCallbacks.set(requestId, callback)
-}
-
 // This method is called when your extension is activated.
 // This method is called when your extension is activated.
 // Your extension is activated the very first time the command is executed.
 // Your extension is activated the very first time the command is executed.
 export function activate(context: vscode.ExtensionContext) {
 export function activate(context: vscode.ExtensionContext) {
@@ -72,40 +61,6 @@ export function activate(context: vscode.ExtensionContext) {
 
 
 	registerCommands({ context, outputChannel, provider: sidebarProvider })
 	registerCommands({ context, outputChannel, provider: sidebarProvider })
 
 
-	// Register human relay callback registration command
-	context.subscriptions.push(
-		vscode.commands.registerCommand(
-			"roo-cline.registerHumanRelayCallback",
-			(requestId: string, callback: (response: string | undefined) => void) => {
-				registerHumanRelayCallback(requestId, callback)
-			},
-		),
-	)
-
-	// Register human relay response processing command
-	context.subscriptions.push(
-		vscode.commands.registerCommand(
-			"roo-cline.handleHumanRelayResponse",
-			(response: { requestId: string; text?: string; cancelled?: boolean }) => {
-				const callback = humanRelayCallbacks.get(response.requestId)
-				if (callback) {
-					if (response.cancelled) {
-						callback(undefined)
-					} else {
-						callback(response.text)
-					}
-					humanRelayCallbacks.delete(response.requestId)
-				}
-			},
-		),
-	)
-
-	context.subscriptions.push(
-		vscode.commands.registerCommand("roo-cline.unregisterHumanRelayCallback", (requestId: string) => {
-			humanRelayCallbacks.delete(requestId)
-		}),
-	)
-
 	/**
 	/**
 	 * We use the text document content provider API to show the left side for diff
 	 * We use the text document content provider API to show the left side for diff
 	 * view by creating a virtual document for the original content. This makes it
 	 * view by creating a virtual document for the original content. This makes it
@@ -143,7 +98,7 @@ export function activate(context: vscode.ExtensionContext) {
 
 
 	registerCodeActions(context)
 	registerCodeActions(context)
 
 
-	return createClineAPI(outputChannel, sidebarProvider)
+	return createRooCodeAPI(outputChannel, sidebarProvider)
 }
 }
 
 
 // This method is called when your extension is deactivated.
 // This method is called when your extension is deactivated.

+ 139 - 7
src/services/browser/BrowserSession.ts

@@ -1,13 +1,15 @@
 import * as vscode from "vscode"
 import * as vscode from "vscode"
 import * as fs from "fs/promises"
 import * as fs from "fs/promises"
 import * as path from "path"
 import * as path from "path"
-import { Browser, Page, ScreenshotOptions, TimeoutError, launch } from "puppeteer-core"
+import { Browser, Page, ScreenshotOptions, TimeoutError, launch, connect } from "puppeteer-core"
 // @ts-ignore
 // @ts-ignore
 import PCR from "puppeteer-chromium-resolver"
 import PCR from "puppeteer-chromium-resolver"
 import pWaitFor from "p-wait-for"
 import pWaitFor from "p-wait-for"
 import delay from "delay"
 import delay from "delay"
+import axios from "axios"
 import { fileExistsAtPath } from "../../utils/fs"
 import { fileExistsAtPath } from "../../utils/fs"
 import { BrowserActionResult } from "../../shared/ExtensionMessage"
 import { BrowserActionResult } from "../../shared/ExtensionMessage"
+import { discoverChromeInstances, testBrowserConnection } from "./browserDiscovery"
 
 
 interface PCRStats {
 interface PCRStats {
 	puppeteer: { launch: typeof launch }
 	puppeteer: { launch: typeof launch }
@@ -19,11 +21,20 @@ export class BrowserSession {
 	private browser?: Browser
 	private browser?: Browser
 	private page?: Page
 	private page?: Page
 	private currentMousePosition?: string
 	private currentMousePosition?: string
+	private cachedWebSocketEndpoint?: string
+	private lastConnectionAttempt: number = 0
 
 
 	constructor(context: vscode.ExtensionContext) {
 	constructor(context: vscode.ExtensionContext) {
 		this.context = context
 		this.context = context
 	}
 	}
 
 
+	/**
+	 * Test connection to a remote browser
+	 */
+	async testConnection(host: string): Promise<{ success: boolean; message: string; endpoint?: string }> {
+		return testBrowserConnection(host)
+	}
+
 	private async ensureChromiumExists(): Promise<PCRStats> {
 	private async ensureChromiumExists(): Promise<PCRStats> {
 		const globalStoragePath = this.context?.globalStorageUri?.fsPath
 		const globalStoragePath = this.context?.globalStorageUri?.fsPath
 		if (!globalStoragePath) {
 		if (!globalStoragePath) {
@@ -52,17 +63,131 @@ export class BrowserSession {
 			await this.closeBrowser() // this may happen when the model launches a browser again after having used it already before
 			await this.closeBrowser() // this may happen when the model launches a browser again after having used it already before
 		}
 		}
 
 
+		// Function to get viewport size
+		const getViewport = () => {
+			const size = (this.context.globalState.get("browserViewportSize") as string | undefined) || "900x600"
+			const [width, height] = size.split("x").map(Number)
+			return { width, height }
+		}
+
+		// Check if remote browser connection is enabled
+		const remoteBrowserEnabled = this.context.globalState.get("remoteBrowserEnabled") as boolean | undefined
+
+		// If remote browser connection is not enabled, use local browser
+		if (!remoteBrowserEnabled) {
+			console.log("Remote browser connection is disabled, using local browser")
+			const stats = await this.ensureChromiumExists()
+			this.browser = await stats.puppeteer.launch({
+				args: [
+					"--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
+				],
+				executablePath: stats.executablePath,
+				defaultViewport: getViewport(),
+				// headless: false,
+			})
+			this.page = await this.browser?.newPage()
+			return
+		}
+		// Remote browser connection is enabled
+		let remoteBrowserHost = this.context.globalState.get("remoteBrowserHost") as string | undefined
+		let browserWSEndpoint: string | undefined = this.cachedWebSocketEndpoint
+		let reconnectionAttempted = false
+
+		// Try to connect with cached endpoint first if it exists and is recent (less than 1 hour old)
+		if (browserWSEndpoint && Date.now() - this.lastConnectionAttempt < 3600000) {
+			try {
+				console.log(`Attempting to connect using cached WebSocket endpoint: ${browserWSEndpoint}`)
+				this.browser = await connect({
+					browserWSEndpoint,
+					defaultViewport: getViewport(),
+				})
+				this.page = await this.browser?.newPage()
+				return
+			} catch (error) {
+				console.log(`Failed to connect using cached endpoint: ${error}`)
+				// Clear the cached endpoint since it's no longer valid
+				this.cachedWebSocketEndpoint = undefined
+				// User wants to give up after one reconnection attempt
+				if (remoteBrowserHost) {
+					reconnectionAttempted = true
+				}
+			}
+		}
+
+		// If user provided a remote browser host, try to connect to it
+		if (remoteBrowserHost && !reconnectionAttempted) {
+			console.log(`Attempting to connect to remote browser at ${remoteBrowserHost}`)
+			try {
+				// Fetch the WebSocket endpoint from the Chrome DevTools Protocol
+				const versionUrl = `${remoteBrowserHost.replace(/\/$/, "")}/json/version`
+				console.log(`Fetching WebSocket endpoint from ${versionUrl}`)
+
+				const response = await axios.get(versionUrl)
+				browserWSEndpoint = response.data.webSocketDebuggerUrl
+
+				if (!browserWSEndpoint) {
+					throw new Error("Could not find webSocketDebuggerUrl in the response")
+				}
+
+				console.log(`Found WebSocket endpoint: ${browserWSEndpoint}`)
+
+				// Cache the successful endpoint
+				this.cachedWebSocketEndpoint = browserWSEndpoint
+				this.lastConnectionAttempt = Date.now()
+
+				this.browser = await connect({
+					browserWSEndpoint,
+					defaultViewport: getViewport(),
+				})
+				this.page = await this.browser?.newPage()
+				return
+			} catch (error) {
+				console.error(`Failed to connect to remote browser: ${error}`)
+				// Fall back to auto-discovery if remote connection fails
+			}
+		}
+
+		// Always try auto-discovery if no custom URL is specified or if connection failed
+		try {
+			console.log("Attempting auto-discovery...")
+			const discoveredHost = await discoverChromeInstances()
+
+			if (discoveredHost) {
+				console.log(`Auto-discovered Chrome at ${discoveredHost}`)
+
+				// Don't save the discovered host to global state to avoid overriding user preference
+				// We'll just use it for this session
+
+				// Try to connect to the discovered host
+				const testResult = await testBrowserConnection(discoveredHost)
+
+				if (testResult.success && testResult.endpoint) {
+					// Cache the successful endpoint
+					this.cachedWebSocketEndpoint = testResult.endpoint
+					this.lastConnectionAttempt = Date.now()
+
+					this.browser = await connect({
+						browserWSEndpoint: testResult.endpoint,
+						defaultViewport: getViewport(),
+					})
+					this.page = await this.browser?.newPage()
+					return
+				}
+			}
+		} catch (error) {
+			console.error(`Auto-discovery failed: ${error}`)
+			// Fall back to local browser if auto-discovery fails
+		}
+
+		// If all remote connection attempts fail, fall back to local browser
+		console.log("Falling back to local browser")
 		const stats = await this.ensureChromiumExists()
 		const stats = await this.ensureChromiumExists()
 		this.browser = await stats.puppeteer.launch({
 		this.browser = await stats.puppeteer.launch({
 			args: [
 			args: [
 				"--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
 				"--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
 			],
 			],
 			executablePath: stats.executablePath,
 			executablePath: stats.executablePath,
-			defaultViewport: (() => {
-				const size = (this.context.globalState.get("browserViewportSize") as string | undefined) || "900x600"
-				const [width, height] = size.split("x").map(Number)
-				return { width, height }
-			})(),
+			defaultViewport: getViewport(),
 			// headless: false,
 			// headless: false,
 		})
 		})
 		// (latest version of puppeteer does not add headless to user agent)
 		// (latest version of puppeteer does not add headless to user agent)
@@ -72,7 +197,14 @@ export class BrowserSession {
 	async closeBrowser(): Promise<BrowserActionResult> {
 	async closeBrowser(): Promise<BrowserActionResult> {
 		if (this.browser || this.page) {
 		if (this.browser || this.page) {
 			console.log("closing browser...")
 			console.log("closing browser...")
-			await this.browser?.close().catch(() => {})
+
+			const remoteBrowserEnabled = this.context.globalState.get("remoteBrowserEnabled") as string | undefined
+			if (remoteBrowserEnabled && this.browser) {
+				await this.browser.disconnect().catch(() => {})
+			} else {
+				await this.browser?.close().catch(() => {})
+			}
+
 			this.browser = undefined
 			this.browser = undefined
 			this.page = undefined
 			this.page = undefined
 			this.currentMousePosition = undefined
 			this.currentMousePosition = undefined

+ 246 - 0
src/services/browser/browserDiscovery.ts

@@ -0,0 +1,246 @@
+import * as vscode from "vscode"
+import * as os from "os"
+import * as net from "net"
+import axios from "axios"
+
+/**
+ * Check if a port is open on a given host
+ */
+export async function isPortOpen(host: string, port: number, timeout = 1000): Promise<boolean> {
+	return new Promise((resolve) => {
+		const socket = new net.Socket()
+		let status = false
+
+		// Set timeout
+		socket.setTimeout(timeout)
+
+		// Handle successful connection
+		socket.on("connect", () => {
+			status = true
+			socket.destroy()
+		})
+
+		// Handle any errors
+		socket.on("error", () => {
+			socket.destroy()
+		})
+
+		// Handle timeout
+		socket.on("timeout", () => {
+			socket.destroy()
+		})
+
+		// Handle close
+		socket.on("close", () => {
+			resolve(status)
+		})
+
+		// Attempt to connect
+		socket.connect(port, host)
+	})
+}
+
+/**
+ * Try to connect to Chrome at a specific IP address
+ */
+export async function tryConnect(ipAddress: string): Promise<{ endpoint: string; ip: string } | null> {
+	try {
+		console.log(`Trying to connect to Chrome at: http://${ipAddress}:9222/json/version`)
+		const response = await axios.get(`http://${ipAddress}:9222/json/version`, { timeout: 1000 })
+		const data = response.data
+		return { endpoint: data.webSocketDebuggerUrl, ip: ipAddress }
+	} catch (error) {
+		return null
+	}
+}
+
+/**
+ * Execute a shell command and return stdout and stderr
+ */
+export async function executeShellCommand(command: string): Promise<{ stdout: string; stderr: string }> {
+	return new Promise<{ stdout: string; stderr: string }>((resolve) => {
+		const cp = require("child_process")
+		cp.exec(command, (err: any, stdout: string, stderr: string) => {
+			resolve({ stdout, stderr })
+		})
+	})
+}
+
+/**
+ * Get Docker gateway IP without UI feedback
+ */
+export async function getDockerGatewayIP(): Promise<string | null> {
+	try {
+		if (process.platform === "linux") {
+			try {
+				const { stdout } = await executeShellCommand("ip route | grep default | awk '{print $3}'")
+				return stdout.trim()
+			} catch (error) {
+				console.log("Could not determine Docker gateway IP:", error)
+			}
+		}
+		return null
+	} catch (error) {
+		console.log("Could not determine Docker gateway IP:", error)
+		return null
+	}
+}
+
+/**
+ * Get Docker host IP
+ */
+export async function getDockerHostIP(): Promise<string | null> {
+	try {
+		// Try to resolve host.docker.internal (works on Docker Desktop)
+		return new Promise((resolve) => {
+			const dns = require("dns")
+			dns.lookup("host.docker.internal", (err: any, address: string) => {
+				if (err) {
+					resolve(null)
+				} else {
+					resolve(address)
+				}
+			})
+		})
+	} catch (error) {
+		console.log("Could not determine Docker host IP:", error)
+		return null
+	}
+}
+
+/**
+ * Scan a network range for Chrome debugging port
+ */
+export async function scanNetworkForChrome(baseIP: string): Promise<string | null> {
+	if (!baseIP || !baseIP.match(/^\d+\.\d+\.\d+\./)) {
+		return null
+	}
+
+	// Extract the network prefix (e.g., "192.168.65.")
+	const networkPrefix = baseIP.split(".").slice(0, 3).join(".") + "."
+
+	// Common Docker host IPs to try first
+	const priorityIPs = [
+		networkPrefix + "1", // Common gateway
+		networkPrefix + "2", // Common host
+		networkPrefix + "254", // Common host in some Docker setups
+	]
+
+	console.log(`Scanning priority IPs in network ${networkPrefix}*`)
+
+	// Check priority IPs first
+	for (const ip of priorityIPs) {
+		const isOpen = await isPortOpen(ip, 9222)
+		if (isOpen) {
+			console.log(`Found Chrome debugging port open on ${ip}`)
+			return ip
+		}
+	}
+
+	return null
+}
+
+/**
+ * Discover Chrome instances on the network
+ */
+export async function discoverChromeInstances(): Promise<string | null> {
+	// Get all network interfaces
+	const networkInterfaces = os.networkInterfaces()
+	const ipAddresses = []
+
+	// Always try localhost first
+	ipAddresses.push("localhost")
+	ipAddresses.push("127.0.0.1")
+
+	// Try to get Docker gateway IP (headless mode)
+	const gatewayIP = await getDockerGatewayIP()
+	if (gatewayIP) {
+		console.log("Found Docker gateway IP:", gatewayIP)
+		ipAddresses.push(gatewayIP)
+	}
+
+	// Try to get Docker host IP
+	const hostIP = await getDockerHostIP()
+	if (hostIP) {
+		console.log("Found Docker host IP:", hostIP)
+		ipAddresses.push(hostIP)
+	}
+
+	// Add all local IP addresses from network interfaces
+	const localIPs: string[] = []
+	Object.values(networkInterfaces).forEach((interfaces) => {
+		if (!interfaces) return
+		interfaces.forEach((iface) => {
+			// Only consider IPv4 addresses
+			if (iface.family === "IPv4" || iface.family === (4 as any)) {
+				localIPs.push(iface.address)
+			}
+		})
+	})
+
+	// Add local IPs to the list
+	ipAddresses.push(...localIPs)
+
+	// Scan network for Chrome debugging port
+	for (const ip of localIPs) {
+		const chromeIP = await scanNetworkForChrome(ip)
+		if (chromeIP && !ipAddresses.includes(chromeIP)) {
+			console.log("Found potential Chrome host via network scan:", chromeIP)
+			ipAddresses.push(chromeIP)
+		}
+	}
+
+	// Remove duplicates
+	const uniqueIPs = [...new Set(ipAddresses)]
+	console.log("IP Addresses to try:", uniqueIPs)
+
+	// Try connecting to each IP address
+	for (const ip of uniqueIPs) {
+		const connection = await tryConnect(ip)
+		if (connection) {
+			console.log(`Successfully connected to Chrome at: ${connection.ip}`)
+			// Store the successful IP for future use
+			console.log(`✅ Found Chrome at ${connection.ip} - You can hardcode this IP if needed`)
+
+			// Return the host URL and endpoint
+			return `http://${connection.ip}:9222`
+		}
+	}
+
+	return null
+}
+
+/**
+ * Test connection to a remote browser
+ */
+export async function testBrowserConnection(
+	host: string,
+): Promise<{ success: boolean; message: string; endpoint?: string }> {
+	try {
+		// Fetch the WebSocket endpoint from the Chrome DevTools Protocol
+		const versionUrl = `${host.replace(/\/$/, "")}/json/version`
+		console.log(`Testing connection to ${versionUrl}`)
+
+		const response = await axios.get(versionUrl, { timeout: 3000 })
+		const browserWSEndpoint = response.data.webSocketDebuggerUrl
+
+		if (!browserWSEndpoint) {
+			return {
+				success: false,
+				message: "Could not find webSocketDebuggerUrl in the response",
+			}
+		}
+
+		return {
+			success: true,
+			message: "Successfully connected to Chrome browser",
+			endpoint: browserWSEndpoint,
+		}
+	} catch (error) {
+		console.error(`Failed to connect to remote browser: ${error}`)
+		return {
+			success: false,
+			message: `Failed to connect: ${error instanceof Error ? error.message : String(error)}`,
+		}
+	}
+}

+ 18 - 57
src/shared/ExtensionMessage.ts

@@ -1,5 +1,3 @@
-// type that represents json data that is sent from extension to webview, called ExtensionMessage and has 'type' enum which can be 'plusButtonClicked' or 'settingsButtonClicked' or 'hello'
-
 import { ApiConfiguration, ApiProvider, ModelInfo } from "./api"
 import { ApiConfiguration, ApiProvider, ModelInfo } from "./api"
 import { HistoryItem } from "./HistoryItem"
 import { HistoryItem } from "./HistoryItem"
 import { McpServer } from "./mcp"
 import { McpServer } from "./mcp"
@@ -9,6 +7,7 @@ import { CustomSupportPrompts } from "./support-prompt"
 import { ExperimentId } from "./experiments"
 import { ExperimentId } from "./experiments"
 import { CheckpointStorage } from "./checkpoints"
 import { CheckpointStorage } from "./checkpoints"
 import { TelemetrySetting } from "./TelemetrySetting"
 import { TelemetrySetting } from "./TelemetrySetting"
+import { ClineMessage, ClineAsk, ClineSay } from "../exports/roo-code"
 
 
 export interface LanguageModelChatSelector {
 export interface LanguageModelChatSelector {
 	vendor?: string
 	vendor?: string
@@ -17,7 +16,9 @@ export interface LanguageModelChatSelector {
 	id?: string
 	id?: string
 }
 }
 
 
-// webview will hold state
+// Represents JSON data that is sent from extension to webview, called
+// ExtensionMessage and has 'type' enum which can be 'plusButtonClicked' or
+// 'settingsButtonClicked' or 'hello'. Webview will hold state.
 export interface ExtensionMessage {
 export interface ExtensionMessage {
 	type:
 	type:
 		| "action"
 		| "action"
@@ -51,6 +52,8 @@ export interface ExtensionMessage {
 		| "humanRelayResponse"
 		| "humanRelayResponse"
 		| "humanRelayCancel"
 		| "humanRelayCancel"
 		| "browserToolEnabled"
 		| "browserToolEnabled"
+		| "browserConnectionResult"
+		| "remoteBrowserEnabled"
 	text?: string
 	text?: string
 	action?:
 	action?:
 		| "chatButtonClicked"
 		| "chatButtonClicked"
@@ -83,6 +86,8 @@ export interface ExtensionMessage {
 	mode?: Mode
 	mode?: Mode
 	customMode?: ModeConfig
 	customMode?: ModeConfig
 	slug?: string
 	slug?: string
+	success?: boolean
+	values?: Record<string, any>
 }
 }
 
 
 export interface ApiConfigMeta {
 export interface ApiConfigMeta {
@@ -123,6 +128,8 @@ export interface ExtensionState {
 	checkpointStorage: CheckpointStorage
 	checkpointStorage: CheckpointStorage
 	browserViewportSize?: string
 	browserViewportSize?: string
 	screenshotQuality?: number
 	screenshotQuality?: number
+	remoteBrowserHost?: string
+	remoteBrowserEnabled?: boolean
 	fuzzyMatchThreshold?: number
 	fuzzyMatchThreshold?: number
 	preferredLanguage: string
 	preferredLanguage: string
 	writeDelayMs: number
 	writeDelayMs: number
@@ -145,58 +152,7 @@ export interface ExtensionState {
 	showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings
 	showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings
 }
 }
 
 
-export interface ClineMessage {
-	ts: number
-	type: "ask" | "say"
-	ask?: ClineAsk
-	say?: ClineSay
-	text?: string
-	images?: string[]
-	partial?: boolean
-	reasoning?: string
-	conversationHistoryIndex?: number
-	checkpoint?: Record<string, unknown>
-}
-
-export type ClineAsk =
-	| "followup"
-	| "command"
-	| "command_output"
-	| "completion_result"
-	| "tool"
-	| "api_req_failed"
-	| "resume_task"
-	| "resume_completed_task"
-	| "mistake_limit_reached"
-	| "browser_action_launch"
-	| "use_mcp_server"
-	| "finishTask"
-
-export type ClineSay =
-	| "task"
-	| "error"
-	| "api_req_started"
-	| "api_req_finished"
-	| "api_req_retried"
-	| "api_req_retry_delayed"
-	| "api_req_deleted"
-	| "text"
-	| "reasoning"
-	| "completion_result"
-	| "user_feedback"
-	| "user_feedback_diff"
-	| "command_output"
-	| "tool"
-	| "shell_integration_warning"
-	| "browser_action"
-	| "browser_action_result"
-	| "command"
-	| "mcp_server_request_started"
-	| "mcp_server_response"
-	| "new_task_started"
-	| "new_task"
-	| "checkpoint_saved"
-	| "rooignore_error"
+export type { ClineMessage, ClineAsk, ClineSay }
 
 
 export interface ClineSayTool {
 export interface ClineSayTool {
 	tool:
 	tool:
@@ -220,8 +176,9 @@ export interface ClineSayTool {
 	reason?: string
 	reason?: string
 }
 }
 
 
-// must keep in sync with system prompt
+// Must keep in sync with system prompt.
 export const browserActions = ["launch", "click", "type", "scroll_down", "scroll_up", "close"] as const
 export const browserActions = ["launch", "click", "type", "scroll_down", "scroll_up", "close"] as const
+
 export type BrowserAction = (typeof browserActions)[number]
 export type BrowserAction = (typeof browserActions)[number]
 
 
 export interface ClineSayBrowserAction {
 export interface ClineSayBrowserAction {
@@ -256,7 +213,6 @@ export interface ClineApiReqInfo {
 	streamingFailedMessage?: string
 	streamingFailedMessage?: string
 }
 }
 
 
-// Human relay related message types
 export interface ShowHumanRelayDialogMessage {
 export interface ShowHumanRelayDialogMessage {
 	type: "showHumanRelayDialog"
 	type: "showHumanRelayDialog"
 	requestId: string
 	requestId: string
@@ -275,3 +231,8 @@ export interface HumanRelayCancelMessage {
 }
 }
 
 
 export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled"
 export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled"
+
+export type ToolProgressStatus = {
+	icon?: string
+	text?: string
+}

+ 5 - 0
src/shared/WebviewMessage.ts

@@ -57,6 +57,7 @@ export interface WebviewMessage {
 		| "checkpointStorage"
 		| "checkpointStorage"
 		| "browserViewportSize"
 		| "browserViewportSize"
 		| "screenshotQuality"
 		| "screenshotQuality"
+		| "remoteBrowserHost"
 		| "openMcpSettings"
 		| "openMcpSettings"
 		| "restartMcpServer"
 		| "restartMcpServer"
 		| "toggleToolAlwaysAllow"
 		| "toggleToolAlwaysAllow"
@@ -102,6 +103,10 @@ export interface WebviewMessage {
 		| "browserToolEnabled"
 		| "browserToolEnabled"
 		| "telemetrySetting"
 		| "telemetrySetting"
 		| "showRooIgnoredFiles"
 		| "showRooIgnoredFiles"
+		| "testBrowserConnection"
+		| "discoverBrowser"
+		| "browserConnectionResult"
+		| "remoteBrowserEnabled"
 	text?: string
 	text?: string
 	disabled?: boolean
 	disabled?: boolean
 	askResponse?: ClineAskResponse
 	askResponse?: ClineAskResponse

+ 10 - 0
src/shared/api.ts

@@ -39,6 +39,7 @@ export interface ApiHandlerOptions {
 	awspromptCacheId?: string
 	awspromptCacheId?: string
 	awsProfile?: string
 	awsProfile?: string
 	awsUseProfile?: boolean
 	awsUseProfile?: boolean
+	awsCustomArn?: string
 	vertexKeyFile?: string
 	vertexKeyFile?: string
 	vertexJsonCredentials?: string
 	vertexJsonCredentials?: string
 	vertexProjectId?: string
 	vertexProjectId?: string
@@ -99,6 +100,7 @@ export const API_CONFIG_KEYS: GlobalStateKey[] = [
 	// "awspromptCacheId", // NOT exist on GlobalStateKey
 	// "awspromptCacheId", // NOT exist on GlobalStateKey
 	"awsProfile",
 	"awsProfile",
 	"awsUseProfile",
 	"awsUseProfile",
+	"awsCustomArn",
 	"vertexKeyFile",
 	"vertexKeyFile",
 	"vertexJsonCredentials",
 	"vertexJsonCredentials",
 	"vertexProjectId",
 	"vertexProjectId",
@@ -496,6 +498,14 @@ export const vertexModels = {
 		inputPrice: 0.15,
 		inputPrice: 0.15,
 		outputPrice: 0.6,
 		outputPrice: 0.6,
 	},
 	},
+	"gemini-2.0-pro-exp-02-05": {
+		maxTokens: 8192,
+		contextWindow: 2_097_152,
+		supportsImages: true,
+		supportsPromptCache: false,
+		inputPrice: 0,
+		outputPrice: 0,
+	},
 	"gemini-2.0-flash-lite-001": {
 	"gemini-2.0-flash-lite-001": {
 		maxTokens: 8192,
 		maxTokens: 8192,
 		contextWindow: 1_048_576,
 		contextWindow: 1_048_576,

+ 3 - 0
src/shared/globalState.ts

@@ -28,6 +28,7 @@ export const GLOBAL_STATE_KEYS = [
 	"awsUseCrossRegionInference",
 	"awsUseCrossRegionInference",
 	"awsProfile",
 	"awsProfile",
 	"awsUseProfile",
 	"awsUseProfile",
+	"awsCustomArn",
 	"vertexKeyFile",
 	"vertexKeyFile",
 	"vertexJsonCredentials",
 	"vertexJsonCredentials",
 	"vertexProjectId",
 	"vertexProjectId",
@@ -66,6 +67,7 @@ export const GLOBAL_STATE_KEYS = [
 	"checkpointStorage",
 	"checkpointStorage",
 	"browserViewportSize",
 	"browserViewportSize",
 	"screenshotQuality",
 	"screenshotQuality",
+	"remoteBrowserHost",
 	"fuzzyMatchThreshold",
 	"fuzzyMatchThreshold",
 	"preferredLanguage", // Language setting for Cline's communication
 	"preferredLanguage", // Language setting for Cline's communication
 	"writeDelayMs",
 	"writeDelayMs",
@@ -100,6 +102,7 @@ export const GLOBAL_STATE_KEYS = [
 	"lmStudioDraftModelId",
 	"lmStudioDraftModelId",
 	"telemetrySetting",
 	"telemetrySetting",
 	"showRooIgnoredFiles",
 	"showRooIgnoredFiles",
+	"remoteBrowserEnabled",
 ] as const
 ] as const
 
 
 // Derive the type from the array - creates a union of string literals
 // Derive the type from the array - creates a union of string literals

+ 1 - 0
webview-ui/src/components/chat/ChatRow.tsx

@@ -258,6 +258,7 @@ export const ChatRowContent = ({
 							<span style={{ fontWeight: "bold" }}>Roo wants to edit this file:</span>
 							<span style={{ fontWeight: "bold" }}>Roo wants to edit this file:</span>
 						</div>
 						</div>
 						<CodeAccordian
 						<CodeAccordian
+							progressStatus={message.progressStatus}
 							isLoading={message.partial}
 							isLoading={message.partial}
 							diff={tool.diff!}
 							diff={tool.diff!}
 							path={tool.path!}
 							path={tool.path!}

+ 11 - 0
webview-ui/src/components/common/CodeAccordian.tsx

@@ -1,6 +1,7 @@
 import { memo, useMemo } from "react"
 import { memo, useMemo } from "react"
 import { getLanguageFromPath } from "../../utils/getLanguageFromPath"
 import { getLanguageFromPath } from "../../utils/getLanguageFromPath"
 import CodeBlock, { CODE_BLOCK_BG_COLOR } from "./CodeBlock"
 import CodeBlock, { CODE_BLOCK_BG_COLOR } from "./CodeBlock"
+import { ToolProgressStatus } from "../../../../src/shared/ExtensionMessage"
 
 
 interface CodeAccordianProps {
 interface CodeAccordianProps {
 	code?: string
 	code?: string
@@ -12,6 +13,7 @@ interface CodeAccordianProps {
 	isExpanded: boolean
 	isExpanded: boolean
 	onToggleExpand: () => void
 	onToggleExpand: () => void
 	isLoading?: boolean
 	isLoading?: boolean
+	progressStatus?: ToolProgressStatus
 }
 }
 
 
 /*
 /*
@@ -32,6 +34,7 @@ const CodeAccordian = ({
 	isExpanded,
 	isExpanded,
 	onToggleExpand,
 	onToggleExpand,
 	isLoading,
 	isLoading,
+	progressStatus,
 }: CodeAccordianProps) => {
 }: CodeAccordianProps) => {
 	const inferredLanguage = useMemo(
 	const inferredLanguage = useMemo(
 		() => code && (language ?? (path ? getLanguageFromPath(path) : undefined)),
 		() => code && (language ?? (path ? getLanguageFromPath(path) : undefined)),
@@ -95,6 +98,14 @@ const CodeAccordian = ({
 						</>
 						</>
 					)}
 					)}
 					<div style={{ flexGrow: 1 }}></div>
 					<div style={{ flexGrow: 1 }}></div>
+					{progressStatus && progressStatus.text && (
+						<>
+							{progressStatus.icon && <span className={`codicon codicon-${progressStatus.icon} mr-1`} />}
+							<span className="mr-1 ml-auto text-vscode-descriptionForeground">
+								{progressStatus.text}
+							</span>
+						</>
+					)}
 					<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>
 					<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>
 				</div>
 				</div>
 			)}
 			)}

+ 85 - 4
webview-ui/src/components/settings/ApiOptions.tsx

@@ -41,7 +41,7 @@ import { VSCodeButtonLink } from "../common/VSCodeButtonLink"
 import { ModelInfoView } from "./ModelInfoView"
 import { ModelInfoView } from "./ModelInfoView"
 import { ModelPicker } from "./ModelPicker"
 import { ModelPicker } from "./ModelPicker"
 import { TemperatureControl } from "./TemperatureControl"
 import { TemperatureControl } from "./TemperatureControl"
-import { validateApiConfiguration, validateModelId } from "@/utils/validate"
+import { validateApiConfiguration, validateModelId, validateBedrockArn } from "@/utils/validate"
 import { ApiErrorMessage } from "./ApiErrorMessage"
 import { ApiErrorMessage } from "./ApiErrorMessage"
 import { ThinkingBudget } from "./ThinkingBudget"
 import { ThinkingBudget } from "./ThinkingBudget"
 
 
@@ -1267,14 +1267,82 @@ const ApiOptions = ({
 						</label>
 						</label>
 						<Dropdown
 						<Dropdown
 							id="model-id"
 							id="model-id"
-							value={selectedModelId}
+							value={selectedModelId === "custom-arn" ? "custom-arn" : selectedModelId}
 							onChange={(value) => {
 							onChange={(value) => {
-								setApiConfigurationField("apiModelId", typeof value == "string" ? value : value?.value)
+								const modelValue = typeof value == "string" ? value : value?.value
+								setApiConfigurationField("apiModelId", modelValue)
+
+								// Clear custom ARN if not using custom ARN option
+								if (modelValue !== "custom-arn" && selectedProvider === "bedrock") {
+									setApiConfigurationField("awsCustomArn", "")
+								}
 							}}
 							}}
-							options={selectedProviderModelOptions}
+							options={[
+								...selectedProviderModelOptions,
+								...(selectedProvider === "bedrock"
+									? [{ value: "custom-arn", label: "Use custom ARN..." }]
+									: []),
+							]}
 							className="w-full"
 							className="w-full"
 						/>
 						/>
 					</div>
 					</div>
+
+					{selectedProvider === "bedrock" && selectedModelId === "custom-arn" && (
+						<>
+							<VSCodeTextField
+								value={apiConfiguration?.awsCustomArn || ""}
+								onInput={(e) => {
+									const value = (e.target as HTMLInputElement).value
+									setApiConfigurationField("awsCustomArn", value)
+								}}
+								placeholder="Enter ARN (e.g. arn:aws:bedrock:us-east-1:123456789012:foundation-model/my-model)"
+								className="w-full">
+								<span className="font-medium">Custom ARN</span>
+							</VSCodeTextField>
+							<div className="text-sm text-vscode-descriptionForeground -mt-2">
+								Enter a valid AWS Bedrock ARN for the model you want to use. Format examples:
+								<ul className="list-disc pl-5 mt-1">
+									<li>
+										arn:aws:bedrock:us-east-1:123456789012:foundation-model/anthropic.claude-3-sonnet-20240229-v1:0
+									</li>
+									<li>
+										arn:aws:bedrock:us-west-2:123456789012:provisioned-model/my-provisioned-model
+									</li>
+									<li>
+										arn:aws:bedrock:us-east-1:123456789012:default-prompt-router/anthropic.claude:1
+									</li>
+								</ul>
+								Make sure the region in the ARN matches your selected AWS Region above.
+							</div>
+							{apiConfiguration?.awsCustomArn &&
+								(() => {
+									const validation = validateBedrockArn(
+										apiConfiguration.awsCustomArn,
+										apiConfiguration.awsRegion,
+									)
+
+									if (!validation.isValid) {
+										return (
+											<div className="text-sm text-vscode-errorForeground mt-2">
+												{validation.errorMessage ||
+													"Invalid ARN format. Please check the examples above."}
+											</div>
+										)
+									}
+
+									if (validation.errorMessage) {
+										return (
+											<div className="text-sm text-vscode-errorForeground mt-2">
+												{validation.errorMessage}
+											</div>
+										)
+									}
+
+									return null
+								})()}
+							=======
+						</>
+					)}
 					<ModelInfoView
 					<ModelInfoView
 						selectedModelId={selectedModelId}
 						selectedModelId={selectedModelId}
 						modelInfo={selectedModelInfo}
 						modelInfo={selectedModelInfo}
@@ -1333,6 +1401,19 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
 		case "anthropic":
 		case "anthropic":
 			return getProviderData(anthropicModels, anthropicDefaultModelId)
 			return getProviderData(anthropicModels, anthropicDefaultModelId)
 		case "bedrock":
 		case "bedrock":
+			// Special case for custom ARN
+			if (modelId === "custom-arn") {
+				return {
+					selectedProvider: provider,
+					selectedModelId: "custom-arn",
+					selectedModelInfo: {
+						maxTokens: 5000,
+						contextWindow: 128_000,
+						supportsPromptCache: false,
+						supportsImages: true,
+					},
+				}
+			}
 			return getProviderData(bedrockModels, bedrockDefaultModelId)
 			return getProviderData(bedrockModels, bedrockDefaultModelId)
 		case "vertex":
 		case "vertex":
 			return getProviderData(vertexModels, vertexDefaultModelId)
 			return getProviderData(vertexModels, vertexDefaultModelId)

+ 136 - 3
webview-ui/src/components/settings/BrowserSettings.tsx

@@ -1,5 +1,5 @@
-import { HTMLAttributes } from "react"
-import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
+import React, { HTMLAttributes, useState, useEffect } from "react"
+import { VSCodeButton, VSCodeCheckbox, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
 import { Dropdown, type DropdownOption } from "vscrui"
 import { Dropdown, type DropdownOption } from "vscrui"
 import { SquareMousePointer } from "lucide-react"
 import { SquareMousePointer } from "lucide-react"
 
 
@@ -7,21 +7,96 @@ import { SetCachedStateField } from "./types"
 import { sliderLabelStyle } from "./styles"
 import { sliderLabelStyle } from "./styles"
 import { SectionHeader } from "./SectionHeader"
 import { SectionHeader } from "./SectionHeader"
 import { Section } from "./Section"
 import { Section } from "./Section"
+import { vscode } from "../../utils/vscode"
 
 
 type BrowserSettingsProps = HTMLAttributes<HTMLDivElement> & {
 type BrowserSettingsProps = HTMLAttributes<HTMLDivElement> & {
 	browserToolEnabled?: boolean
 	browserToolEnabled?: boolean
 	browserViewportSize?: string
 	browserViewportSize?: string
 	screenshotQuality?: number
 	screenshotQuality?: number
-	setCachedStateField: SetCachedStateField<"browserToolEnabled" | "browserViewportSize" | "screenshotQuality">
+	remoteBrowserHost?: string
+	remoteBrowserEnabled?: boolean
+	setCachedStateField: SetCachedStateField<
+		| "browserToolEnabled"
+		| "browserViewportSize"
+		| "screenshotQuality"
+		| "remoteBrowserHost"
+		| "remoteBrowserEnabled"
+	>
 }
 }
 
 
 export const BrowserSettings = ({
 export const BrowserSettings = ({
 	browserToolEnabled,
 	browserToolEnabled,
 	browserViewportSize,
 	browserViewportSize,
 	screenshotQuality,
 	screenshotQuality,
+	remoteBrowserHost,
+	remoteBrowserEnabled,
 	setCachedStateField,
 	setCachedStateField,
 	...props
 	...props
 }: BrowserSettingsProps) => {
 }: BrowserSettingsProps) => {
+	const [testingConnection, setTestingConnection] = useState(false)
+	const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
+	const [discovering, setDiscovering] = useState(false)
+	// We don't need a local state for useRemoteBrowser since we're using the enableRemoteBrowser prop directly
+	// This ensures the checkbox always reflects the current global state
+
+	// Set up message listener for browser connection results
+	useEffect(() => {
+		const handleMessage = (event: MessageEvent) => {
+			const message = event.data
+
+			if (message.type === "browserConnectionResult") {
+				setTestResult({
+					success: message.success,
+					message: message.text,
+				})
+				setTestingConnection(false)
+				setDiscovering(false)
+			}
+		}
+
+		window.addEventListener("message", handleMessage)
+
+		return () => {
+			window.removeEventListener("message", handleMessage)
+		}
+	}, [])
+
+	const testConnection = async () => {
+		setTestingConnection(true)
+		setTestResult(null)
+
+		try {
+			// Send a message to the extension to test the connection
+			vscode.postMessage({
+				type: "testBrowserConnection",
+				text: remoteBrowserHost,
+			})
+		} catch (error) {
+			setTestResult({
+				success: false,
+				message: `Error: ${error instanceof Error ? error.message : String(error)}`,
+			})
+			setTestingConnection(false)
+		}
+	}
+
+	const discoverBrowser = async () => {
+		setDiscovering(true)
+		setTestResult(null)
+
+		try {
+			// Send a message to the extension to discover Chrome instances
+			vscode.postMessage({
+				type: "discoverBrowser",
+			})
+		} catch (error) {
+			setTestResult({
+				success: false,
+				message: `Error: ${error instanceof Error ? error.message : String(error)}`,
+			})
+			setDiscovering(false)
+		}
+	}
 	return (
 	return (
 		<div {...props}>
 		<div {...props}>
 			<SectionHeader>
 			<SectionHeader>
@@ -96,6 +171,64 @@ export const BrowserSettings = ({
 									screenshots but increase token usage.
 									screenshots but increase token usage.
 								</p>
 								</p>
 							</div>
 							</div>
+							<div className="mt-4">
+								<div className="mb-2">
+									<VSCodeCheckbox
+										checked={remoteBrowserEnabled}
+										onChange={(e: any) => {
+											// Update the global state - remoteBrowserEnabled now means "enable remote browser connection"
+											setCachedStateField("remoteBrowserEnabled", e.target.checked)
+											if (!e.target.checked) {
+												// If disabling remote browser, clear the custom URL
+												setCachedStateField("remoteBrowserHost", undefined)
+											}
+										}}>
+										<span className="font-medium">Use remote browser connection</span>
+									</VSCodeCheckbox>
+									<p className="text-vscode-descriptionForeground text-sm mt-0 ml-6">
+										Connect to a Chrome browser running with remote debugging enabled
+										(--remote-debugging-port=9222).
+									</p>
+								</div>
+								{remoteBrowserEnabled && (
+									<>
+										<div style={{ display: "flex", gap: "5px", marginTop: "10px" }}>
+											<VSCodeTextField
+												value={remoteBrowserHost ?? ""}
+												onChange={(e: any) =>
+													setCachedStateField(
+														"remoteBrowserHost",
+														e.target.value || undefined,
+													)
+												}
+												placeholder="Custom URL (e.g., http://localhost:9222)"
+												style={{ flexGrow: 1 }}
+											/>
+											<VSCodeButton
+												disabled={testingConnection}
+												onClick={remoteBrowserHost ? testConnection : discoverBrowser}>
+												{testingConnection || discovering ? "Testing..." : "Test Connection"}
+											</VSCodeButton>
+										</div>
+										{testResult && (
+											<div
+												className={`p-2 mt-2 mb-2 rounded text-sm ${
+													testResult.success
+														? "bg-green-800/20 text-green-400"
+														: "bg-red-800/20 text-red-400"
+												}`}>
+												{testResult.message}
+											</div>
+										)}
+										<p className="text-vscode-descriptionForeground text-sm mt-2">
+											Enter the DevTools Protocol host address or
+											<strong> leave empty to auto-discover Chrome local instances.</strong>
+											The Test Connection button will try the custom URL if provided, or
+											auto-discover if the field is empty.
+										</p>
+									</>
+								)}
+							</div>
 						</div>
 						</div>
 					)}
 					)}
 				</div>
 				</div>

+ 25 - 8
webview-ui/src/components/settings/SettingsView.tsx

@@ -1,6 +1,15 @@
 import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"
 import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"
 import { Button as VSCodeButton } from "vscrui"
 import { Button as VSCodeButton } from "vscrui"
-import { CheckCheck, SquareMousePointer, Webhook, GitBranch, Bell, Cog, FlaskConical } from "lucide-react"
+import {
+	CheckCheck,
+	SquareMousePointer,
+	Webhook,
+	GitBranch,
+	Bell,
+	Cog,
+	FlaskConical,
+	AlertTriangle,
+} from "lucide-react"
 
 
 import { ApiConfiguration } from "../../../../src/shared/api"
 import { ApiConfiguration } from "../../../../src/shared/api"
 import { ExperimentId } from "../../../../src/shared/experiments"
 import { ExperimentId } from "../../../../src/shared/experiments"
@@ -78,6 +87,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 		mcpEnabled,
 		mcpEnabled,
 		rateLimitSeconds,
 		rateLimitSeconds,
 		requestDelaySeconds,
 		requestDelaySeconds,
+		remoteBrowserHost,
 		screenshotQuality,
 		screenshotQuality,
 		soundEnabled,
 		soundEnabled,
 		soundVolume,
 		soundVolume,
@@ -85,6 +95,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 		terminalOutputLimit,
 		terminalOutputLimit,
 		writeDelayMs,
 		writeDelayMs,
 		showRooIgnoredFiles,
 		showRooIgnoredFiles,
+		remoteBrowserEnabled,
 	} = cachedState
 	} = cachedState
 
 
 	// Make sure apiConfiguration is initialized and managed by SettingsView.
 	// Make sure apiConfiguration is initialized and managed by SettingsView.
@@ -173,6 +184,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 			vscode.postMessage({ type: "enableCheckpoints", bool: enableCheckpoints })
 			vscode.postMessage({ type: "enableCheckpoints", bool: enableCheckpoints })
 			vscode.postMessage({ type: "checkpointStorage", text: checkpointStorage })
 			vscode.postMessage({ type: "checkpointStorage", text: checkpointStorage })
 			vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize })
 			vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize })
+			vscode.postMessage({ type: "remoteBrowserHost", text: remoteBrowserHost })
+			vscode.postMessage({ type: "remoteBrowserEnabled", bool: remoteBrowserEnabled })
 			vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 })
 			vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 })
 			vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs })
 			vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs })
 			vscode.postMessage({ type: "screenshotQuality", value: screenshotQuality ?? 75 })
 			vscode.postMessage({ type: "screenshotQuality", value: screenshotQuality ?? 75 })
@@ -367,6 +380,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 						browserToolEnabled={browserToolEnabled}
 						browserToolEnabled={browserToolEnabled}
 						browserViewportSize={browserViewportSize}
 						browserViewportSize={browserViewportSize}
 						screenshotQuality={screenshotQuality}
 						screenshotQuality={screenshotQuality}
+						remoteBrowserHost={remoteBrowserHost}
+						remoteBrowserEnabled={remoteBrowserEnabled}
 						setCachedStateField={setCachedStateField}
 						setCachedStateField={setCachedStateField}
 					/>
 					/>
 				</div>
 				</div>
@@ -419,15 +434,17 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 			<AlertDialog open={isDiscardDialogShow} onOpenChange={setDiscardDialogShow}>
 			<AlertDialog open={isDiscardDialogShow} onOpenChange={setDiscardDialogShow}>
 				<AlertDialogContent>
 				<AlertDialogContent>
 					<AlertDialogHeader>
 					<AlertDialogHeader>
-						<AlertDialogTitle>Unsaved changes</AlertDialogTitle>
-						<AlertDialogDescription>
-							<span className={`codicon codicon-warning align-middle mr-1`} />
-							Do you want to discard changes and continue?
-						</AlertDialogDescription>
+						<AlertDialogTitle>
+							<AlertTriangle className="w-5 h-5 text-yellow-500" />
+							Unsaved Changes
+						</AlertDialogTitle>
+						<AlertDialogDescription>Do you want to discard changes and continue?</AlertDialogDescription>
 					</AlertDialogHeader>
 					</AlertDialogHeader>
 					<AlertDialogFooter>
 					<AlertDialogFooter>
-						<AlertDialogAction onClick={() => onConfirmDialogResult(true)}>Yes</AlertDialogAction>
-						<AlertDialogCancel onClick={() => onConfirmDialogResult(false)}>No</AlertDialogCancel>
+						<AlertDialogCancel onClick={() => onConfirmDialogResult(false)}>Cancel</AlertDialogCancel>
+						<AlertDialogAction onClick={() => onConfirmDialogResult(true)}>
+							Discard changes
+						</AlertDialogAction>
 					</AlertDialogFooter>
 					</AlertDialogFooter>
 				</AlertDialogContent>
 				</AlertDialogContent>
 			</AlertDialog>
 			</AlertDialog>

+ 28 - 13
webview-ui/src/components/ui/alert-dialog.tsx

@@ -36,7 +36,7 @@ function AlertDialogContent({ className, ...props }: React.ComponentProps<typeof
 			<AlertDialogPrimitive.Content
 			<AlertDialogPrimitive.Content
 				data-slot="alert-dialog-content"
 				data-slot="alert-dialog-content"
 				className={cn(
 				className={cn(
-					"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
+					"bg-vscode-editor-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-3 rounded-sm border border-vscode-panel-border p-4 shadow-lg duration-200 sm:max-w-md",
 					className,
 					className,
 				)}
 				)}
 				{...props}
 				{...props}
@@ -46,20 +46,14 @@ function AlertDialogContent({ className, ...props }: React.ComponentProps<typeof
 }
 }
 
 
 function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
 function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
-	return (
-		<div
-			data-slot="alert-dialog-header"
-			className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
-			{...props}
-		/>
-	)
+	return <div data-slot="alert-dialog-header" className={cn("flex flex-col gap-1 text-left", className)} {...props} />
 }
 }
 
 
 function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
 function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
 	return (
 	return (
 		<div
 		<div
 			data-slot="alert-dialog-footer"
 			data-slot="alert-dialog-footer"
-			className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
+			className={cn("flex flex-row justify-end gap-2 mt-4", className)}
 			{...props}
 			{...props}
 		/>
 		/>
 	)
 	)
@@ -69,7 +63,10 @@ function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof A
 	return (
 	return (
 		<AlertDialogPrimitive.Title
 		<AlertDialogPrimitive.Title
 			data-slot="alert-dialog-title"
 			data-slot="alert-dialog-title"
-			className={cn("text-lg font-semibold", className)}
+			className={cn(
+				"text-base font-medium text-vscode-editor-foreground flex items-center gap-2 text-left",
+				className,
+			)}
 			{...props}
 			{...props}
 		/>
 		/>
 	)
 	)
@@ -82,18 +79,36 @@ function AlertDialogDescription({
 	return (
 	return (
 		<AlertDialogPrimitive.Description
 		<AlertDialogPrimitive.Description
 			data-slot="alert-dialog-description"
 			data-slot="alert-dialog-description"
-			className={cn("text-muted-foreground text-sm", className)}
+			className={cn("text-vscode-descriptionForeground text-sm text-left", className)}
 			{...props}
 			{...props}
 		/>
 		/>
 	)
 	)
 }
 }
 
 
 function AlertDialogAction({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
 function AlertDialogAction({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
-	return <AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />
+	return (
+		<AlertDialogPrimitive.Action
+			className={cn(
+				buttonVariants(),
+				"bg-vscode-button-background text-vscode-button-foreground hover:bg-vscode-button-hoverBackground border border-transparent h-6 px-3 py-1",
+				className,
+			)}
+			{...props}
+		/>
+	)
 }
 }
 
 
 function AlertDialogCancel({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
 function AlertDialogCancel({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
-	return <AlertDialogPrimitive.Cancel className={cn(buttonVariants({ variant: "outline" }), className)} {...props} />
+	return (
+		<AlertDialogPrimitive.Cancel
+			className={cn(
+				buttonVariants({ variant: "outline" }),
+				"bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground hover:bg-vscode-button-secondaryHoverBackground border border-vscode-button-border h-6 px-3 py-1",
+				className,
+			)}
+			{...props}
+		/>
+	)
 }
 }
 
 
 export {
 export {

+ 3 - 0
webview-ui/src/context/ExtensionStateContext.tsx

@@ -75,6 +75,8 @@ export interface ExtensionStateContextType extends ExtensionState {
 	setCustomModes: (value: ModeConfig[]) => void
 	setCustomModes: (value: ModeConfig[]) => void
 	setMaxOpenTabsContext: (value: number) => void
 	setMaxOpenTabsContext: (value: number) => void
 	setTelemetrySetting: (value: TelemetrySetting) => void
 	setTelemetrySetting: (value: TelemetrySetting) => void
+	remoteBrowserEnabled?: boolean
+	setRemoteBrowserEnabled: (value: boolean) => void
 	machineId?: string
 	machineId?: string
 }
 }
 
 
@@ -286,6 +288,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		setBrowserToolEnabled: (value) => setState((prevState) => ({ ...prevState, browserToolEnabled: value })),
 		setBrowserToolEnabled: (value) => setState((prevState) => ({ ...prevState, browserToolEnabled: value })),
 		setTelemetrySetting: (value) => setState((prevState) => ({ ...prevState, telemetrySetting: value })),
 		setTelemetrySetting: (value) => setState((prevState) => ({ ...prevState, telemetrySetting: value })),
 		setShowRooIgnoredFiles: (value) => setState((prevState) => ({ ...prevState, showRooIgnoredFiles: value })),
 		setShowRooIgnoredFiles: (value) => setState((prevState) => ({ ...prevState, showRooIgnoredFiles: value })),
+		setRemoteBrowserEnabled: (value) => setState((prevState) => ({ ...prevState, remoteBrowserEnabled: value })),
 	}
 	}
 
 
 	return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>
 	return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>

+ 38 - 0
webview-ui/src/utils/validate.ts

@@ -80,6 +80,44 @@ export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): s
 
 
 	return undefined
 	return undefined
 }
 }
+/**
+ * Validates an AWS Bedrock ARN format and optionally checks if the region in the ARN matches the provided region
+ * @param arn The ARN string to validate
+ * @param region Optional region to check against the ARN's region
+ * @returns An object with validation results: { isValid, arnRegion, errorMessage }
+ */
+export function validateBedrockArn(arn: string, region?: string) {
+	// Validate ARN format
+	const arnRegex = /^arn:aws:bedrock:([^:]+):(\d+):(foundation-model|provisioned-model|default-prompt-router)\/(.+)$/
+	const match = arn.match(arnRegex)
+
+	if (!match) {
+		return {
+			isValid: false,
+			arnRegion: undefined,
+			errorMessage: "Invalid ARN format. Please check the format requirements.",
+		}
+	}
+
+	// Extract region from ARN
+	const arnRegion = match[1]
+
+	// Check if region in ARN matches provided region (if specified)
+	if (region && arnRegion !== region) {
+		return {
+			isValid: true,
+			arnRegion,
+			errorMessage: `Warning: The region in your ARN (${arnRegion}) does not match your selected region (${region}). This may cause access issues. The provider will use the region from the ARN.`,
+		}
+	}
+
+	// ARN is valid and region matches (or no region was provided to check against)
+	return {
+		isValid: true,
+		arnRegion,
+		errorMessage: undefined,
+	}
+}
 
 
 export function validateModelId(
 export function validateModelId(
 	apiConfiguration?: ApiConfiguration,
 	apiConfiguration?: ApiConfiguration,