Compare commits

...

10 Commits

Author SHA1 Message Date
Andrei Edell 9fb63f7d05 formatting & changeset 2025-03-25 12:03:53 -10:00
Andrei Edell f951e9b516 really nice generic scroll to settings & highlight 2025-03-25 11:58:29 -10:00
Andrei Edell 097171d016 first version of scrolling to browser settings 2025-03-25 11:27:22 -10:00
Andrei Edell 0160b18a56 rename incorrect headless mode to 'local mode' 2025-03-25 10:25:42 -10:00
Andrei Edell 173020f2cd correct chrome path description, remove some old comments, and rename headless mode to local mode 2025-03-24 18:58:21 -10:00
Andrei Edell 2f37207e2b changeset & prettier 2025-03-24 18:46:02 -10:00
Andrei Edell 8dbd25cf6e move the browser settings into regular settings 2025-03-24 18:46:02 -10:00
Andrei Edell b6c590d0c4 clean up auto-detect vs specified path 2025-03-24 18:46:02 -10:00
Andrei Edell e51b39e7f2 successfully open remote chrome 2025-03-24 18:46:02 -10:00
Andrei Edell 78bb82ace8 manual port 2025-03-24 18:45:54 -10:00
14 changed files with 818 additions and 284 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Feature to open basic settings & scroll a section into view with a highlight animation
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Remote browser control via devtools protocol
+10
View File
@@ -232,6 +232,16 @@
"default": null,
"description": "Path to Chrome executable for browser use functionality. If not set, the extension will attempt to find or download it automatically."
},
"cline.remoteBrowserEnabled": {
"type": "boolean",
"default": false,
"description": "Enable connection to a remote Chrome browser with remote debugging enabled (--remote-debugging-port=9222)."
},
"cline.remoteBrowserHost": {
"type": "string",
"default": "http://localhost:9222",
"description": "URL of the remote Chrome browser's DevTools Protocol endpoint. Leave empty for auto-discovery."
},
"cline.preferredLanguage": {
"type": "string",
"enum": [
+129 -1
View File
@@ -41,6 +41,8 @@ import { getTotalTasksSize } from "../../utils/storage"
import { ConversationTelemetryService } from "../../services/telemetry/ConversationTelemetryService"
import { GlobalFileNames } from "../../global-constants"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import { BrowserSession } from "../../services/browser/BrowserSession"
import { discoverChromeInstances } from "../../services/browser/browserDiscovery"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -115,6 +117,8 @@ type GlobalStateKey =
| "asksageApiUrl"
| "thinkingBudgetTokens"
| "planActSeparateModelsSetting"
| "remoteBrowserHost"
| "remoteBrowserEnabled"
export class ClineProvider implements vscode.WebviewViewProvider {
public static readonly sideBarId = "claude-dev.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension.
@@ -574,6 +578,107 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.postStateToWebview()
}
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 { browserSettings } = await this.getState()
const browserSession = new BrowserSession(this.context, browserSettings)
// 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 { browserSettings } = await this.getState()
const browserSession = new BrowserSession(this.context, browserSettings)
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 "togglePlanActMode":
if (message.chatSettings) {
await this.togglePlanActModeWithChatSettings(message.chatSettings, message.chatContent)
@@ -901,6 +1006,13 @@ export class ClineProvider implements vscode.WebviewViewProvider {
})
break
}
case "scrollToSettings": {
await this.postMessageToWebview({
type: "scrollToSettings",
text: message.text,
})
break
}
case "telemetrySetting": {
if (message.telemetrySetting) {
await this.updateTelemetrySetting(message.telemetrySetting)
@@ -2083,6 +2195,11 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
*/
async getState() {
// Read settings from VSCode configuration
const config = vscode.workspace.getConfiguration("cline")
const configRemoteBrowserEnabled = config.get<boolean>("remoteBrowserEnabled")
const configRemoteBrowserHost = config.get<string>("remoteBrowserHost")
const [
storedApiProvider,
apiModelId,
@@ -2147,6 +2264,8 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
thinkingBudgetTokens,
sambanovaApiKey,
planActSeparateModelsSettingRaw,
remoteBrowserEnabled,
remoteBrowserHost,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -2211,6 +2330,8 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
this.getGlobalState("thinkingBudgetTokens") as Promise<number | undefined>,
this.getSecret("sambanovaApiKey") as Promise<string | undefined>,
this.getGlobalState("planActSeparateModelsSetting") as Promise<boolean | undefined>,
this.getGlobalState("remoteBrowserEnabled") as Promise<boolean | undefined>,
this.getGlobalState("remoteBrowserHost") as Promise<string | undefined>,
])
let apiProvider: ApiProvider
@@ -2251,6 +2372,13 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
await this.updateGlobalState("planActSeparateModelsSetting", planActSeparateModelsSetting)
}
// Merge browser settings with configuration values
const mergedBrowserSettings = {
...(browserSettings || DEFAULT_BROWSER_SETTINGS),
remoteBrowserEnabled: remoteBrowserEnabled ?? configRemoteBrowserEnabled ?? false,
remoteBrowserHost: remoteBrowserHost ?? configRemoteBrowserHost ?? "http://localhost:9222",
}
return {
apiConfiguration: {
apiProvider,
@@ -2308,7 +2436,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
customInstructions,
taskHistory,
autoApprovalSettings: autoApprovalSettings || DEFAULT_AUTO_APPROVAL_SETTINGS, // default value can be 0 or empty string
browserSettings: browserSettings || DEFAULT_BROWSER_SETTINGS,
browserSettings: mergedBrowserSettings,
chatSettings: chatSettings || DEFAULT_CHAT_SETTINGS,
userInfo,
previousModeApiProvider,
+120 -52
View File
@@ -1,14 +1,16 @@
import * as vscode from "vscode"
import * as fs from "fs/promises"
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
import PCR from "puppeteer-chromium-resolver"
import pWaitFor from "p-wait-for"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import axios from "axios"
import { fileExistsAtPath } from "../../utils/fs"
import { BrowserActionResult } from "../../shared/ExtensionMessage"
import { BrowserSettings } from "../../shared/BrowserSettings"
import { discoverChromeInstances, testBrowserConnection } from "./browserDiscovery"
// import * as chromeLauncher from "chrome-launcher"
interface PCRStats {
@@ -16,13 +18,15 @@ interface PCRStats {
executablePath: string
}
// const DEBUG_PORT = 9222 // Chrome's default debugging port
const DEBUG_PORT = 9222 // Chrome's default debugging port
export class BrowserSession {
private context: vscode.ExtensionContext
private browser?: Browser
private page?: Page
private currentMousePosition?: string
private cachedWebSocketEndpoint?: string
private lastConnectionAttempt: number = 0
browserSettings: BrowserSettings
constructor(context: vscode.ExtensionContext, browserSettings: BrowserSettings) {
@@ -30,6 +34,11 @@ export class BrowserSession {
this.browserSettings = browserSettings
}
// Tests remote browser connection
async testConnection(host: string): Promise<{ success: boolean; message: string; endpoint?: string }> {
return testBrowserConnection(host)
}
private async ensureChromiumExists(): Promise<PCRStats> {
const globalStoragePath = this.context?.globalStorageUri?.fsPath
if (!globalStoragePath) {
@@ -55,16 +64,6 @@ export class BrowserSession {
return stats
}
// private async checkExistingChromeDebugger(): Promise<boolean> {
// try {
// // Try to connect to existing debugger
// const response = await fetch(`http://localhost:${DEBUG_PORT}/json/version`)
// return response.ok
// } catch {
// return false
// }
// }
// async relaunchChromeDebugMode() {
// const result = await vscode.window.showWarningMessage(
// "This will close your existing Chrome tabs and relaunch Chrome in debug mode. Are you sure?",
@@ -103,29 +102,30 @@ export class BrowserSession {
// return installation
// }
// /**
// * Helper to detect users default Chrome data dir.
// * Adjust for OS if needed.
// */
// private getDefaultChromeUserDataDir(): string {
// const homedir = require("os").homedir()
// switch (process.platform) {
// case "win32":
// return path.join(homedir, "AppData", "Local", "Google", "Chrome", "User Data")
// case "darwin":
// return path.join(homedir, "Library", "Application Support", "Google", "Chrome")
// default:
// return path.join(homedir, ".config", "google-chrome")
// }
// }
async launchBrowser() {
console.log("launch browser called")
if (this.browser) {
// throw new Error("Browser already launched")
await this.closeBrowser() // this may happen when the model launches a browser again after having used it already before
}
if (this.browserSettings.remoteBrowserEnabled) {
console.log(`launch browser called -- remote host mode (headless: ${this.browserSettings.headless})`)
try {
await this.launchRemoteBrowser()
// Don't create a new page here, as we'll create it in launchRemoteBrowser
return
} catch (error) {
console.error("Failed to launch remote browser, falling back to local mode:", error)
await this.launchLocalBrowser()
}
} else {
console.log(`launch browser called -- local mode (headless: ${this.browserSettings.headless})`)
await this.launchLocalBrowser()
}
this.page = await this.browser?.newPage()
}
async launchLocalBrowser() {
const stats = await this.ensureChromiumExists()
this.browser = await stats.puppeteer.launch({
args: [
@@ -135,34 +135,102 @@ export class BrowserSession {
defaultViewport: this.browserSettings.viewport,
headless: this.browserSettings.headless,
})
}
// if (this.browserSettings.chromeType === "system") {
// const userDataDir = this.getDefaultChromeUserDataDir()
// this.browser = await stats.puppeteer.launch({
// args: [`--user-data-dir=${userDataDir}`, "--profile-directory=Default"],
// executablePath: await this.getSystemChromeExecutablePath(),
// defaultViewport: this.browserSettings.viewport,
// headless: this.browserSettings.headless,
// })
// } else {
// 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: this.browserSettings.viewport,
// headless: this.browserSettings.headless,
// })
// }
async launchRemoteBrowser() {
let remoteBrowserHost = this.browserSettings.remoteBrowserHost
let browserWSEndpoint: string | undefined = this.cachedWebSocketEndpoint
let reconnectionAttempted = false
// (latest version of puppeteer does not add headless to user agent)
this.page = await this.browser?.newPage()
const getViewport = () => {
const size = (this.context.globalState.get("browserViewportSize") as string | undefined) || "900x600"
const [width, height] = size.split("x").map(Number)
return { width, height }
}
// First try auto-discovery if no host is provided
if (!remoteBrowserHost) {
try {
console.log("No remote browser host provided, trying auto-discovery")
const discoveredHost = await discoverChromeInstances()
if (discoveredHost) {
console.log(`Auto-discovered Chrome at ${discoveredHost}`)
remoteBrowserHost = discoveredHost
}
} catch (error) {
console.log(`Auto-discovery failed: ${error}`)
}
}
// 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
}
}
}
// Try to connect with host (either user-provided or auto-discovered)
if (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 browser 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.log(`Failed to connect to remote browser: ${error}`)
}
}
// If we get here, all connection attempts failed
throw new Error(
"Failed to connect to remote browser. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).",
)
}
async closeBrowser(): Promise<BrowserActionResult> {
if (this.browser || this.page) {
console.log("closing browser...")
await this.browser?.close().catch(() => {})
if (this.browserSettings.remoteBrowserEnabled && this.browser) {
await this.browser.disconnect().catch(() => {})
console.log("disconnected from remote browser...")
} else {
await this.browser?.close().catch(() => {})
console.log("closed local browser...")
}
this.browser = undefined
this.page = undefined
this.currentMousePosition = undefined
+245
View File
@@ -0,0 +1,245 @@
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
*/
export async function getDockerGatewayIP(): Promise<string | null> {
try {
if (process.platform === "linux") {
try {
// this looks sketchy: command cross-platform availability -Andrei
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
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)}`,
}
}
}
+4
View File
@@ -8,6 +8,8 @@ export interface BrowserSettings {
headless: boolean
// Chrome installation to use
// chromeType: "chromium" | "system"
remoteBrowserHost?: string
remoteBrowserEnabled?: boolean
}
export const DEFAULT_BROWSER_SETTINGS: BrowserSettings = {
@@ -16,6 +18,8 @@ export const DEFAULT_BROWSER_SETTINGS: BrowserSettings = {
height: 600,
},
headless: true,
remoteBrowserEnabled: false,
remoteBrowserHost: undefined,
// chromeType: "chromium",
}
+5
View File
@@ -40,6 +40,8 @@ export interface ExtensionMessage {
| "userCreditsPayments"
| "totalTasksSize"
| "addToInput"
| "browserConnectionResult"
| "scrollToSettings"
text?: string
action?:
| "chatButtonClicked"
@@ -80,6 +82,8 @@ export interface ExtensionMessage {
userCreditsUsage?: UsageTransaction[]
userCreditsPayments?: PaymentTransaction[]
totalTasksSize?: number | null
success?: boolean
values?: Record<string, any>
}
export type Invoke = "sendMessage" | "primaryButtonClick" | "secondaryButtonClick"
@@ -92,6 +96,7 @@ export interface ExtensionState {
apiConfiguration?: ApiConfiguration
autoApprovalSettings: AutoApprovalSettings
browserSettings: BrowserSettings
remoteBrowserHost?: string
chatSettings: ChatSettings
checkpointTrackerErrorMessage?: string
clineMessages: ClineMessage[]
+6
View File
@@ -34,6 +34,11 @@ export interface WebviewMessage {
| "deleteMcpServer"
| "autoApprovalSettings"
| "browserSettings"
| "remoteBrowserHost"
| "remoteBrowserEnabled"
| "discoverBrowser"
| "testBrowserConnection"
| "browserConnectionResult"
| "togglePlanActMode"
| "checkpointDiff"
| "checkpointRestore"
@@ -65,6 +70,7 @@ export interface WebviewMessage {
| "fetchUserCreditsData"
| "optionsResponse"
| "requestTotalTasksSize"
| "scrollToSettings"
// | "relaunchChromeDebugMode"
text?: string
disabled?: boolean
@@ -1,235 +1,38 @@
import { VSCodeButton, VSCodeCheckbox, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
import React, { useRef, useState } from "react"
import { useClickAway } from "react-use"
import styled from "styled-components"
import { BROWSER_VIEWPORT_PRESETS } from "../../../../src/shared/BrowserSettings"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import React, { useRef } from "react"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
interface BrowserSettingsMenuProps {
disabled?: boolean
maxWidth?: number
}
export const BrowserSettingsMenu: React.FC<BrowserSettingsMenuProps> = ({ disabled = false, maxWidth }) => {
export const BrowserSettingsMenu: React.FC<BrowserSettingsMenuProps> = ({ maxWidth }) => {
const { browserSettings } = useExtensionState()
const [showMenu, setShowMenu] = useState(false)
const [hasMouseEntered, setHasMouseEntered] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
useClickAway(containerRef, () => {
if (showMenu) {
setShowMenu(false)
setHasMouseEntered(false)
}
})
const handleMouseEnter = () => {
setHasMouseEntered(true)
}
const handleMouseLeave = () => {
if (hasMouseEntered) {
setShowMenu(false)
setHasMouseEntered(false)
}
}
const handleControlsMouseLeave = (e: React.MouseEvent) => {
const menuElement = menuRef.current
if (menuElement && showMenu) {
const menuRect = menuElement.getBoundingClientRect()
// If mouse is moving towards the menu, don't close it
if (
e.clientY >= menuRect.top &&
e.clientY <= menuRect.bottom &&
e.clientX >= menuRect.left &&
e.clientX <= menuRect.right
) {
return
}
}
setShowMenu(false)
setHasMouseEntered(false)
}
const handleViewportChange = (event: Event) => {
const target = event.target as HTMLSelectElement
const selectedSize = BROWSER_VIEWPORT_PRESETS[target.value as keyof typeof BROWSER_VIEWPORT_PRESETS]
if (selectedSize) {
vscode.postMessage({
type: "browserSettings",
browserSettings: {
...browserSettings,
viewport: selectedSize,
},
})
}
}
const updateHeadless = (headless: boolean) => {
const openBrowserSettings = () => {
// First open the settings panel
vscode.postMessage({
type: "browserSettings",
browserSettings: {
...browserSettings,
headless,
},
type: "openSettings",
})
// After a short delay, send a message to scroll to browser settings
setTimeout(() => {
vscode.postMessage({
type: "scrollToSettings",
text: "browser-settings-section",
})
}, 300) // Give the settings panel time to open
}
// const updateChromeType = (chromeType: BrowserSettings["chromeType"]) => {
// vscode.postMessage({
// type: "browserSettings",
// browserSettings: {
// ...browserSettings,
// chromeType,
// },
// })
// }
// const relaunchChromeDebugMode = () => {
// vscode.postMessage({
// type: "relaunchChromeDebugMode",
// })
// }
return (
<div ref={containerRef} style={{ position: "relative", marginTop: "-1px" }} onMouseLeave={handleControlsMouseLeave}>
<VSCodeButton appearance="icon" onClick={() => setShowMenu(!showMenu)} disabled={disabled}>
<div ref={containerRef} style={{ position: "relative", marginTop: "-1px" }}>
<VSCodeButton appearance="icon" onClick={openBrowserSettings}>
<i className="codicon codicon-settings-gear" style={{ fontSize: "14.5px" }} />
</VSCodeButton>
{showMenu && (
<SettingsMenu ref={menuRef} maxWidth={maxWidth} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<SettingsGroup>
{/* <SettingsHeader>Headless Mode</SettingsHeader> */}
<VSCodeCheckbox
style={{ marginBottom: "8px", marginTop: -1 }}
checked={browserSettings.headless}
onChange={(e) => updateHeadless((e.target as HTMLInputElement).checked)}>
Run in headless mode
</VSCodeCheckbox>
<SettingsDescription>When enabled, Chrome will run in the background.</SettingsDescription>
</SettingsGroup>
{/* <SettingsGroup>
<SettingsHeader>Chrome Executable</SettingsHeader>
<VSCodeDropdown
style={{ width: "100%", marginBottom: "8px" }}
value={browserSettings.chromeType}
onChange={(e) =>
updateChromeType((e.target as HTMLSelectElement).value as BrowserSettings["chromeType"])
}>
<VSCodeOption value="chromium">Chromium (Auto-downloaded)</VSCodeOption>
<VSCodeOption value="system">System Chrome</VSCodeOption>
</VSCodeDropdown>
<SettingsDescription>
{browserSettings.chromeType === "system" ? (
<>
Cline will use your personal browser. You must{" "}
<VSCodeLink
href="#"
style={{ fontSize: "inherit" }}
onClick={(e: React.MouseEvent) => {
e.preventDefault()
relaunchChromeDebugMode()
}}>
relaunch Chrome in debug mode
</VSCodeLink>{" "}
to use this setting.
</>
) : (
"Cline will use a Chromium browser bundled with the extension."
)}
</SettingsDescription>
</SettingsGroup> */}
<SettingsGroup>
<SettingsHeader>Viewport Size</SettingsHeader>
<VSCodeDropdown
style={{ width: "100%" }}
value={
Object.entries(BROWSER_VIEWPORT_PRESETS).find(
([_, size]) =>
size.width === browserSettings.viewport.width &&
size.height === browserSettings.viewport.height,
)?.[0]
}
onChange={(event) => handleViewportChange(event as Event)}>
{Object.entries(BROWSER_VIEWPORT_PRESETS).map(([name]) => (
<VSCodeOption key={name} value={name}>
{name}
</VSCodeOption>
))}
</VSCodeDropdown>
</SettingsGroup>
</SettingsMenu>
)}
</div>
)
}
const SettingsMenu = styled.div<{ maxWidth?: number }>`
position: absolute;
top: calc(100% + 8px);
right: -2px;
background: ${CODE_BLOCK_BG_COLOR};
border: 1px solid var(--vscode-editorGroup-border);
padding: 8px;
border-radius: 3px;
z-index: 1000;
width: calc(100vw - 57px);
min-width: 0px;
max-width: ${(props) => (props.maxWidth ? `${props.maxWidth - 23}px` : "100vw")};
// Add invisible padding to create a safe hover zone
&::before {
content: "";
position: absolute;
top: -14px; // Same as margin-top in the parent's top property
left: 0;
right: -6px;
height: 14px;
}
&::after {
content: "";
position: absolute;
top: -6px;
right: 6px;
width: 10px;
height: 10px;
background: ${CODE_BLOCK_BG_COLOR};
border-left: 1px solid var(--vscode-editorGroup-border);
border-top: 1px solid var(--vscode-editorGroup-border);
transform: rotate(45deg);
z-index: 1; // Ensure arrow stays above the padding
}
`
const SettingsGroup = styled.div`
&:not(:last-child) {
margin-bottom: 8px;
// padding-bottom: 8px;
border-bottom: 1px solid var(--vscode-editorGroup-border);
}
`
const SettingsHeader = styled.div`
font-size: 11px;
font-weight: 600;
margin-bottom: 6px;
color: var(--vscode-foreground);
`
const SettingsDescription = styled.div<{ isLast?: boolean }>`
font-size: 11px;
color: var(--vscode-descriptionForeground);
margin-bottom: ${(props) => (props.isLast ? "0" : "8px")};
`
export default BrowserSettingsMenu
@@ -307,7 +307,7 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
{displayState.url || "http"}
</div>
</div>
<BrowserSettingsMenu disabled={!shouldShowSettings} maxWidth={maxWidth} />
<BrowserSettingsMenu maxWidth={maxWidth} />
</div>
{/* Screenshot Area */}
@@ -0,0 +1,239 @@
import React, { useState, useEffect } from "react"
import { VSCodeButton, VSCodeCheckbox, VSCodeDropdown, VSCodeOption, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { BROWSER_VIEWPORT_PRESETS } from "../../../../src/shared/BrowserSettings"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
export const BrowserSettingsSection: React.FC = () => {
const { browserSettings } = useExtensionState()
const [testingConnection, setTestingConnection] = useState(false)
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
// Listen for browser connection test results
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message = event.data
if (message.type === "browserConnectionResult") {
setTestResult({
success: message.success,
message: message.text,
})
setTestingConnection(false)
}
}
window.addEventListener("message", handleMessage)
return () => window.removeEventListener("message", handleMessage)
}, [])
const handleViewportChange = (event: Event) => {
const target = event.target as HTMLSelectElement
const selectedSize = BROWSER_VIEWPORT_PRESETS[target.value as keyof typeof BROWSER_VIEWPORT_PRESETS]
if (selectedSize) {
vscode.postMessage({
type: "browserSettings",
browserSettings: {
...browserSettings,
viewport: selectedSize,
},
})
}
}
const updateHeadless = (headless: boolean) => {
vscode.postMessage({
type: "browserSettings",
browserSettings: {
...browserSettings,
headless,
},
})
}
const updateRemoteBrowserEnabled = (enabled: boolean) => {
vscode.postMessage({
type: "remoteBrowserEnabled",
bool: enabled,
})
// If disabling, clear the host
if (!enabled) {
vscode.postMessage({
type: "remoteBrowserHost",
text: undefined,
})
}
}
const updateRemoteBrowserHost = (host: string | undefined) => {
vscode.postMessage({
type: "remoteBrowserHost",
text: host,
})
}
const testConnection = () => {
setTestingConnection(true)
setTestResult(null)
vscode.postMessage({
type: "testBrowserConnection",
text: browserSettings.remoteBrowserHost,
})
}
const discoverBrowser = () => {
setTestingConnection(true)
setTestResult(null)
vscode.postMessage({
type: "discoverBrowser",
})
}
return (
<div
id="browser-settings-section"
style={{ marginBottom: 20, borderTop: "1px solid var(--vscode-panel-border)", paddingTop: 15 }}>
<h3 style={{ color: "var(--vscode-foreground)", margin: "0 0 10px 0", fontSize: "14px" }}>Browser Settings</h3>
<div style={{ marginBottom: 15 }}>
<div style={{ marginBottom: 8 }}>
<label style={{ fontWeight: "500", display: "block", marginBottom: 5 }}>Viewport Size</label>
<VSCodeDropdown
style={{ width: "100%" }}
value={
Object.entries(BROWSER_VIEWPORT_PRESETS).find(([_, size]) => {
const typedSize = size as { width: number; height: number }
return (
typedSize.width === browserSettings.viewport.width &&
typedSize.height === browserSettings.viewport.height
)
})?.[0]
}
onChange={(event) => handleViewportChange(event as Event)}>
{Object.entries(BROWSER_VIEWPORT_PRESETS).map(([name]) => (
<VSCodeOption key={name} value={name}>
{name}
</VSCodeOption>
))}
</VSCodeDropdown>
</div>
<p
style={{
fontSize: "12px",
color: "var(--vscode-descriptionForeground)",
margin: 0,
}}>
Set the size of the browser viewport for screenshots and interactions.
</p>
</div>
<div style={{ marginBottom: 15 }}>
<VSCodeCheckbox
style={{ marginBottom: "8px" }}
checked={browserSettings.headless}
onChange={(e) => updateHeadless((e.target as HTMLInputElement).checked)}>
Run in headless mode
</VSCodeCheckbox>
<p
style={{
fontSize: "12px",
color: "var(--vscode-descriptionForeground)",
margin: "0 0 0 20px",
}}>
When enabled, Chrome will run in the background without a visible window.
</p>
</div>
<div style={{ marginBottom: 15 }}>
<div style={{ marginBottom: 8 }}>
<label style={{ fontWeight: "500", display: "block", marginBottom: 5 }}>Chrome Executable Path</label>
<VSCodeTextField
style={{ width: "100%" }}
placeholder="Path to Chrome executable"
onChange={(e: any) => {
const value = e.target.value
// Update VSCode configuration directly
vscode.postMessage({
type: "openExtensionSettings",
text: "chromeExecutablePath",
})
}}
/>
</div>
<p
style={{
fontSize: "12px",
color: "var(--vscode-descriptionForeground)",
margin: 0,
}}>
Path to Chrome executable for browser use functionality. If not set, the extension will attempt to find it
automatically.
</p>
</div>
<div style={{ marginBottom: 15 }}>
<div style={{ marginBottom: 8 }}>
<VSCodeCheckbox
checked={browserSettings.remoteBrowserEnabled}
onChange={(e) => updateRemoteBrowserEnabled((e.target as HTMLInputElement).checked)}>
Use remote browser connection
</VSCodeCheckbox>
</div>
<p
style={{
fontSize: "12px",
color: "var(--vscode-descriptionForeground)",
margin: "0 0 8px 20px",
}}>
Connect to a Chrome browser running with remote debugging enabled (--remote-debugging-port=9222). This allows
Cline to use your existing browser session with all authentication cookies.
</p>
{browserSettings.remoteBrowserEnabled && (
<div style={{ marginLeft: 20 }}>
<div style={{ display: "flex", gap: "5px", marginBottom: 8 }}>
<VSCodeTextField
value={browserSettings.remoteBrowserHost || ""}
placeholder="http://localhost:9222"
style={{ flexGrow: 1 }}
onChange={(e: any) => updateRemoteBrowserHost(e.target.value || undefined)}
/>
<VSCodeButton
disabled={testingConnection}
onClick={browserSettings.remoteBrowserHost ? testConnection : discoverBrowser}>
{testingConnection ? "Testing..." : "Test Connection"}
</VSCodeButton>
</div>
{testResult && (
<div
style={{
padding: "8px",
marginBottom: "8px",
backgroundColor: testResult.success ? "rgba(0, 128, 0, 0.1)" : "rgba(255, 0, 0, 0.1)",
color: testResult.success
? "var(--vscode-terminal-ansiGreen)"
: "var(--vscode-terminal-ansiRed)",
borderRadius: "3px",
fontSize: "11px",
}}>
{testResult.message}
</div>
)}
<p
style={{
fontSize: "12px",
color: "var(--vscode-descriptionForeground)",
margin: 0,
}}>
Enter the DevTools Protocol host address or leave empty to auto-discover Chrome instances.
</p>
</div>
)}
</div>
</div>
)
}
export default BrowserSettingsSection
@@ -11,11 +11,11 @@ import { memo, useCallback, useEffect, useState } from "react"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { validateApiConfiguration, validateModelId } from "../../utils/validate"
import { vscode } from "../../utils/vscode"
import SettingsButton from "../common/SettingsButton"
import ApiOptions from "./ApiOptions"
import { TabButton } from "../mcp/McpView"
import { useEvent } from "react-use"
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
import BrowserSettingsSection from "./BrowserSettingsSection"
const { IS_DEV } = process.env
type SettingsViewProps = {
@@ -32,6 +32,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
telemetrySetting,
setTelemetrySetting,
chatSettings,
remoteBrowserHost,
planActSeparateModelsSetting,
setPlanActSeparateModelsSetting,
} = useExtensionState()
@@ -113,6 +114,24 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setPendingTabChange(null)
}
break
case "scrollToSettings":
setTimeout(() => {
const elementId = message.text
if (elementId) {
const element = document.getElementById(elementId)
if (element) {
element.scrollIntoView({ behavior: "smooth" })
element.style.transition = "background-color 0.5s ease"
element.style.backgroundColor = "var(--vscode-textPreformat-background)"
setTimeout(() => {
element.style.backgroundColor = "transparent"
}, 1200)
}
}
}, 300)
break
}
},
[pendingTabChange],
@@ -279,6 +298,9 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
</p>
</div>
{/* Browser Settings Section */}
<BrowserSettingsSection />
{IS_DEV && (
<>
<div style={{ marginTop: "10px", marginBottom: "4px" }}>Debug</div>
@@ -296,22 +318,6 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
</>
)}
<div
style={{
marginTop: "auto",
paddingRight: 8,
display: "flex",
justifyContent: "center",
}}>
<SettingsButton
onClick={() => vscode.postMessage({ type: "openExtensionSettings" })}
style={{
margin: "0 0 16px 0",
}}>
<i className="codicon codicon-settings-gear" />
Advanced Settings
</SettingsButton>
</div>
<div
style={{
textAlign: "center",
@@ -319,6 +325,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
fontSize: "12px",
lineHeight: "1.2",
padding: "0 8px 15px 0",
marginTop: "auto",
}}>
<p
style={{
@@ -26,6 +26,7 @@ interface ExtensionStateContextType extends ExtensionState {
setTelemetrySetting: (value: TelemetrySetting) => void
setShowAnnouncement: (value: boolean) => void
setPlanActSeparateModelsSetting: (value: boolean) => void
setRemoteBrowserEnabled: (value: boolean) => void
}
const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@@ -188,6 +189,14 @@ export const ExtensionStateContextProvider: React.FC<{
...prevState,
shouldShowAnnouncement: value,
})),
setRemoteBrowserEnabled: (value) =>
setState((prevState) => ({
...prevState,
browserSettings: {
...prevState.browserSettings,
remoteBrowserEnabled: value,
},
})),
}
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>