Compare commits

...

21 Commits

Author SHA1 Message Date
abeatrix 290798ef0f Posthog: add test env config
- Remove redundant "none" case and error logging in TelemetryProviderFactory
- Add test environment configuration for PostHog with disabled API keys
- Enhance environment detection logic to support test and local environments
- Simplify default case handling to always return NoOpTelemetryProvider
2025-09-04 15:05:01 -07:00
celestial-vault 0adb1046e4 fix taskHistory migration (#6004)
* fix taskHistory migration when run across main and the prod version which use the old and the new location

* Update src/core/storage/state-migrations.ts

Co-authored-by: Bee <68532117+abeatrix@users.noreply.github.com>

* Update src/core/storage/state-migrations.ts

Co-authored-by: Bee <68532117+abeatrix@users.noreply.github.com>

* Update src/core/storage/state-migrations.ts

Co-authored-by: Bee <68532117+abeatrix@users.noreply.github.com>

* Update src/core/storage/state-migrations.ts

Co-authored-by: Bee <68532117+abeatrix@users.noreply.github.com>

* Update src/core/storage/state-migrations.ts

Co-authored-by: Bee <68532117+abeatrix@users.noreply.github.com>

* Adding Multi root algo

* Adding Multi root algo

* Simplify the migration function

Simplify the migration function by consolidating conditional logic,
removing redundant logging, and using concurrent operations for
better performance. The logic now handles both empty and populated
destination scenarios more clearly.

* update

---------

Co-authored-by: Ara <arafat.da.khan@gmail.com>
Co-authored-by: Bee <68532117+abeatrix@users.noreply.github.com>
Co-authored-by: abeatrix <beatrix@cline.bot>
2025-09-04 14:45:24 -07:00
Sarah Fortune 324564af72 Don't keep regenerating the telemetry UUID (#6005)
Some environments do not return a value for the vscode machine ID.
For these cases we are generating a UUID. But the UUID was not being stored, so a new one was being created everytime the extension started.

Start storing the generated ID in the global state.

I am changing the name of the key to be more descriptive, because we only want to store the generated ID. If there is an actual machine ID, we should just use that and not store it. The old key name was only ever been read, it was never written, so this will have no affect on existing users.
2025-09-04 12:39:27 -07:00
JicLotus 291287c8e4 chord: Optional gRPC session recording system to improve observability (#5999)
chord: Optional gRPC session recording system to improve observability
2025-09-04 21:26:58 +02:00
Bee d69fb10cfd fix: only focus chat input when in chat view (#5991)
* fix: only focus chat input when in chat view

- Only focus chat input when chat view is visible, not when hidden (in other view)
- Wrap onDone callback in arrow function for consistency
- Replace inline styles with Tailwind classes for button container

* update gap value
2025-09-04 10:39:09 -07:00
celestial-vault 0f6eab2bfe make proto objects not optional by default (#5985) 2025-09-03 19:47:14 -07:00
Saoud Rizwan 016ac1eade Fix diff viewer failing showing incorrect error message (#5988) 2025-09-03 19:17:34 -07:00
Bee 2606ed7abf Remove CI environment check from PostHog config selection (#5987)
Remove process.env.CI check and rely only on IS_DEV flag to determine whether to use development or production PostHog configuration.
This is because process.env.CI is always true during the publish GitHub workflow.
2025-09-03 19:07:32 -07:00
Ara 3ed4d55a36 Add telemetry tracking for terminal hang detection and user intervention (#5955)
* Add telemetry tracking for terminal hang detection and user interventions

- Add terminal hang detection with configurable timeouts for buffer stuck, stream timeout, and completion waiting stages
- Track user interventions when clicking "Process while Running" button
- Add telemetry for terminal output failures with specific failure reasons
- Implement comprehensive monitoring of terminal process lifecycle events
- Add metrics for shell integration usage and terminal operation performance

* Remove Shortcut of Cline from its title

* Remove Shortcut of Cline from its title

* Remove Shortcut of Cline from its title
2025-09-03 16:42:45 -07:00
celestial-vault a3b4ad53dc Add filewatcher to taskHistory (#5910)
* add filewatcher to taskHistory to update across Cline instances

* changeset
2025-09-03 12:08:51 -07:00
celestial-vault bd98955f57 Move updateDefaultTerminalProfile RPC into updateSettings RPC (#5976)
* move browserSettings rpc into updateSettings

* move updateDefaultTerminalProfile rpc into updateSettings rpc
2025-09-03 11:53:56 -07:00
celestial-vault 297cfbb895 move browserSettings rpc into updateSettings (#5959) 2025-09-03 11:40:29 -07:00
canvrno b4c95fbeab Respect user's settings when evaluating feature flag for focus chain (#5969)
Co-authored-by: Kevin Bond <kevin@Mac.hsd1.ca.comcast.net>
2025-09-03 08:32:35 -07:00
JicLotus 5e8102e588 chord: Helper script for testing standalone Core API server (#5944)
* chord: Adding a new standalone core api server script for local and integration testing purposes
2025-09-03 08:54:58 +02:00
Saoud Rizwan 60f2c3a22b Update bug report template: correct model name to "Claude Sonnet 4" and change model section validation from required to optional. 2025-09-02 21:48:11 -07:00
Saoud Rizwan 7472b1cb4a Fix github issue default value 2025-09-02 21:45:57 -07:00
Saoud Rizwan 794d8bccd3 Fix github issue template formatting 2025-09-02 21:45:02 -07:00
Saoud Rizwan 0db3cfe34d Enhance bug report template with additional fields for plugin type and Cline version. Update placeholder text for provider/model input. Remove required validation for logs section. 2025-09-02 21:41:52 -07:00
Nick Baumann 5c878b5b84 Jetbrains docs (early access language tweaks) (#5957)
* docs: Update Claude Code documentation to include Pro plans alongside Max plans

* Add JetBrains installation documentation

- Create comprehensive installation guide for JetBrains IDEs
- Include both marketplace and manual installation methods
- Document supported IDEs and key differences from VSCode
- Add alpha status note and terminal integration limitations
- Update docs.json navigation to include new page

* Add images to JetBrains installation documentation

- Add demo GIF showing Cline in action in JetBrains IDE
- Add screenshot of JetBrains marketplace download page
- Add screenshot of Install Plugin from Disk dialog
- Add screenshot of file selection dialog with zip file
- Complete visual walkthrough of installation process

* Update JetBrains demo to high-quality GIF

- Replace jetbrains-demo.gif with jetbrains-demo-hifi.gif
- Improved visual quality for better user experience

* Add JetBrains settings dialog screenshot

- Add screenshot showing the main settings dialog
- Provides visual guidance for accessing IDE settings
- Complete visual walkthrough now includes 5 screenshots

* Add JetBrains logo and finalize documentation

- Add JetBrains logo at top of page with proper styling
- Update content with user revisions (BYOK note, streamlined structure)
- Complete visual installation walkthrough with 5 images
- Ready for PR review

* Update early access messaging

- Change from 'You're getting early access' to 'Cline is in early access'
- More professional and product-focused messaging
- Maintains excitement while being clearer about the product status

* Update installation link for Cline plugin

---------

Co-authored-by: pashpashpash <nik@cline.bot>
2025-09-02 17:13:38 -07:00
Ara e3bc5f0143 Setup Raw Structure for implementing multi-workspace support with WorkspaceRoot (#5849)
* Setup Raw Structure for implementing multi-workspace support with WorkspaceRoot

* Remove Shortcut of Cline from its title
2025-09-02 16:14:55 -07:00
Igor Tceglevskii f2dbab814e root folder path comparison on windows (#5938) 2025-09-02 14:26:05 -07:00
71 changed files with 2290 additions and 393 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Adding telemetry for terminal command issue
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Sync taskHistory across Cline instances so that it will update for all Cline instances when one instance creates, adds to, or deletes a task.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Adding a grpc recording class with its builder
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix issue where editor panel gets reset to chat view on each chat input focus event
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Adding a new standalone core api server script for local and integration testing purposes
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Setup Raw Structure for implementing multi-workspace support with WorkspaceRoot
+66 -61
View File
@@ -1,64 +1,69 @@
name: 🐛 Bug Report
description: File a bug report
labels: ["bug"]
labels: ['bug']
body:
- type: markdown
attributes:
value: |
**Important:** All bug reports must be reproducible using Claude 4 Sonnet. Cline uses complex prompts so less capable models may not work as expected.
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: Tell us what you see!
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: How do you trigger this bug? Please walk us through it step by step.
value: |
1.
2.
3.
validations:
required: false
- type: textarea
id: logs
attributes:
label: Relevant API REQUEST output
description: Please copy and paste any relevant output. This will be automatically formatted into code, so no need for backticks.
render: shell
- type: input
id: provider-model
attributes:
label: Provider/Model
description: What provider and model were you using when the issue occurred?
placeholder: "e.g., cline:anthropic/claude-3.7-sonnet, gemini:gemini-2.5-pro-exp-03-25"
validations:
required: true
- type: textarea
id: system-info
attributes:
label: System Information
description: What operating system and hardware are you using?
placeholder: |
Operating System: Windows 11, macOS Sonoma, Ubuntu 22.04, etc.
Hardware: CPU, GPU, RAM specifications if relevant
e.g.,
OS: Windows 11
CPU: Intel Core i7-11700K
GPU: NVIDIA GeForce RTX 3070
RAM: 32GB DDR4
validations:
required: true
- type: input
id: cline-version
attributes:
label: Cline Version
description: What version of Cline are you using? (You can find this at the bottom of the Settings view)
placeholder: "e.g., 1.2.3"
validations:
required: true
- type: markdown
attributes:
value: |
**Important:** All bug reports must be reproducible using Claude Sonnet 4. Cline uses complex prompts so less capable models may not work as expected.
- type: dropdown
id: plugin-type
attributes:
label: Plugin Type
description: Which plugin are you reporting a bug for?
options:
- VSCode Extension
- JetBrains Plugin
default: 0
validations:
required: true
- type: input
id: cline-version
attributes:
label: Cline Version
description: What version of Cline are you using? (You can find this at the bottom of the Settings view)
placeholder: 'e.g., 1.2.3'
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: Tell us what you see!
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: How do you trigger this bug? Please walk us through it step by step.
value: |
1.
2.
3.
validations:
required: false
- type: input
id: provider-model
attributes:
label: Provider/Model
description: What provider and model were you using when the issue occurred?
placeholder: 'e.g., cline:anthropic/claude-sonnet-4, gemini:gemini-2.5-pro-exp-03-25'
validations:
required: false
- type: textarea
id: system-info
attributes:
label: System Information
description: What operating system and hardware are you using?
placeholder: |
Operating System: Windows 11, macOS Sonoma, Ubuntu 22.04, etc.
Hardware: CPU, GPU, RAM specifications if relevant
e.g.,
OS: Windows 11
CPU: Intel Core i7-11700K
GPU: NVIDIA GeForce RTX 3070
RAM: 32GB DDR4
validations:
required: false
+51 -7
View File
@@ -91,7 +91,7 @@
{
"type": "node",
"request": "launch",
"name": "Run cline-core service",
"name": "Debug Test Standalone Core Api Server (test:sca-server)",
"skipFiles": [
"<node_internals>/**"
],
@@ -100,18 +100,62 @@
"${workspaceFolder}/**",
"!**/node_modules/**"
],
"cwd": "${workspaceFolder}/dist-standalone",
"cwd": "${workspaceFolder}",
"outFiles": [
"${workspaceFolder}/dist/**/*.js",
"${workspaceFolder}/dist-standalone/**/*.js"
],
"preLaunchTask": "compile-standalone",
"runtimeExecutable": "npx",
"runtimeArgs": [
"tsx"
],
"program": "scripts/test-standalone-core-api-server.ts",
"env": {
// Turns on grpc debug log.
//"GRPC_TRACE": "all",
//"GRPC_VERBOSITY": "DEBUG",
"NODE_PATH": "${workspaceFolder}/dist-standalone/node_modules"
"PROTOBUS_PORT": "26040",
"HOSTBRIDGE_PORT": "26041",
"WORKSPACE_DIR": "${workspaceFolder}",
"E2E_TEST": "true",
"CLINE_ENVIRONMENT": "local"
},
"program": "cline-core.js"
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"type": "node",
"request": "launch",
"name": "Debug Current Test File",
"skipFiles": [
"<node_internals>/**"
],
"sourceMaps": true,
"resolveSourceMapLocations": [
"${workspaceFolder}/**",
"!**/node_modules/**"
],
"cwd": "${workspaceFolder}",
"runtimeExecutable": "npx",
"runtimeArgs": [
"mocha"
],
"args": [
"--require",
"ts-node/register",
"--require",
"source-map-support/register",
"--require",
"./src/test/requires.ts",
"--exit",
"${file}"
],
"env": {
"TS_NODE_PROJECT": "./tsconfig.unit-test.json",
"NODE_ENV": "test",
"IS_DEV": "true",
"CLINE_ENVIRONMENT": "local"
},
"console": "integratedTerminal",
"internalConsoleOptions": "openOnSessionStart"
}
]
}
+1
View File
@@ -148,6 +148,7 @@
"!src/core/storage/FileContextTracker.ts",
"!src/core/context/context-tracking/FileContextTracker.ts",
"!src/common.ts",
"!src/services/logging/distinctId.ts",
"!src/core/storage/utils/state-helpers.ts",
"!src/extension.ts"
],
@@ -1,6 +1,6 @@
---
title: "Installing Cline for JetBrains"
description: "Get Cline running in your favorite JetBrains IDE with the same powerful AI assistance you know from VSCode."
title: "Installing Cline for JetBrains (Early Access)"
description: "Get early access to Cline in your favorite JetBrains IDE with the same powerful AI assistance you know from VSCode."
---
<Frame>
@@ -20,16 +20,16 @@ Cline for JetBrains works almost identically to Cline in VSCode. All the core fe
/>
</Frame>
<Note>Cline for JetBrains is currently in alpha. While all core features are functional, you may encounter occasional issues.</Note>
<Note>Cline for JetBrains is in early access. All core features are functional, with ongoing improvements based on user feedback.</Note>
## Installation
Since Cline for JetBrains is currently in alpha, it's not yet available on the JetBrains Marketplace. You'll need to install it manually from a downloaded file:
As part of our early access program, Cline for JetBrains is available through direct download before its official marketplace release. You'll need to install it manually from a downloaded file:
### Manual Installation from Disk
1. **Download the Plugin:**
- Go to [https://plugins.jetbrains.com/plugin/28247-cline/versions/stable](https://plugins.jetbrains.com/plugin/28247-cline/versions/stable)
- Go to [https://plugins.jetbrains.com/plugin/28247-cline/versions/eap](https://plugins.jetbrains.com/plugin/28247-cline/versions/eap)
- Click **Download** to get the `.zip` file
<Frame>
+1
View File
@@ -355,6 +355,7 @@
"test:integration": "vscode-test",
"test:unit": "TS_NODE_PROJECT='./tsconfig.unit-test.json' mocha",
"test:coverage": "vscode-test --coverage",
"test:sca-server": "npx tsx watch scripts/test-standalone-core-api-server.ts",
"e2e": "playwright test -c playwright.config.ts",
"test:e2e": "playwright install && vsce package --no-dependencies --allow-package-secrets sendgrid --out dist/e2e.vsix && node src/test/e2e/utils/build.mjs && playwright test",
"test:e2e:optimal": "vsce package --no-dependencies --allow-package-secrets sendgrid --out dist/e2e.vsix && node src/test/e2e/utils/build.mjs && playwright test",
+1 -6
View File
@@ -2,6 +2,7 @@ syntax = "proto3";
package cline;
import "cline/common.proto";
import "cline/state.proto";
option java_package = "bot.cline.proto";
option java_multiple_files = true;
@@ -10,7 +11,6 @@ service BrowserService {
rpc testBrowserConnection(StringRequest) returns (BrowserConnection);
rpc discoverBrowser(EmptyRequest) returns (BrowserConnection);
rpc getDetectedChromePath(EmptyRequest) returns (ChromePath);
rpc updateBrowserSettings(UpdateBrowserSettingsRequest) returns (Boolean);
rpc relaunchChromeDebugMode(EmptyRequest) returns (String);
}
@@ -31,11 +31,6 @@ message ChromePath {
bool is_bundled = 2;
}
message Viewport {
int32 width = 1;
int32 height = 2;
}
message BrowserSettings {
Viewport viewport = 1;
optional string remote_browser_host = 2;
-6
View File
@@ -8,19 +8,16 @@ message Metadata {
}
message EmptyRequest {
Metadata metadata = 1;
}
message Empty {
}
message StringRequest {
Metadata metadata = 1;
string value = 2;
}
message StringArrayRequest {
Metadata metadata = 1;
repeated string value = 2;
}
@@ -29,7 +26,6 @@ message String {
}
message Int64Request {
Metadata metadata = 1;
int64 value = 2;
}
@@ -38,7 +34,6 @@ message Int64 {
}
message BytesRequest {
Metadata metadata = 1;
bytes value = 2;
}
@@ -47,7 +42,6 @@ message Bytes {
}
message BooleanRequest {
Metadata metadata = 1;
bool value = 2;
}
+17 -1
View File
@@ -8,7 +8,6 @@ service StateService {
rpc getLatestState(EmptyRequest) returns (State);
rpc updateTerminalConnectionTimeout(UpdateTerminalConnectionTimeoutRequest) returns (UpdateTerminalConnectionTimeoutResponse);
rpc updateTerminalReuseEnabled(BooleanRequest) returns (Empty);
rpc updateDefaultTerminalProfile(StringRequest) returns (TerminalProfileUpdateResponse);
rpc getAvailableTerminalProfiles(EmptyRequest) returns (TerminalProfiles);
rpc subscribeToState(EmptyRequest) returns (stream State);
rpc toggleFavoriteModel(StringRequest) returns (Empty);
@@ -106,6 +105,16 @@ message TelemetrySettingRequest {
TelemetrySettingEnum setting = 2;
}
// Browser settings for UpdateSettingsRequest
message BrowserSettingsUpdate {
optional Viewport viewport = 1;
optional string remote_browser_host = 2;
optional bool remote_browser_enabled = 3;
optional string chrome_executable_path = 4;
optional bool disable_tool_use = 5;
optional string custom_args = 6;
}
// Message for updating settings
message UpdateSettingsRequest {
Metadata metadata = 1;
@@ -126,6 +135,8 @@ message UpdateSettingsRequest {
optional FocusChainSettings focus_chain_settings = 17;
optional bool use_auto_condense = 18;
optional string custom_prompt = 19;
optional BrowserSettingsUpdate browser_settings = 20;
optional string default_terminal_profile = 21;
}
// Complete API Configuration message
@@ -268,6 +279,11 @@ message FocusChainSettings {
int32 remind_cline_interval = 2;
}
message Viewport {
int32 width = 1;
int32 height = 2;
}
message UpdateTerminalConnectionTimeoutResponse {
optional int32 timeout_ms = 1;
}
-4
View File
@@ -4,8 +4,6 @@ package host;
option java_package = "bot.cline.host.proto";
option java_multiple_files = true;
import "cline/common.proto";
// Provides methods for working with IDE windows and editors.
service WindowService {
// Opens a text document in the IDE editor and returns editor information.
@@ -40,7 +38,6 @@ service WindowService {
}
message ShowTextDocumentRequest {
cline.Metadata metadata = 1;
string path = 2;
optional ShowTextDocumentOptions options = 3;
}
@@ -59,7 +56,6 @@ message TextEditorInfo {
}
message ShowOpenDialogueRequest {
cline.Metadata metadata = 1;
optional bool can_select_many = 2;
optional string open_label = 3;
optional ShowOpenDialogueFilterOption filters = 4;
+1 -1
View File
@@ -31,7 +31,7 @@ const TS_PROTO_OPTIONS = [
"esModuleInterop=true",
"outputServices=generic-definitions", // output generic ServiceDefinitions
"outputIndex=true", // output an index file for each package which exports all protos in the package.
"useOptionals=messages", // Message fields are optional, scalars are not.
"useOptionals=none", // scalar and message fields are required unless they are marked as optional.
"useDate=false", // Timestamp fields will not be automatically converted to Date.
]
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env npx tsx
/**
* Simple Cline gRPC Server
*
* This script provides a minimal way to run the Cline core gRPC service
* without requiring the full installation, while automatically mocking all external services. Simply run:
*
* # One-time setup (generates protobuf files)
* npm run compile-standalone
* npm run test:sca-server
*
* The following components are started automatically:
* 1. HostBridge test server
* 2. ClineApiServerMock (mock implementation of the Cline API)
* 3. AuthServiceMock (activated if E2E_TEST="true")
*
* Environment Variables for Customization:
* PROJECT_ROOT - Override project root directory (default: parent of scripts dir)
* CLINE_DIST_DIR - Override distribution directory (default: PROJECT_ROOT/dist-standalone)
* CLINE_CORE_FILE - Override core file name (default: cline-core.js)
* PROTOBUS_PORT - gRPC server port (default: 26040)
* HOSTBRIDGE_PORT - HostBridge server port (default: 26041)
* WORKSPACE_DIR - Working directory (default: current directory)
* E2E_TEST - Enable E2E test mode (default: true)
* CLINE_ENVIRONMENT - Environment setting (default: local)
*
* Ideal for local development, testing, or lightweight E2E scenarios.
*/
import { ChildProcess, spawn } from "child_process"
import * as fs from "fs"
import * as path from "path"
import { ClineApiServerMock } from "../src/test/e2e/fixtures/server/index"
// Configuration
const PROTOBUS_PORT = process.env.PROTOBUS_PORT || "26040"
const HOSTBRIDGE_PORT = process.env.HOSTBRIDGE_PORT || "26041"
const WORKSPACE_DIR = process.env.WORKSPACE_DIR || process.cwd()
const E2E_TEST = process.env.E2E_TEST || "true"
const CLINE_ENVIRONMENT = process.env.CLINE_ENVIRONMENT || "local"
// Locate the standalone build directory and core file with flexible path resolution
const projectRoot = process.env.PROJECT_ROOT || path.resolve(__dirname, "..")
const distDir = process.env.CLINE_DIST_DIR || path.join(projectRoot, "dist-standalone")
const clineCoreFile = process.env.CLINE_CORE_FILE || "cline-core.js"
const coreFile = path.join(distDir, clineCoreFile)
async function main(): Promise<void> {
console.log("Starting Simple Cline gRPC Server...")
console.log(`Workspace: ${WORKSPACE_DIR}`)
console.log(`ProtoBus Port: ${PROTOBUS_PORT}`)
console.log(`HostBridge Port: ${HOSTBRIDGE_PORT}`)
console.log(`Looking for standalone build at: ${coreFile}`)
if (!fs.existsSync(coreFile)) {
console.error(`Standalone build not found at: ${coreFile}`)
console.error("Available environment variables for customization:")
console.error(" PROJECT_ROOT - Override project root directory")
console.error(" CLINE_DIST_DIR - Override distribution directory")
console.error(" CLINE_CORE_FILE - Override core file name")
console.error("")
console.error("To build the standalone version, run: npm run compile-standalone")
process.exit(1)
}
try {
const apiServer = await ClineApiServerMock.startGlobalServer()
console.log("Cline API Server started in-process")
} catch (error) {
console.error("Failed to start Cline API Server:", error)
process.exit(1)
}
// Start hostbridge test server in background.
// We run it as a child process to emulate how the extension currently operates
console.log("Starting HostBridge test server...")
const hostbridge: ChildProcess = spawn("npx", ["tsx", path.join(__dirname, "test-hostbridge-server.ts")], {
stdio: "pipe",
detached: false,
})
// Start the core service
// We run it as a child process to emulate how the extension currently operates
console.log("Starting Cline Core Service...")
const coreService: ChildProcess = spawn("node", [clineCoreFile], {
cwd: distDir,
env: {
...process.env,
NODE_PATH: "./node_modules",
DEV_WORKSPACE_FOLDER: WORKSPACE_DIR,
PROTOBUS_ADDRESS: `127.0.0.1:${PROTOBUS_PORT}`,
HOST_BRIDGE_ADDRESS: `localhost:${HOSTBRIDGE_PORT}`,
E2E_TEST: E2E_TEST,
CLINE_ENVIRONMENT: CLINE_ENVIRONMENT,
},
stdio: "inherit",
})
// Handle graceful shutdown
const shutdown = async (): Promise<void> => {
console.log("\n Shutting down services...")
hostbridge.kill()
coreService.kill()
await ClineApiServerMock.stopGlobalServer()
process.exit(0)
}
process.on("SIGINT", shutdown)
process.on("SIGTERM", shutdown)
coreService.on("exit", (code) => {
console.log(`Core service exited with code ${code}`)
hostbridge.kill()
process.exit(code || 0)
})
hostbridge.on("exit", (code) => {
console.log(`HostBridge exited with code ${code}`)
coreService.kill()
process.exit(code || 0)
})
console.log("Cline gRPC Server is running!")
console.log(`Connect to: 127.0.0.1:${PROTOBUS_PORT}`)
console.log("Press Ctrl+C to stop")
}
if (require.main === module) {
main().catch((error) => {
console.error("Failed to start simple Cline server:", error)
process.exit(1)
})
}
@@ -1,62 +0,0 @@
import { UpdateBrowserSettingsRequest } from "@shared/proto/cline/browser"
import { Boolean } from "@shared/proto/cline/common"
import { DEFAULT_BROWSER_SETTINGS, BrowserSettings as SharedBrowserSettings } from "../../../shared/BrowserSettings"
import { Controller } from "../index"
/**
* Update browser settings
* @param controller The controller instance
* @param request The browser settings request message
* @returns Success response
*/
export async function updateBrowserSettings(controller: Controller, request: UpdateBrowserSettingsRequest): Promise<Boolean> {
try {
// Get current browser settings to preserve fields not in the request
const currentSettings = controller.stateManager.getGlobalStateKey("browserSettings")
const mergedWithDefaults = { ...DEFAULT_BROWSER_SETTINGS, ...currentSettings }
// Convert from protobuf format to shared format, merging with existing settings
const newBrowserSettings: SharedBrowserSettings = {
...mergedWithDefaults, // Start with existing settings (and defaults)
viewport: {
// Apply updates from request
width: request.viewport?.width || mergedWithDefaults.viewport.width,
height: request.viewport?.height || mergedWithDefaults.viewport.height,
},
// Explicitly handle optional boolean and string fields from the request
remoteBrowserEnabled:
request.remoteBrowserEnabled === undefined
? mergedWithDefaults.remoteBrowserEnabled
: request.remoteBrowserEnabled,
remoteBrowserHost:
request.remoteBrowserHost === undefined ? mergedWithDefaults.remoteBrowserHost : request.remoteBrowserHost,
chromeExecutablePath:
// If chromeExecutablePath is explicitly in the request (even as ""), use it.
// Otherwise, fall back to mergedWithDefaults.
"chromeExecutablePath" in request ? request.chromeExecutablePath : mergedWithDefaults.chromeExecutablePath,
disableToolUse: request.disableToolUse === undefined ? mergedWithDefaults.disableToolUse : request.disableToolUse,
customArgs: "customArgs" in request ? request.customArgs : mergedWithDefaults.customArgs,
}
// Update global state with new settings
controller.stateManager.setGlobalState("browserSettings", newBrowserSettings)
// Update task browser settings if task exists
if (controller.task) {
controller.task.browserSettings = newBrowserSettings
controller.task.browserSession.browserSettings = newBrowserSettings
}
// Post updated state to webview
await controller.postStateToWebview()
return Boolean.create({
value: true,
})
} catch (error) {
console.error("Error updating browser settings:", error)
return Boolean.create({
value: false,
})
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { refreshClineRulesToggles } from "@core/context/instructions/user-instructions/cline-rules"
import { createRuleFile as createRuleFileImpl } from "@core/context/instructions/user-instructions/rule-helpers"
import { getWorkspaceBasename } from "@core/workspace"
import { RuleFile, RuleFileRequest } from "@shared/proto/cline/file"
import * as path from "path"
import { refreshWorkflowToggles } from "@/core/context/instructions/user-instructions/workflows"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/host/window"
@@ -68,7 +68,7 @@ export async function createRuleFile(controller: Controller, request: RuleFileRe
return RuleFile.create({
filePath: filePath,
displayName: path.basename(filePath),
displayName: getWorkspaceBasename(filePath, "Controller.createRuleFile"),
alreadyExists: fileExists,
})
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { deleteRuleFile as deleteRuleFileImpl } from "@core/context/instructions/user-instructions/rule-helpers"
import { getWorkspaceBasename } from "@core/workspace"
import { RuleFile, RuleFileRequest } from "@shared/proto/cline/file"
import * as path from "path"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/host/window"
import { Controller } from ".."
@@ -40,7 +40,7 @@ export async function deleteRuleFile(controller: Controller, request: RuleFileRe
//await refreshWorkflowToggles(controller.context, cwd)
await controller.postStateToWebview()
const fileName = path.basename(request.rulePath)
const fileName = getWorkspaceBasename(request.rulePath, "Controller.deleteRuleFile")
const fileTypeName = request.type === "workflow" ? "workflow" : "rule"
@@ -1,7 +1,7 @@
import { workspaceResolver } from "@core/workspace"
import { BooleanResponse, StringRequest } from "@shared/proto/cline/common"
import { getWorkspacePath } from "@utils/path"
import * as fs from "fs"
import * as path from "path"
import { Controller } from ".."
/**
@@ -25,7 +25,12 @@ export async function ifFileExistsRelativePath(_controller: Controller, request:
}
// Resolve the relative path to absolute path
const absolutePath = path.resolve(workspacePath, request.value)
const resolvedPath = workspaceResolver.resolveWorkspacePath(
workspacePath,
request.value,
"Controller.ifFileExistsRelativePath",
)
const absolutePath = typeof resolvedPath === "string" ? resolvedPath : resolvedPath.absolutePath
// Check if the file exists
try {
return BooleanResponse.create({ value: fs.statSync(absolutePath).isFile() })
@@ -1,7 +1,7 @@
import { workspaceResolver } from "@core/workspace"
import { openFile as openFileIntegration } from "@integrations/misc/open-file"
import { Empty, StringRequest } from "@shared/proto/cline/common"
import { getWorkspacePath } from "@utils/path"
import * as path from "path"
import { Controller } from ".."
/**
@@ -20,7 +20,12 @@ export async function openFileRelativePath(_controller: Controller, request: Str
if (request.value) {
// Resolve the relative path to absolute path
const absolutePath = path.resolve(workspacePath, request.value)
const resolvedPath = workspaceResolver.resolveWorkspacePath(
workspacePath,
request.value,
"Controller.openFileRelativePath",
)
const absolutePath = typeof resolvedPath === "string" ? resolvedPath : resolvedPath.absolutePath
// Open the file using the existing integration
openFileIntegration(absolutePath)
+2 -9
View File
@@ -1,4 +1,4 @@
import path from "node:path"
import { getWorkspaceBasename } from "@core/workspace"
import type { ToggleClineRuleRequest } from "@shared/proto/cline/file"
import { ToggleClineRules } from "@shared/proto/cline/file"
import { telemetryService } from "@/services/telemetry"
@@ -36,14 +36,7 @@ export async function toggleClineRule(controller: Controller, request: ToggleCli
// Track rule toggle telemetry with current task context
if (controller.task?.ulid) {
// Extract just the filename for privacy (no full paths)
const ruleFileName = path.basename(rulePath)
telemetryService.captureClineRuleToggled(controller.task.ulid, ruleFileName, enabled, isGlobal)
}
// Track rule toggle telemetry with current task context
if (controller.task?.ulid) {
// Extract just the filename for privacy (no full paths)
const ruleFileName = path.basename(rulePath)
const ruleFileName = getWorkspaceBasename(rulePath, "Controller.toggleClineRule")
telemetryService.captureClineRuleToggled(controller.task.ulid, ruleFileName, enabled, isGlobal)
}
@@ -0,0 +1,33 @@
import { GrpcRecorder, GrpcRecorderNoops, IRecorder } from "@/core/controller/grpc-recorder/grpc-recorder"
import { LogFileHandler, LogFileHandlerNoops } from "@/core/controller/grpc-recorder/log-file-handler"
/**
* A builder class for constructing a gRPC recorder instance.
*
* This class follows the Builder pattern, allowing consumers
* to configure logging behavior and control whether recording
* is enabled or disabled before creating a final `IRecorder`.
*/
export class GrpcRecorderBuilder {
private fileHandler: LogFileHandler | null = null
private enabled: boolean = true
public withLogFileHandler(handler: LogFileHandler): this {
this.fileHandler = handler
return this
}
public enableIf(condition: boolean): this {
this.enabled = condition
return this
}
public build(): IRecorder {
if (!this.enabled) {
return new GrpcRecorderNoops()
}
const handler = this.fileHandler ?? new LogFileHandlerNoops()
return new GrpcRecorder(handler)
}
}
@@ -0,0 +1,106 @@
import { GrpcRecorder, IRecorder } from "@core/controller/grpc-recorder/grpc-recorder"
import { expect } from "chai"
import { ExtensionMessage } from "@/shared/ExtensionMessage"
import { GrpcRequest } from "@/shared/WebviewMessage"
describe("grpc-recorder", () => {
let recorder: IRecorder
before(async () => {
recorder = GrpcRecorder.builder().enableIf(true).build()
})
describe("GrpcRecorder", () => {
it("matches multiple request, response and stats", async () => {
interface UseCase {
request: GrpcRequest
response: ExtensionMessage["grpc_response"]
expectedStatus: string
}
const requestResponseUseCases: UseCase[] = [
{
request: {
service: "the-service",
method: "the-method",
message: "the-message",
request_id: "request-id-1",
is_streaming: false,
},
response: {
request_id: "request-id-1",
message: "the-message-response",
error: "",
},
expectedStatus: "completed",
},
{
request: {
service: "streaming-service",
method: "stream-method",
message: { data: "streaming-data", count: 42 },
request_id: "request-id-2",
is_streaming: true,
},
response: {
request_id: "request-id-2",
message: { streamData: "chunk-1" },
error: "",
is_streaming: true,
sequence_number: 1,
},
expectedStatus: "completed",
},
{
request: {
service: "another-service",
method: "another-method",
message: { complex: { nested: "object", array: [1, 2, 3] } },
request_id: "request-id-3",
is_streaming: false,
},
response: {
request_id: "request-id-3",
message: "",
error: "Something went wrong",
},
expectedStatus: "error",
},
]
const initialExpectedStatus = "pending"
requestResponseUseCases.forEach((us: UseCase, index: number) => {
recorder.recordRequest(us.request)
let sessionLog = recorder.getSessionLog()
expect(sessionLog.entries).length(index + 1)
expect(sessionLog.entries[index]).to.include({
service: us.request.service,
method: us.request.method,
isStreaming: us.request.is_streaming,
requestId: us.request.request_id,
status: initialExpectedStatus,
})
if (us.response) {
recorder.recordResponse(us.request.request_id, us.response)
}
sessionLog = recorder.getSessionLog()
expect(sessionLog.entries[index].status).equal(us.expectedStatus)
expect(sessionLog.entries[index].response).to.deep.include({
error: us.response?.error,
})
})
const sessionLog = recorder.getSessionLog()
expect(sessionLog.stats).to.include({
totalRequests: 3,
pendingRequests: 0,
completedRequests: 2,
errorRequests: 1,
})
})
})
})
@@ -0,0 +1,179 @@
import { GrpcResponse } from "@shared/ExtensionMessage"
import { GrpcRequest } from "@shared/WebviewMessage"
import { GrpcRecorderBuilder } from "@/core/controller/grpc-recorder/grpc-recorder.builder"
import { ILogFileHandler } from "@/core/controller/grpc-recorder/log-file-handler"
import { GrpcLogEntry, GrpcSessionLog, SessionStats } from "@/core/controller/grpc-recorder/types"
export class GrpcRecorderNoops implements IRecorder {
recordRequest(_request: GrpcRequest): void {}
recordResponse(_requestId: string, _response: GrpcResponse): void {}
recordError(_requestId: string, _error: string): void {}
getSessionLog(): GrpcSessionLog {
return {
startTime: "",
entries: [],
}
}
}
export interface IRecorder {
recordRequest(request: GrpcRequest): void
recordResponse(requestId: string, response: GrpcResponse): void
recordError(requestId: string, error: string): void
getSessionLog(): GrpcSessionLog
}
/**
* Default implementation of a gRPC recorder.
*
* Responsibilities:
* - Records requests, responses, and errors.
* - Tracks request/response lifecycle, including duration and status.
* - Maintains a session log of all recorded entries.
* - Persists logs asynchronously through a file handler.
*/
export class GrpcRecorder implements IRecorder {
private sessionLog: GrpcSessionLog
private pendingRequests: Map<string, { entry: GrpcLogEntry; startTime: number }> = new Map()
constructor(private fileHandler: ILogFileHandler) {
this.sessionLog = {
startTime: new Date().toISOString(),
entries: [],
}
this.fileHandler.initialize(this.sessionLog).catch((error) => {
console.error("Failed to initialize gRPC log file:", error)
})
}
public static builder(): GrpcRecorderBuilder {
return new GrpcRecorderBuilder()
}
/**
* Records a gRPC request.
*
* - Stores the request as a "pending" log entry.
* - Tracks the request start time for later duration calculation.
* - Persists the log asynchronously.
*
* @param request - The incoming gRPC request.
*/
public recordRequest(request: GrpcRequest): void {
const entry: GrpcLogEntry = {
requestId: request.request_id,
service: request.service,
method: request.method,
isStreaming: request.is_streaming || false,
request: {
message: request.message,
},
status: "pending",
}
this.pendingRequests.set(request.request_id, {
entry,
startTime: Date.now(),
})
this.sessionLog.entries.push(entry)
this.flushLogAsync()
}
public getSessionLog(): GrpcSessionLog {
return this.sessionLog
}
/**
* Records a gRPC response for a given request.
*
* - Looks up the pending request entry.
* - Updates the entry with response data, status, and duration.
* - Removes the request from pending if it's not streaming.
* - Recomputes session stats.
* - Persists the log asynchronously.
*
* @param requestId - The ID of the request being responded to.
* @param response - The corresponding gRPC response.
*/
public recordResponse(requestId: string, response: GrpcResponse): void {
const pendingRequest = this.pendingRequests.get(requestId)
if (!pendingRequest) {
console.warn(`No pending request found for response with ID: ${requestId}`)
return
}
const { entry, startTime } = pendingRequest
entry.response = {
message: response?.message ? response.message : undefined,
error: response?.error,
isStreaming: response?.is_streaming,
sequenceNumber: response?.sequence_number,
}
entry.duration = Date.now() - startTime
entry.status = response?.error ? "error" : "completed"
if (!response?.is_streaming) {
this.pendingRequests.delete(requestId)
}
this.sessionLog.stats = this.getStats()
this.flushLogAsync()
}
/**
* Records an error for a given request.
*
* - Marks the request as failed.
* - Records the error message and request duration.
* - Removes it from the pending requests.
* - Persists the log asynchronously.
*
* @param requestId - The ID of the request that errored.
* @param error - Error message.
*/
public recordError(requestId: string, error: string): void {
const pendingRequest = this.pendingRequests.get(requestId)
if (!pendingRequest) {
console.warn(`No pending request found for error with ID: ${requestId}`)
return
}
const { entry, startTime } = pendingRequest
entry.response = {
error: error,
}
entry.duration = Date.now() - startTime
entry.status = "error"
this.pendingRequests.delete(requestId)
this.flushLogAsync()
}
private flushLogAsync(): void {
setImmediate(() => {
this.fileHandler.write(this.sessionLog).catch((error) => {
console.error("Failed to flush gRPC log:", error)
})
})
}
public getStats(): SessionStats {
const totalRequests = this.sessionLog.entries.length
const pendingRequests = this.sessionLog.entries.filter((e) => e.status === "pending").length
const completedRequests = this.sessionLog.entries.filter((e) => e.status === "completed").length
const errorRequests = this.sessionLog.entries.filter((e) => e.status === "error").length
return {
totalRequests,
pendingRequests,
completedRequests,
errorRequests,
}
}
}
@@ -0,0 +1,19 @@
import { expect } from "chai"
import { before, describe, it } from "mocha"
import { LogFileHandler } from "@/core/controller/grpc-recorder/log-file-handler"
describe("log-file-handler", () => {
let logHandler: LogFileHandler
before(async () => {
logHandler = new LogFileHandler()
expect(logHandler.getFilePath()).not.empty
})
describe("LogFileHandler", () => {
it("returns file name with timestamp when env var not set", () => {
const result = logHandler.getFileName()
expect(result).to.contains("grpc_recorded_session")
})
})
})
@@ -0,0 +1,57 @@
import { writeFile } from "@utils/fs"
import fs from "fs/promises"
import * as path from "path"
import { GrpcSessionLog } from "@/core/controller/grpc-recorder/types"
const LOG_FILE_PREFIX = "grpc_recorded_session"
export class LogFileHandlerNoops implements ILogFileHandler {
async initialize(_initialData: GrpcSessionLog): Promise<void> {}
async write(_sessionLog: GrpcSessionLog): Promise<void> {}
}
export interface ILogFileHandler {
initialize(initialData: GrpcSessionLog): Promise<void>
write(sessionLog: GrpcSessionLog): Promise<void>
}
/**
* Default implementation of `ILogFileHandler` that persists logs to disk.
*
* - Creates a log file inside the workspace `tests/specs` folder.
* - Uses a timestamped filename by default, unless overridden by an env var.
* - Saves logs in JSON format.
*/
export class LogFileHandler implements ILogFileHandler {
private logFilePath: string
constructor() {
const fileName = this.getFileName()
const workspaceFolder = process.env.DEV_WORKSPACE_FOLDER ?? process.cwd()
const folderPath = path.join(workspaceFolder, "tests", "specs")
this.logFilePath = path.join(folderPath, fileName)
}
public getFilePath(): string {
return this.logFilePath
}
public getFileName(): string {
const envFileName = path.basename(process.env.GRPC_RECORDER_FILE_NAME || "").replace(/[^a-zA-Z0-9-_]/g, "_")
if (envFileName && envFileName.trim().length > 0) {
return `${LOG_FILE_PREFIX}_${envFileName}.json`
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
return `${LOG_FILE_PREFIX}_${timestamp}.json`
}
public async initialize(initialData: GrpcSessionLog): Promise<void> {
await fs.mkdir(path.dirname(this.logFilePath), { recursive: true })
await writeFile(this.logFilePath, JSON.stringify(initialData, null, 2), "utf8")
}
public async write(sessionLog: GrpcSessionLog): Promise<void> {
await writeFile(this.logFilePath, JSON.stringify(sessionLog, null, 2), "utf8")
}
}
@@ -0,0 +1,30 @@
export interface GrpcLogEntry {
requestId: string
service: string
method: string
isStreaming: boolean
request: {
message: any
}
response?: {
message?: any
error?: string
isStreaming?: boolean
sequenceNumber?: number
}
duration?: number
status: "pending" | "completed" | "error"
}
export interface SessionStats {
totalRequests: number
pendingRequests: number
completedRequests: number
errorRequests: number
}
export interface GrpcSessionLog {
startTime: string
stats?: SessionStats
entries: GrpcLogEntry[]
}
+15 -2
View File
@@ -91,6 +91,10 @@ export class Controller {
}
}
this.stateManager.onSyncExternalChange = async () => {
await this.postStateToWebview()
}
this.mcpHub = new McpHub(
() => ensureMcpServersDirectoryExists(),
() => ensureSettingsDirectoryExists(this.context),
@@ -195,10 +199,19 @@ export class Controller {
}
this.stateManager.setGlobalState("autoApprovalSettings", updatedAutoApprovalSettings)
}
// Apply remote feature flag gate to focus chain settings
// Apply remote feature flag gate to focus chain settings. Respect if user has disabled it.
let focusChainEnabled: boolean
if (focusChainSettings?.enabled === false) {
focusChainEnabled = false
} else if (focusChainFeatureFlagEnabled === false) {
focusChainEnabled = false
} else {
focusChainEnabled = Boolean(focusChainSettings?.enabled)
}
const effectiveFocusChainSettings = {
...(focusChainSettings || { enabled: true, remindClineInterval: 6 }),
enabled: Boolean(focusChainSettings?.enabled) && Boolean(focusChainFeatureFlagEnabled),
enabled: focusChainEnabled,
}
this.task = new Task(
@@ -1,55 +0,0 @@
import { HostProvider } from "@/hosts/host-provider"
import { TerminalInfo } from "@/integrations/terminal/TerminalRegistry"
import * as proto from "@/shared/proto"
import { ShowMessageType } from "@/shared/proto/host/window"
import { Controller } from "../index"
export async function updateDefaultTerminalProfile(
controller: Controller,
request: proto.cline.StringRequest,
): Promise<proto.cline.TerminalProfileUpdateResponse> {
const profileId = request.value
// Update the terminal profile in the state
controller.stateManager.setGlobalState("defaultTerminalProfile", profileId)
let closedCount = 0
let busyTerminals: TerminalInfo[] = []
// Update the terminal manager of the current task if it exists
if (controller.task) {
// Call the updated setDefaultTerminalProfile method that returns closed terminal info
const result = controller.task.terminalManager.setDefaultTerminalProfile(profileId)
closedCount = result.closedCount
busyTerminals = result.busyTerminals
// Show information message if terminals were closed
if (closedCount > 0) {
const message = `Closed ${closedCount} ${closedCount === 1 ? "terminal" : "terminals"} with different profile.`
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message,
})
}
// Show warning if there are busy terminals that couldn't be closed
if (busyTerminals.length > 0) {
const message =
`${busyTerminals.length} busy ${busyTerminals.length === 1 ? "terminal has" : "terminals have"} a different profile. ` +
`Close ${busyTerminals.length === 1 ? "it" : "them"} to use the new profile for all commands.`
HostProvider.window.showMessage({
type: ShowMessageType.WARNING,
message,
})
}
}
// Broadcast state update to all webviews
await controller.postStateToWebview()
return proto.cline.TerminalProfileUpdateResponse.create({
closedCount,
busyTerminalsCount: busyTerminals.length,
hasBusyTerminals: busyTerminals.length > 0,
})
}
@@ -9,8 +9,12 @@ import {
import { convertProtoApiConfigurationToApiConfiguration } from "@shared/proto-conversions/state/settings-conversion"
import { OpenaiReasoningEffort } from "@shared/storage/types"
import { TelemetrySetting } from "@shared/TelemetrySetting"
import { HostProvider } from "@/hosts/host-provider"
import { TerminalInfo } from "@/integrations/terminal/TerminalRegistry"
import { McpDisplayMode } from "@/shared/McpDisplayMode"
import { ShowMessageType } from "@/shared/proto/host/window"
import { telemetryService } from "../../../services/telemetry"
import { BrowserSettings as SharedBrowserSettings } from "../../../shared/BrowserSettings"
import { Controller } from ".."
/**
@@ -176,6 +180,91 @@ export async function updateSettings(controller: Controller, request: UpdateSett
controller.stateManager.setGlobalState("customPrompt", value)
}
// Update browser settings
if (request.browserSettings !== undefined) {
// Get current browser settings to preserve fields not in the request
const currentSettings = controller.stateManager.getGlobalStateKey("browserSettings")
// Convert from protobuf format to shared format, merging with existing settings
const newBrowserSettings: SharedBrowserSettings = {
...currentSettings, // Start with existing settings (and defaults)
viewport: {
// Apply updates from request
width: request.browserSettings.viewport?.width || currentSettings.viewport.width,
height: request.browserSettings.viewport?.height || currentSettings.viewport.height,
},
// Explicitly handle optional boolean and string fields from the request
remoteBrowserEnabled:
request.browserSettings.remoteBrowserEnabled === undefined
? currentSettings.remoteBrowserEnabled
: request.browserSettings.remoteBrowserEnabled,
remoteBrowserHost:
request.browserSettings.remoteBrowserHost === undefined
? currentSettings.remoteBrowserHost
: request.browserSettings.remoteBrowserHost,
chromeExecutablePath:
// If chromeExecutablePath is explicitly in the request (even as ""), use it.
// Otherwise, fall back to mergedWithDefaults.
"chromeExecutablePath" in request.browserSettings
? request.browserSettings.chromeExecutablePath
: currentSettings.chromeExecutablePath,
disableToolUse:
request.browserSettings.disableToolUse === undefined
? currentSettings.disableToolUse
: request.browserSettings.disableToolUse,
customArgs:
"customArgs" in request.browserSettings ? request.browserSettings.customArgs : currentSettings.customArgs,
}
// Update global state with new settings
controller.stateManager.setGlobalState("browserSettings", newBrowserSettings)
// Update task browser settings if task exists
if (controller.task) {
controller.task.browserSettings = newBrowserSettings
controller.task.browserSession.browserSettings = newBrowserSettings
}
}
// Update default terminal profile
if (request.defaultTerminalProfile !== undefined) {
const profileId = request.defaultTerminalProfile
// Update the terminal profile in the state
controller.stateManager.setGlobalState("defaultTerminalProfile", profileId)
let closedCount = 0
let busyTerminals: TerminalInfo[] = []
// Update the terminal manager of the current task if it exists
if (controller.task) {
// Call the updated setDefaultTerminalProfile method that returns closed terminal info
const result = controller.task.terminalManager.setDefaultTerminalProfile(profileId)
closedCount = result.closedCount
busyTerminals = result.busyTerminals
// Show information message if terminals were closed
if (closedCount > 0) {
const message = `Closed ${closedCount} ${closedCount === 1 ? "terminal" : "terminals"} with different profile.`
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message,
})
}
// Show warning if there are busy terminals that couldn't be closed
if (busyTerminals.length > 0) {
const message =
`${busyTerminals.length} busy ${busyTerminals.length === 1 ? "terminal has" : "terminals have"} a different profile. ` +
`Close ${busyTerminals.length === 1 ? "it" : "them"} to use the new profile for all commands.`
HostProvider.window.showMessage({
type: ShowMessageType.WARNING,
message,
})
}
}
}
// Post updated state to webview
await controller.postStateToWebview()
@@ -138,7 +138,11 @@ async function cleanupTaskFiles(controller: Controller, preserveTaskIds: string[
// Delete only non-preserved task directories
for (const dir of taskDirs) {
if (!preserveTaskIds.includes(dir)) {
await fs.rm(path.join(taskDirPath, dir), { recursive: true, force: true })
// Task dir path is not workspace specific
await fs.rm(path.join(taskDirPath, dir), {
recursive: true,
force: true,
})
}
}
}
+64 -1
View File
@@ -1,6 +1,7 @@
import { ApiConfiguration, fireworksDefaultModelId } from "@shared/api"
import chokidar, { FSWatcher } from "chokidar"
import type { ExtensionContext } from "vscode"
import { writeTaskHistoryToState } from "./disk"
import { getTaskHistoryStateFilePath, readTaskHistoryFromState, writeTaskHistoryToState } from "./disk"
import { STATE_MANAGER_NOT_INITIALIZED } from "./error-messages"
import { GlobalState, GlobalStateKey, LocalState, LocalStateKey, SecretKey, Secrets } from "./state-keys"
import { readGlobalStateFromDisk, readSecretsFromDisk, readWorkspaceStateFromDisk } from "./utils/state-helpers"
@@ -29,10 +30,14 @@ export class StateManager {
private pendingWorkspaceState = new Set<LocalStateKey>()
private persistenceTimeout: NodeJS.Timeout | null = null
private readonly PERSISTENCE_DELAY_MS = 500
private taskHistoryWatcher: FSWatcher | null = null
// Callback for persistence errors
onPersistenceError?: (event: PersistenceErrorEvent) => void
// Callback to sync external state changes with the UI client
onSyncExternalChange?: () => void | Promise<void>
constructor(context: ExtensionContext) {
this.context = context
}
@@ -52,6 +57,9 @@ export class StateManager {
this.populateCache(globalState, secrets, workspaceState)
this.isInitialized = true
// Start watcher for taskHistory.json so external edits update cache (no persist loop)
await this.setupTaskHistoryWatcher()
} catch (error) {
console.error("[StateManager] Failed to initialize:", error)
throw error
@@ -163,6 +171,56 @@ export class StateManager {
this.scheduleDebouncedPersistence()
}
/**
* Initialize chokidar watcher for the taskHistory.json file
* Updates in-memory cache on external changes without writing back to disk.
*/
private async setupTaskHistoryWatcher(): Promise<void> {
try {
const historyFile = await getTaskHistoryStateFilePath(this.context)
// Close any existing watcher before creating a new one
if (this.taskHistoryWatcher) {
await this.taskHistoryWatcher.close()
this.taskHistoryWatcher = null
}
this.taskHistoryWatcher = chokidar.watch(historyFile, {
persistent: true,
ignoreInitial: true,
atomic: true,
awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 },
})
const syncTaskHistoryFromDisk = async () => {
try {
if (!this.isInitialized) {
return
}
const onDisk = await readTaskHistoryFromState(this.context)
const cached = this.globalStateCache["taskHistory"]
if (JSON.stringify(onDisk) !== JSON.stringify(cached)) {
this.globalStateCache["taskHistory"] = onDisk
await this.onSyncExternalChange?.()
}
} catch (err) {
console.error("[StateManager] Failed to reload task history on change:", err)
}
}
this.taskHistoryWatcher
.on("add", () => syncTaskHistoryFromDisk())
.on("change", () => syncTaskHistoryFromDisk())
.on("unlink", async () => {
this.globalStateCache["taskHistory"] = []
await this.onSyncExternalChange?.()
})
.on("error", (error) => console.error("[StateManager] TaskHistory watcher error:", error))
} catch (err) {
console.error("[StateManager] Failed to set up taskHistory watcher:", err)
}
}
/**
* Convenience method for getting API configuration
* Ensures cache is initialized if not already done
@@ -515,6 +573,11 @@ export class StateManager {
clearTimeout(this.persistenceTimeout)
this.persistenceTimeout = null
}
// Close file watcher if active
if (this.taskHistoryWatcher) {
this.taskHistoryWatcher.close()
this.taskHistoryWatcher = null
}
this.pendingGlobalState.clear()
this.pendingSecrets.clear()
+30 -12
View File
@@ -1,6 +1,7 @@
import fs from "fs/promises"
import path from "path"
import * as vscode from "vscode"
import { HistoryItem } from "@/shared/HistoryItem"
import { ensureRulesDirectoryExists, readTaskHistoryFromState, writeTaskHistoryToState } from "./disk"
import { StateManager } from "./StateManager"
@@ -68,20 +69,37 @@ export async function migrateWorkspaceToGlobalStorage(context: vscode.ExtensionC
export async function migrateTaskHistoryToFile(context: vscode.ExtensionContext) {
try {
// If the old taskHistory vs code global state is undefined, do nothing
const vscodeGlobalStateTaskHistory = await context.globalState.get("taskHistory")
if (vscodeGlobalStateTaskHistory === undefined) {
// Get data from old location
const vscodeGlobalStateTaskHistory = context.globalState.get<HistoryItem[] | undefined>("taskHistory")
// Normalize old location data to array (empty array if undefined/null/not-array)
const oldLocationData = Array.isArray(vscodeGlobalStateTaskHistory) ? vscodeGlobalStateTaskHistory : []
// Early return if no migration needed
if (oldLocationData.length === 0) {
console.log("[Storage Migration] No task history to migrate")
return
}
// Read legacy from VS Code globalState, default to []
console.log("[Storage Migration] taskHistory from vscode global state: ", vscodeGlobalStateTaskHistory)
// Always create the file, even when empty
await writeTaskHistoryToState(context, Array.isArray(vscodeGlobalStateTaskHistory) ? vscodeGlobalStateTaskHistory : [])
// Don't remove the old taskHistory yet, while this version is not in production, for better dev experience.
// This is because the old version of the code (still in prod) is still reading taskHistory from the vs code global state, so it will appear as if all the user's tasks have been deleted.
// await context.globalState.update("taskHistory", undefined)
console.log("[Storage Migration] taskHistory file in new location: ", await readTaskHistoryFromState(context))
console.log("[Storage Migration] old vscode global state: ", await context.globalState.get("taskHistory"))
let finalData: HistoryItem[]
let migrationAction: string
const newLocationData = await readTaskHistoryFromState(context)
if (newLocationData.length === 0) {
// Move old data to new location
finalData = oldLocationData
migrationAction = "Migrated task history from old location to new location"
} else {
// Merge old data (more recent) with new data
finalData = [...newLocationData, ...oldLocationData]
migrationAction = "Merged task history from old and new locations"
}
// Perform migration operations sequentially - only clear old data if write succeeds
await writeTaskHistoryToState(context, finalData)
void context.globalState.update("taskHistory", undefined)
console.log(`[Storage Migration] ${migrationAction}`)
} catch (error) {
console.error("[Storage Migration] Failed to migrate task history to file:", error)
}
+42 -1
View File
@@ -70,7 +70,7 @@ import type { SystemPromptContext } from "@/core/prompts/system-prompt"
import { getSystemPrompt } from "@/core/prompts/system-prompt"
import { HostProvider } from "@/hosts/host-provider"
import { errorService } from "@/services/error"
import { telemetryService } from "@/services/telemetry"
import { TerminalHangStage, TerminalUserInterventionAction, telemetryService } from "@/services/telemetry"
import { ShowMessageType } from "@/shared/proto/index.host"
import { isInTestMode } from "../../services/test/TestMode"
import { ensureLocalClineDirExists } from "../context/instructions/user-instructions/rule-helpers"
@@ -1491,6 +1491,10 @@ export class Task {
let chunkTimer: NodeJS.Timeout | null = null
let chunkEnroute = false
// Track if buffer gets stuck
let bufferStuckTimer: NodeJS.Timeout | null = null
const BUFFER_STUCK_TIMEOUT_MS = 6000 // 6 seconds
const flushBuffer = async (force = false) => {
if (chunkEnroute || outputBuffer.length === 0) {
if (force && !chunkEnroute && outputBuffer.length > 0) {
@@ -1503,9 +1507,18 @@ export class Task {
outputBuffer = []
outputBufferSize = 0
chunkEnroute = true
// Start timer to detect if buffer gets stuck
bufferStuckTimer = setTimeout(() => {
telemetryService.captureTerminalHang(TerminalHangStage.BUFFER_STUCK)
bufferStuckTimer = null
}, BUFFER_STUCK_TIMEOUT_MS)
try {
const { response, text, images, files } = await this.ask("command_output", chunk)
if (response === "yesButtonClicked") {
// Track when user clicks "Process while Running"
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.PROCESS_WHILE_RUNNING)
// proceed while running - but still capture user feedback if provided
if (text || (images && images.length > 0) || (files && files.length > 0)) {
userFeedback = { text, images, files }
@@ -1517,7 +1530,13 @@ export class Task {
process.continue()
} catch {
Logger.error("Error while asking for command output")
telemetryService.captureTerminalHang(TerminalHangStage.STREAM_TIMEOUT)
} finally {
// Clear the stuck timer
if (bufferStuckTimer) {
clearTimeout(bufferStuckTimer)
bufferStuckTimer = null
}
chunkEnroute = false
// If more output accumulated while chunkEnroute, flush again
if (outputBuffer.length > 0) {
@@ -1552,8 +1571,24 @@ export class Task {
})
let completed = false
let completionTimer: NodeJS.Timeout | null = null
const COMPLETION_TIMEOUT_MS = 6000 // 6 seconds
// Start timer to detect if waiting for completion takes too long
completionTimer = setTimeout(() => {
if (!completed) {
telemetryService.captureTerminalHang(TerminalHangStage.WAITING_FOR_COMPLETION)
completionTimer = null
}
}, COMPLETION_TIMEOUT_MS)
process.once("completed", async () => {
completed = true
// Clear the completion timer
if (completionTimer) {
clearTimeout(completionTimer)
completionTimer = null
}
// Flush any remaining buffered output
if (!didContinue && outputBuffer.length > 0) {
if (chunkTimer) {
@@ -1570,6 +1605,12 @@ export class Task {
await process
// Clear timer if process completes normally
if (completionTimer) {
clearTimeout(completionTimer)
completionTimer = null
}
// Wait for a short delay to ensure all messages are sent to the webview
// This delay allows time for non-awaited promises to be created and
// for their associated messages to be sent to the webview, maintaining
+4 -4
View File
@@ -1,7 +1,7 @@
import { ToolUseName } from "@core/assistant-message"
import { resolveWorkspacePath } from "@core/workspace"
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
import * as path from "path"
import { getCwd, getDesktopDir } from "@/utils/path"
import { getCwd, getDesktopDir, isLocatedInPath } from "@/utils/path"
export class AutoApprove {
autoApprovalSettings: AutoApprovalSettings
@@ -54,8 +54,8 @@ export class AutoApprove {
let isLocalRead: boolean = false
if (autoApproveActionpath) {
const cwd = await getCwd(getDesktopDir())
const absolutePath = path.resolve(cwd, autoApproveActionpath)
isLocalRead = absolutePath.startsWith(cwd)
const absolutePath = resolveWorkspacePath(cwd, autoApproveActionpath, "AutoApprove.shouldAutoApproveToolWithPath")
isLocalRead = isLocatedInPath(cwd, absolutePath)
} else {
// If we do not get a path for some reason, default to a (safer) false return
isLocalRead = false
@@ -1,7 +1,7 @@
import type { ToolUse } from "@core/assistant-message"
import { getWorkspaceBasename, resolveWorkspacePath } from "@core/workspace"
import { parseSourceCodeForDefinitionsTopLevel } from "@services/tree-sitter"
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
import * as path from "path"
import { formatResponse } from "@/core/prompts/responses"
import { telemetryService } from "@/services/telemetry"
import type { ToolResponse } from "../../index"
@@ -57,7 +57,7 @@ export class ListCodeDefinitionNamesToolHandler implements IFullyManagedTool {
}
config.taskState.consecutiveMistakeCount = 0
const absolutePath = path.resolve(config.cwd, relDirPath!)
const absolutePath = resolveWorkspacePath(config.cwd, relDirPath!, "ListCodeDefinitionNamesToolHandler.execute")
// Execute the actual parse source code operation
const result = await parseSourceCodeForDefinitionsTopLevel(absolutePath, config.services.clineIgnoreController)
@@ -81,7 +81,7 @@ export class ListCodeDefinitionNamesToolHandler implements IFullyManagedTool {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to analyze code definitions in ${path.basename(absolutePath)}`
const notificationMessage = `Cline wants to analyze code definitions in ${getWorkspaceBasename(absolutePath, "ListCodeDefinitionNamesToolHandler.notification")}`
// Show notification
showNotificationForApprovalIfAutoApprovalEnabled(
@@ -1,8 +1,8 @@
import type { ToolUse } from "@core/assistant-message"
import { formatResponse } from "@core/prompts/responses"
import { getWorkspaceBasename, resolveWorkspacePath } from "@core/workspace"
import { listFiles } from "@services/glob/list-files"
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
import * as path from "path"
import { telemetryService } from "@/services/telemetry"
import type { ToolResponse } from "../../index"
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
@@ -62,7 +62,7 @@ export class ListFilesToolHandler implements IFullyManagedTool {
}
config.taskState.consecutiveMistakeCount = 0
const absolutePath = path.resolve(config.cwd, relDirPath!)
const absolutePath = resolveWorkspacePath(config.cwd, relDirPath!, "ListFilesToolHandler.execute")
// Execute the actual list files operation
const [files, didHitLimit] = await listFiles(absolutePath, recursive, 200)
@@ -89,7 +89,7 @@ export class ListFilesToolHandler implements IFullyManagedTool {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to view directory ${path.basename(absolutePath)}/`
const notificationMessage = `Cline wants to view directory ${getWorkspaceBasename(absolutePath, "ListFilesToolHandler.notification")}/`
// Show notification
showNotificationForApprovalIfAutoApprovalEnabled(
@@ -1,8 +1,8 @@
import type { ToolUse } from "@core/assistant-message"
import { formatResponse } from "@core/prompts/responses"
import { getWorkspaceBasename, resolveWorkspacePath } from "@core/workspace"
import { extractFileContent } from "@integrations/misc/extract-file-content"
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
import * as path from "path"
import { telemetryService } from "@/services/telemetry"
import { ClineSayTool } from "@/shared/ExtensionMessage"
import type { ToolResponse } from "../../index"
@@ -65,7 +65,7 @@ export class ReadFileToolHandler implements IFullyManagedTool {
}
config.taskState.consecutiveMistakeCount = 0
const absolutePath = path.resolve(config.cwd, relPath!)
const absolutePath = resolveWorkspacePath(config.cwd, relPath!, "ReadFileToolHandler.execute")
// Handle approval flow
const sharedMessageProps = {
@@ -87,7 +87,7 @@ export class ReadFileToolHandler implements IFullyManagedTool {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to read ${path.basename(absolutePath)}`
const notificationMessage = `Cline wants to read ${getWorkspaceBasename(absolutePath, "ReadFileToolHandler.notification")}`
// Show notification
showNotificationForApprovalIfAutoApprovalEnabled(
@@ -1,8 +1,8 @@
import type { ToolUse } from "@core/assistant-message"
import { regexSearchFiles } from "@services/ripgrep"
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
import * as path from "path"
import { formatResponse } from "@/core/prompts/responses"
import { resolveWorkspacePath } from "@/core/workspace/WorkspaceResolver"
import { telemetryService } from "@/services/telemetry"
import { ClineSayTool } from "@/shared/ExtensionMessage"
import type { ToolResponse } from "../../index"
@@ -72,7 +72,8 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
}
config.taskState.consecutiveMistakeCount = 0
const absolutePath = path.resolve(config.cwd, relDirPath!)
const absolutePath = resolveWorkspacePath(config.cwd, relDirPath!, "SearchFilesTool.execute")
// Execute the actual regex search operation
const results = await regexSearchFiles(
config.cwd,
@@ -2,12 +2,12 @@ import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import type { ToolUse } from "@core/assistant-message"
import { constructNewFileContent } from "@core/assistant-message/diff"
import { formatResponse } from "@core/prompts/responses"
import { getWorkspaceBasename, resolveWorkspacePath } from "@core/workspace"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { ClineSayTool } from "@shared/ExtensionMessage"
import { fileExistsAtPath } from "@utils/fs"
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
import { fixModelHtmlEscaping, removeInvalidChars } from "@utils/string"
import * as path from "path"
import { telemetryService } from "@/services/telemetry"
import type { ToolResponse } from "../../index"
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
@@ -49,12 +49,13 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
const config = uiHelpers.getConfig()
try {
const result = await this.validateAndPrepareFileOperation(config, block, rawRelPath, rawDiff, rawContent)
if (!result) {
return
}
// Creates file if it doesn't exist, and opens editor to stream content in. We don't want to handle this in the try/catch below since the error handler for it resets the diff view, which wouldn't be open if this failed.
const result = await this.validateAndPrepareFileOperation(config, block, rawRelPath, rawDiff, rawContent)
if (!result) {
return
}
try {
const { relPath, fileExists, diff, content, newContent } = result
// Create and show partial UI message
@@ -176,7 +177,7 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
await setTimeoutPromise(3_500)
} else {
// Manual approval flow with detailed feedback handling
const notificationMessage = `Cline wants to ${fileExists ? "edit" : "create"} ${path.basename(relPath)}`
const notificationMessage = `Cline wants to ${fileExists ? "edit" : "create"} ${getWorkspaceBasename(relPath, "WriteToFile.notification")}`
// Show notification
showNotificationForApprovalIfAutoApprovalEnabled(
@@ -331,7 +332,11 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
if (config.services.diffViewProvider.editType !== undefined) {
fileExists = config.services.diffViewProvider.editType === "modify"
} else {
const absolutePath = path.resolve(config.cwd, relPath)
const absolutePath = resolveWorkspacePath(
config.cwd,
relPath,
"WriteToFileToolHandler.validateAndPrepareFileOperation",
)
fileExists = await fileExistsAtPath(absolutePath)
config.services.diffViewProvider.editType = fileExists ? "modify" : "create"
}
+167
View File
@@ -0,0 +1,167 @@
/**
* Handles formatting and presentation of workspace migration reports
*
* Separated from WorkspaceResolver to follow Single Responsibility Principle.
* This class focuses purely on report generation and formatting.
*/
/**
* Tracks path resolution usage for migration planning
*/
export interface UsageStats {
count: number
examples: string[]
lastUsed: Date
}
/**
* Configuration options for report generation
*/
interface ReportOptions {
includeExamples?: boolean
includeHighUsage?: boolean
highUsageThreshold?: number
sortByUsage?: boolean
}
/**
* Handles generation and formatting of migration reports
*/
export class MigrationReporter {
private readonly defaultOptions: Required<ReportOptions> = {
includeExamples: true,
includeHighUsage: true,
highUsageThreshold: 100,
sortByUsage: true,
}
/**
* Generate a comprehensive migration report from usage statistics
* @param usageMap - Map of component names to their usage statistics
* @param traceEnabled - Whether tracing is currently enabled
* @param options - Optional configuration for report generation
* @returns Formatted migration report string
*/
generateReport(usageMap: Map<string, UsageStats>, traceEnabled: boolean, options: ReportOptions = {}): string {
const config = { ...this.defaultOptions, ...options }
const entries = this.prepareEntries(usageMap, config.sortByUsage)
let report = this.generateHeader(entries.length, traceEnabled)
report += this.generateComponentDetails(entries, config)
report += this.generateSummary(entries)
if (config.includeHighUsage) {
report += this.generateHighUsageSection(entries, config.highUsageThreshold)
}
return report
}
/**
* Generate a simplified summary report
* @param usageMap - Map of component names to their usage statistics
* @returns Brief summary string
*/
generateSummary(entries: Array<[string, UsageStats]>): string {
const totalCalls = entries.reduce((sum, [_, stats]) => sum + stats.count, 0)
let summary = `\n=== Summary ===\n`
summary += `Total path resolution calls: ${totalCalls}\n`
return summary
}
/**
* Prepare and optionally sort the usage entries
*/
private prepareEntries(usageMap: Map<string, UsageStats>, sortByUsage: boolean): Array<[string, UsageStats]> {
const entries = Array.from(usageMap.entries())
if (sortByUsage) {
return entries.sort((a, b) => b[1].count - a[1].count)
}
return entries
}
/**
* Generate the report header section
*/
private generateHeader(componentCount: number, traceEnabled: boolean): string {
let header = "=== Multi-Root Migration Report ===\n"
header += `Total components using single-root: ${componentCount}\n`
header += `Trace enabled: ${traceEnabled}\n\n`
return header
}
/**
* Generate detailed component usage information
*/
private generateComponentDetails(entries: Array<[string, UsageStats]>, config: Required<ReportOptions>): string {
let details = ""
entries.forEach(([context, stats]) => {
details += `${context}:\n`
details += ` Calls: ${stats.count}\n`
details += ` Last used: ${stats.lastUsed.toISOString()}\n`
if (config.includeExamples && stats.examples.length > 0) {
details += ` Example paths:\n`
stats.examples.forEach((ex) => {
details += ` - "${ex}"\n`
})
}
details += "\n"
})
return details
}
/**
* Generate high-usage components section
*/
private generateHighUsageSection(entries: Array<[string, UsageStats]>, threshold: number): string {
const highUsageComponents = entries
.filter(([_, stats]) => stats.count > threshold)
.map(([context, stats]) => ({ context, count: stats.count }))
if (highUsageComponents.length === 0) {
return ""
}
let section = `\n=== High-Usage Components ===\n`
section += `(Operations with >${threshold} calls)\n`
highUsageComponents.forEach((h) => {
section += ` - ${h.context}: ${h.count} calls\n`
})
return section
}
/**
* Generate a JSON representation of the usage data
* @param usageMap - Map of component names to their usage statistics
* @returns JSON string representation
*/
generateJsonReport(usageMap: Map<string, UsageStats>): string {
const data = Object.fromEntries(usageMap)
return JSON.stringify(data, null, 2)
}
/**
* Generate a CSV representation of the usage data
* @param usageMap - Map of component names to their usage statistics
* @returns CSV string representation
*/
generateCsvReport(usageMap: Map<string, UsageStats>): string {
const entries = Array.from(usageMap.entries())
let csv = "Component,Calls,LastUsed,ExamplePaths\n"
entries.forEach(([context, stats]) => {
const examples = stats.examples.join("; ")
csv += `"${context}",${stats.count},"${stats.lastUsed.toISOString()}","${examples}"\n`
})
return csv
}
}
+275
View File
@@ -0,0 +1,275 @@
/**
* Workspace path resolution with migration tracing for multi-workspace support
*
* Phase 0: Acts as a tracer to identify all single-root path operations
* Phase 1+: Will handle multi-root path resolution
*/
import { Logger } from "@services/logging/Logger"
import * as path from "path"
import { MigrationReporter, type UsageStats } from "./MigrationReporter"
import { WorkspaceRoot } from "./WorkspaceRoot"
/**
* Maximum number of example paths to store per component for debugging purposes.
* This limit prevents excessive memory usage while providing enough examples
* to understand usage patterns during migration analysis.
*/
const MAX_EXAMPLE_PATHS = 5
export class WorkspaceResolver {
private usageMap = new Map<string, UsageStats>()
private traceEnabled = process.env.MULTI_ROOT_TRACE === "true" || process.env.NODE_ENV === "development"
/**
* Track usage statistics for a given context and path
* @param context - Component/handler name for tracking usage
* @param examplePath - The path to track as an example
*/
private trackUsage(context: string, examplePath: string): void {
const stats = this.usageMap.get(context) || {
count: 0,
examples: [],
lastUsed: new Date(),
}
stats.count++
stats.lastUsed = new Date()
// Keep up to MAX_EXAMPLE_PATHS example paths for debugging
if (stats.examples.length < MAX_EXAMPLE_PATHS && !stats.examples.includes(examplePath)) {
stats.examples.push(examplePath)
}
this.usageMap.set(context, stats)
}
/**
* Phase 0: Traces single-root path resolution for migration planning
* Phase 1+: Will resolve path against multiple workspace roots
*
* @param cwdOrRoots - Current working directory (Phase 0) or array of workspace roots (Phase 1+)
* @param relativePath - The relative path to resolve
* @param context - Component/handler name for tracking usage
* @returns Absolute path (Phase 0) or object with path and root (Phase 1+)
*/
resolveWorkspacePath(
cwdOrRoots: string | WorkspaceRoot[],
relativePath: string,
context?: string,
): string | { absolutePath: string; root: WorkspaceRoot } {
// Phase 0: Single-root tracer mode
if (typeof cwdOrRoots === "string") {
return this.resolveSingleRootPath(cwdOrRoots, relativePath, context)
}
// Phase 1+: Multi-root resolution
return this.resolveMultiRootPath(cwdOrRoots, relativePath)
}
/**
* Resolves a path against a single workspace root (Phase 0)
*
* @param cwd - Current working directory
* @param relativePath - The relative path to resolve
* @param context - Component/handler name for tracking usage
* @returns Absolute path
*/
private resolveSingleRootPath(cwd: string, relativePath: string, context?: string): string {
// Track usage for migration planning
if (context) {
this.trackUsage(context, relativePath)
if (this.traceEnabled) {
Logger.debug(`[MULTI-ROOT-TRACE] ${context}: resolving "${relativePath}" against "${cwd}"`)
}
}
return path.resolve(cwd, relativePath)
}
/**
* Resolves a path against multiple workspace roots (Phase 1+)
*
* @param workspaceRoots - Array of workspace roots
* @param relativePath - The relative path to resolve
* @returns Object with absolute path and matching root
*/
private resolveMultiRootPath(
workspaceRoots: WorkspaceRoot[],
relativePath: string,
): { absolutePath: string; root: WorkspaceRoot } {
// Handle absolute paths
if (path.isAbsolute(relativePath)) {
return this.resolveAbsolutePath(workspaceRoots, relativePath)
}
// Handle relative paths
return this.resolveRelativePath(workspaceRoots, relativePath)
}
/**
* Resolves an absolute path against workspace roots
*
* @param workspaceRoots - Array of workspace roots
* @param absolutePath - The absolute path to resolve
* @returns Object with absolute path and matching root
*/
private resolveAbsolutePath(
workspaceRoots: WorkspaceRoot[],
absolutePath: string,
): { absolutePath: string; root: WorkspaceRoot } {
const matchingRoot = workspaceRoots.find((root) => absolutePath.startsWith(root.path))
return {
absolutePath,
root: matchingRoot || workspaceRoots[0], // fallback to primary
}
}
/**
* Resolves a relative path against workspace roots
*
* @param workspaceRoots - Array of workspace roots
* @param relativePath - The relative path to resolve
* @returns Object with absolute path and matching root
*/
private resolveRelativePath(
workspaceRoots: WorkspaceRoot[],
relativePath: string,
): { absolutePath: string; root: WorkspaceRoot } {
// Check which roots have this relative path
const candidateRoots: WorkspaceRoot[] = []
for (const root of workspaceRoots) {
// const testPath = path.join(root.path, relativePath)
// In Phase 1, check if path exists
// For now, just add all roots as candidates
candidateRoots.push(root)
}
return this.selectBestRoot(workspaceRoots, candidateRoots, relativePath)
}
/**
* Selects the best root from candidate roots using disambiguation logic
*
* @param workspaceRoots - All available workspace roots
* @param candidateRoots - Candidate roots that could contain the path
* @param relativePath - The relative path being resolved
* @returns Object with absolute path and selected root
*/
private selectBestRoot(
workspaceRoots: WorkspaceRoot[],
candidateRoots: WorkspaceRoot[],
relativePath: string,
): { absolutePath: string; root: WorkspaceRoot } {
// Disambiguation logic (simplified for Phase 0)
if (candidateRoots.length === 0) {
// Path doesn't exist in any root, use primary
return {
absolutePath: path.resolve(workspaceRoots[0].path, relativePath),
root: workspaceRoots[0],
}
}
if (candidateRoots.length === 1) {
// Unambiguous
return {
absolutePath: path.resolve(candidateRoots[0].path, relativePath),
root: candidateRoots[0],
}
}
// Multiple matches - need disambiguation
// Phase 2: This will trigger UI picker
// For now, use primary root if it's a candidate, otherwise first match
const primaryRoot = workspaceRoots[0]
const selectedRoot = candidateRoots.find((r) => r.path === primaryRoot.path) || candidateRoots[0]
return {
absolutePath: path.resolve(selectedRoot.path, relativePath),
root: selectedRoot,
}
}
/**
* Get migration report showing all single-root usage patterns
* Currently this function is mainly called by the vscode debugger
*/
getMigrationReport(): string {
const reporter = new MigrationReporter()
return reporter.generateReport(this.usageMap, this.traceEnabled)
}
/**
* Get raw usage statistics for external analysis
* @returns Map of component names to their usage statistics
*/
getUsageStats(): Map<string, UsageStats> {
return new Map(this.usageMap)
}
/**
* Clear usage statistics (useful for testing)
*/
clearUsageStats(): void {
this.usageMap.clear()
}
/**
* Export usage data as JSON for analysis
*/
exportUsageData(): Record<string, UsageStats> {
return Object.fromEntries(this.usageMap)
}
/**
* Phase 0: Instance method for getting basename with tracking
* Phase 1+: Will handle basename for multi-workspace paths
*
* @param filePath - The file path to get basename from
* @param context - Component/handler name for tracking usage
* @returns The basename of the path
*/
getBasename(filePath: string, context?: string): string {
// Track usage for migration planning
if (!context?.length) {
return path.basename(filePath)
}
this.trackUsage(context, filePath)
// Phase 0: Just wrap existing behavior
const result = path.basename(filePath)
if (this.traceEnabled) {
Logger.debug(`[MULTI-ROOT-TRACE] ${context}: getting basename for "${filePath}"`)
}
return result
}
}
// Export singleton instance
export const workspaceResolver = new WorkspaceResolver()
/**
* Phase 0: Convenience function for easy migration from path.resolve()
* This is what we'll use to replace existing path.resolve(cwd, ...) calls
*/
export function resolveWorkspacePath(cwd: string, relativePath: string, context?: string): string {
return workspaceResolver.resolveWorkspacePath(cwd, relativePath, context) as string
}
/**
* Helper to check if we're in trace mode
*/
export function isWorkspaceTraceEnabled(): boolean {
return process.env.MULTI_ROOT_TRACE === "true" || process.env.NODE_ENV === "development"
}
/**
* Phase 0: Convenience function for path.basename with tracking
* This is what we'll use to replace existing path.basename() calls
*/
export function getWorkspaceBasename(filePath: string, context?: string): string {
return workspaceResolver.getBasename(filePath, context)
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Workspace root types and interfaces for multi-workspace support
*/
export enum VcsType {
None = "none",
Git = "git",
Mercurial = "mercurial",
}
export interface WorkspaceRoot {
path: string // Absolute path to the workspace root
name?: string // Optional display name for the workspace (auto-derived from path if not provided)
vcs: VcsType // Version control system type for this root
commitHash?: string // Optional latest commit hash/changeset ID for VCS tracking
}
// Example usage:
// const workspaceRoots: WorkspaceRoot[] = [
// {
// path: "/Users/dev/frontend",
// name: "frontend",
// vcs: VcsType.Git,
// commitHash: "a1b2c3d4e5f6789"
// },
// {
// path: "/Users/dev/backend",
// name: "backend",
// vcs: VcsType.Git,
// commitHash: "f6e5d4c3b2a1987"
// }
// ]
+261
View File
@@ -0,0 +1,261 @@
/**
* WorkspaceRootManager - Central manager for multi-workspace operations
* This class handles workspace root resolution, path mapping, and workspace context
*/
import { execa } from "execa"
import * as path from "path"
import { getLatestGitCommitHash } from "../../utils/git"
import { VcsType, WorkspaceRoot } from "./WorkspaceRoot"
export interface WorkspaceContext {
workspaceRoots: WorkspaceRoot[]
primaryRoot: WorkspaceRoot
currentRoot?: WorkspaceRoot
}
export class WorkspaceRootManager {
private roots: WorkspaceRoot[] = []
private primaryIndex: number = 0
constructor(roots: WorkspaceRoot[] = [], primaryIndex: number = 0) {
this.roots = roots
this.primaryIndex = Math.min(primaryIndex, Math.max(0, roots.length - 1))
}
/**
* Initialize from a single cwd for backward compatibility
*/
static async fromLegacyCwd(cwd: string): Promise<WorkspaceRootManager> {
const vcs = await WorkspaceRootManager.detectVcs(cwd)
const gitHash = vcs === VcsType.Git ? await getLatestGitCommitHash(cwd) : null
const commitHash = gitHash === null ? undefined : gitHash
const root: WorkspaceRoot = {
path: cwd,
name: path.basename(cwd),
vcs,
commitHash,
}
return new WorkspaceRootManager([root], 0)
}
/**
* Detect version control system for a directory
*/
private static async detectVcs(dirPath: string): Promise<VcsType> {
try {
// Check for Git
await execa("git", ["rev-parse", "--git-dir"], { cwd: dirPath })
return VcsType.Git
} catch {
// Not a git repo
}
try {
// Check for Mercurial
await execa("hg", ["root"], { cwd: dirPath })
return VcsType.Mercurial
} catch {
// Not a mercurial repo
}
return VcsType.None
}
/**
* Add a new workspace root
*/
async addRoot(rootPath: string, name?: string): Promise<void> {
const vcs = await WorkspaceRootManager.detectVcs(rootPath)
const gitHash = vcs === VcsType.Git ? await getLatestGitCommitHash(rootPath) : null
const commitHash = gitHash === null ? undefined : gitHash
const root: WorkspaceRoot = {
path: rootPath,
name: name || path.basename(rootPath),
vcs,
commitHash,
}
this.roots.push(root)
}
/**
* Remove a workspace root by path
*/
removeRoot(path: string): boolean {
const index = this.roots.findIndex((r) => r.path === path)
if (index === -1) {
return false
}
this.roots.splice(index, 1)
// Adjust primary index if needed
if (this.primaryIndex >= this.roots.length) {
this.primaryIndex = Math.max(0, this.roots.length - 1)
}
return true
}
/**
* Get all workspace roots
*/
getRoots(): WorkspaceRoot[] {
return [...this.roots]
}
/**
* Get the primary workspace root
*/
getPrimaryRoot(): WorkspaceRoot | undefined {
return this.roots[this.primaryIndex]
}
/**
* Set the primary workspace root by index
*/
setPrimaryIndex(index: number): void {
if (index >= 0 && index < this.roots.length) {
this.primaryIndex = index
}
}
/**
* Find the workspace root that contains the given absolute path
*/
resolvePathToRoot(absolutePath: string): WorkspaceRoot | undefined {
// Sort roots by path length (longest first) to handle nested workspaces
const sortedRoots = [...this.roots].sort((a, b) => b.path.length - a.path.length)
for (const root of sortedRoots) {
if (absolutePath.startsWith(root.path)) {
return root
}
}
return undefined
}
/**
* Find workspace root by name
*/
getRootByName(name: string): WorkspaceRoot | undefined {
return this.roots.find((r) => r.name === name)
}
/**
* Get workspace root by index
*/
getRootByIndex(index: number): WorkspaceRoot | undefined {
return this.roots[index]
}
/**
* Check if a path is within any workspace root
*/
isPathInWorkspace(absolutePath: string): boolean {
return this.resolvePathToRoot(absolutePath) !== undefined
}
/**
* Get relative path from workspace root
*/
getRelativePathFromRoot(absolutePath: string, root?: WorkspaceRoot): string | undefined {
const targetRoot = root || this.resolvePathToRoot(absolutePath)
if (!targetRoot) {
return undefined
}
return path.relative(targetRoot.path, absolutePath)
}
/**
* Create workspace context for tool execution
*/
createContext(currentRoot?: WorkspaceRoot): WorkspaceContext {
return {
workspaceRoots: this.getRoots(),
primaryRoot: this.getPrimaryRoot()!,
currentRoot: currentRoot || this.getPrimaryRoot(),
}
}
/**
* Serialize for storage
*/
toJSON(): { roots: WorkspaceRoot[]; primaryIndex: number } {
return {
roots: this.roots,
primaryIndex: this.primaryIndex,
}
}
/**
* Deserialize from storage
*/
static fromJSON(data: { roots: WorkspaceRoot[]; primaryIndex: number }): WorkspaceRootManager {
return new WorkspaceRootManager(data.roots, data.primaryIndex)
}
/**
* Get a summary string for display
*/
getSummary(): string {
if (this.roots.length === 0) {
return "No workspace roots configured"
}
if (this.roots.length === 1) {
return `Single workspace: ${this.roots[0].name || this.roots[0].path}`
}
const primary = this.getPrimaryRoot()
return `Multi-workspace (${this.roots.length} roots)\nPrimary: ${primary?.name || primary?.path}\nAdditional: ${this.roots
.filter((_, i) => i !== this.primaryIndex)
.map((r) => r.name || path.basename(r.path))
.join(", ")}`
}
/**
* Check if this is a single-root workspace (for backward compatibility)
*/
isSingleRoot(): boolean {
return this.roots.length === 1
}
/**
* Get the single root if this is a single-root workspace
* Throws if multiple roots exist
*/
getSingleRoot(): WorkspaceRoot {
if (this.roots.length !== 1) {
throw new Error(`Expected single root, but found ${this.roots.length} roots`)
}
return this.roots[0]
}
/**
* Update commit hashes for all Git repositories
*/
async updateCommitHashes(): Promise<void> {
for (const root of this.roots) {
if (root.vcs === VcsType.Git) {
const gitHash = await getLatestGitCommitHash(root.path)
root.commitHash = gitHash === null ? undefined : gitHash
}
}
}
}
// Export for use in Task and Controller
export function createLegacyWorkspaceRoot(cwd: string): WorkspaceRoot {
return {
path: cwd,
name: path.basename(cwd),
vcs: VcsType.None, // Will be detected properly during initialization
}
}
@@ -0,0 +1,206 @@
/**
* Unit tests for WorkspaceResolver
* These tests ensure behavior preservation during refactoring
*/
import { expect } from "chai"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as path from "path"
import * as sinon from "sinon"
import { Logger } from "../../../services/logging/Logger"
import { WorkspaceResolver } from "../WorkspaceResolver"
import { VcsType, WorkspaceRoot } from "../WorkspaceRoot"
describe("WorkspaceResolver", () => {
let resolver: WorkspaceResolver
let loggerStub: sinon.SinonStub
let originalEnv: string | undefined
beforeEach(() => {
resolver = new WorkspaceResolver()
loggerStub = sinon.stub(Logger, "debug")
originalEnv = process.env.MULTI_ROOT_TRACE
})
afterEach(() => {
loggerStub.restore()
process.env.MULTI_ROOT_TRACE = originalEnv
resolver.clearUsageStats()
})
describe("resolveWorkspacePath - Single Root Mode", () => {
const testCwd = "/test/workspace"
const testRelativePath = "src/file.ts"
const expectedAbsolutePath = path.resolve(testCwd, testRelativePath)
it("should resolve path without context", () => {
const result = resolver.resolveWorkspacePath(testCwd, testRelativePath)
expect(result).to.equal(expectedAbsolutePath)
})
it("should resolve path with context and track usage", () => {
const context = "TestComponent"
const result = resolver.resolveWorkspacePath(testCwd, testRelativePath, context)
expect(result).to.equal(expectedAbsolutePath)
// Verify usage tracking
const usageStats = resolver.getUsageStats()
expect(usageStats.has(context)).to.be.true
const stats = usageStats.get(context)!
expect(stats.count).to.equal(1)
expect(stats.examples).to.include(testRelativePath)
})
it("should track multiple calls to same context", () => {
const context = "TestComponent"
resolver.resolveWorkspacePath(testCwd, "file1.ts", context)
resolver.resolveWorkspacePath(testCwd, "file2.ts", context)
resolver.resolveWorkspacePath(testCwd, "file1.ts", context) // duplicate
const usageStats = resolver.getUsageStats()
const stats = usageStats.get(context)!
expect(stats.count).to.equal(3)
expect(stats.examples).to.have.length(2) // no duplicates in examples
expect(stats.examples).to.include("file1.ts")
expect(stats.examples).to.include("file2.ts")
})
it("should not log when tracing is disabled", () => {
process.env.MULTI_ROOT_TRACE = "false"
process.env.NODE_ENV = "production"
resolver.resolveWorkspacePath(testCwd, testRelativePath, "TestComponent")
expect(loggerStub.called).to.be.false
})
it("should handle absolute paths correctly", () => {
const absolutePath = "/absolute/path/file.ts"
const result = resolver.resolveWorkspacePath(testCwd, absolutePath)
expect(result).to.equal(path.resolve(testCwd, absolutePath))
})
it("should handle empty relative path", () => {
const result = resolver.resolveWorkspacePath(testCwd, "")
expect(result).to.equal(path.resolve(testCwd))
})
it("should handle relative paths with .. navigation", () => {
const relativePath = "../other/file.ts"
const result = resolver.resolveWorkspacePath(testCwd, relativePath)
expect(result).to.equal(path.resolve(testCwd, relativePath))
})
})
describe("resolveWorkspacePath - Multi Root Mode", () => {
const workspaceRoots: WorkspaceRoot[] = [
{ path: "/workspace/primary", name: "primary", vcs: VcsType.Git },
{ path: "/workspace/secondary", name: "secondary", vcs: VcsType.Git },
]
it("should handle absolute paths in multi-root mode", () => {
const absolutePath = "/workspace/primary/src/file.ts"
const result = resolver.resolveWorkspacePath(workspaceRoots, absolutePath)
expect(result).to.be.an("object")
expect((result as any).absolutePath).to.equal(absolutePath)
expect((result as any).root).to.equal(workspaceRoots[0])
})
it("should fallback to primary root for unmatched absolute paths", () => {
const absolutePath = "/other/path/file.ts"
const result = resolver.resolveWorkspacePath(workspaceRoots, absolutePath)
expect(result).to.be.an("object")
expect((result as any).absolutePath).to.equal(absolutePath)
expect((result as any).root).to.equal(workspaceRoots[0])
})
it("should resolve relative paths against primary root", () => {
const relativePath = "src/file.ts"
const result = resolver.resolveWorkspacePath(workspaceRoots, relativePath)
expect(result).to.be.an("object")
expect((result as any).absolutePath).to.equal(path.resolve(workspaceRoots[0].path, relativePath))
expect((result as any).root).to.equal(workspaceRoots[0])
})
it("should handle empty workspace roots array", () => {
// This should throw an error or handle gracefully
expect(() => {
resolver.resolveWorkspacePath([], "src/file.ts")
}).to.throw()
})
})
describe("getBasename", () => {
const testFilePath = "/path/to/file.ts"
const expectedBasename = "file.ts"
it("should return basename without context", () => {
const result = resolver.getBasename(testFilePath)
expect(result).to.equal(expectedBasename)
})
it("should return basename with context and track usage", () => {
const context = "TestComponent"
const result = resolver.getBasename(testFilePath, context)
expect(result).to.equal(expectedBasename)
// Verify usage tracking
const usageStats = resolver.getUsageStats()
expect(usageStats.has(context)).to.be.true
const stats = usageStats.get(context)!
expect(stats.count).to.equal(1)
expect(stats.examples).to.include(testFilePath)
})
})
describe("Usage Statistics Management", () => {
it("should track usage statistics correctly", () => {
resolver.resolveWorkspacePath("/test", "file1.ts", "Component1")
resolver.resolveWorkspacePath("/test", "file2.ts", "Component1")
resolver.getBasename("/test/file3.ts", "Component2")
const usageStats = resolver.getUsageStats()
expect(usageStats.size).to.equal(2)
const component1Stats = usageStats.get("Component1")!
expect(component1Stats.count).to.equal(2)
expect(component1Stats.examples).to.have.length(2)
const component2Stats = usageStats.get("Component2")!
expect(component2Stats.count).to.equal(1)
expect(component2Stats.examples).to.have.length(1)
})
})
describe("Edge Cases", () => {
it("should handle null/undefined inputs gracefully", () => {
// These should not throw
expect(() => resolver.resolveWorkspacePath("/test", "")).to.not.throw()
expect(() => resolver.getBasename("")).to.not.throw()
})
it("should handle special characters in paths", () => {
const specialPath = "src/file with spaces & symbols!.ts"
const result = resolver.resolveWorkspacePath("/test", specialPath, "Component")
expect(result).to.equal(path.resolve("/test", specialPath))
})
it("should handle very long paths", () => {
const longPath = "a/".repeat(100) + "file.ts"
const result = resolver.resolveWorkspacePath("/test", longPath, "Component")
expect(result).to.equal(path.resolve("/test", longPath))
})
})
})
+18
View File
@@ -0,0 +1,18 @@
/**
* Workspace module exports for multi-workspace support
*/
export {
getWorkspaceBasename,
isWorkspaceTraceEnabled,
resolveWorkspacePath,
WorkspaceResolver,
workspaceResolver,
} from "@core/workspace/WorkspaceResolver"
export type { WorkspaceRoot } from "@core/workspace/WorkspaceRoot"
export { VcsType } from "@core/workspace/WorkspaceRoot"
export type { WorkspaceContext } from "@core/workspace/WorkspaceRootManager"
export { createLegacyWorkspaceRoot, WorkspaceRootManager } from "@core/workspace/WorkspaceRootManager"
// Re-export convenience function at module level for easier imports
// Usage: import { resolveWorkspacePath } from "@core/workspace"
@@ -47,6 +47,10 @@ describe("Diagnostics Tests", () => {
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Error in file1",
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 10 },
},
},
],
},
@@ -114,6 +118,10 @@ describe("Diagnostics Tests", () => {
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Error in file1",
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 10 },
},
},
],
},
@@ -125,6 +133,10 @@ describe("Diagnostics Tests", () => {
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Error in file1",
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 10 },
},
},
],
},
@@ -134,6 +146,10 @@ describe("Diagnostics Tests", () => {
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Error in file2",
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 10 },
},
},
],
},
@@ -198,6 +214,10 @@ describe("Diagnostics Tests", () => {
{
severity: DiagnosticSeverity.DIAGNOSTIC_WARNING,
message: "Warning message",
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 10 },
},
},
],
},
@@ -240,6 +260,10 @@ describe("Diagnostics Tests", () => {
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "File-level error",
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 10 },
},
},
],
},
@@ -248,7 +272,7 @@ describe("Diagnostics Tests", () => {
const result = await diagnosticsToProblemsString(diagnostics, severities)
expect(result).to.equal("src/file1.ts\n- [Error] Line : File-level error")
expect(result).to.equal("src/file1.ts\n- [Error] Line 1: File-level error")
})
it("should handle diagnostics with missing start property in range", async () => {
+2 -2
View File
@@ -1,3 +1,4 @@
import { workspaceResolver } from "@core/workspace"
import { DifyHandler } from "../../core/api/providers/dify"
/**
@@ -252,14 +253,13 @@ export function createDifyIntegration(
*/
export async function prepareClineFilesForDify(files: string[]): Promise<Array<{ name: string; content: Buffer }>> {
const fs = await import("fs")
const path = await import("path")
const fileData: Array<{ name: string; content: Buffer }> = []
for (const filePath of files) {
try {
const content = fs.readFileSync(filePath)
const name = path.basename(filePath)
const name = workspaceResolver.getBasename(filePath, "DifyIntegration.prepareClineFilesForDify")
fileData.push({ name, content })
} catch (error) {
console.error(`Failed to read file ${filePath}:`, error)
+4 -2
View File
@@ -1,10 +1,10 @@
import { formatResponse } from "@core/prompts/responses"
import { workspaceResolver } from "@core/workspace"
import { createDirectoriesForFile } from "@utils/fs"
import { getCwd } from "@utils/path"
import * as diff from "diff"
import * as fs from "fs/promises"
import * as iconv from "iconv-lite"
import * as path from "path"
import { HostProvider } from "@/hosts/host-provider"
import { diagnosticsToProblemsString, getNewDiagnostics } from "@/integrations/diagnostics"
import { DiagnosticSeverity, FileDiagnostics } from "@/shared/proto/index.cline"
@@ -29,7 +29,9 @@ export abstract class DiffViewProvider {
public async open(relPath: string): Promise<void> {
this.isEditing = true
this.relPath = relPath
this.absolutePath = path.resolve(await getCwd(), relPath)
const cwd = await getCwd()
const absolutePathResolved = workspaceResolver.resolveWorkspacePath(cwd, relPath, "DiffViewProvider.open.absolutePath")
this.absolutePath = typeof absolutePathResolved === "string" ? absolutePathResolved : absolutePathResolved.absolutePath
const fileExists = this.editType === "modify"
// if the file is already open, ensure it's not dirty before getting its contents
@@ -1,3 +1,4 @@
import { TerminalOutputFailureReason, telemetryService } from "@services/telemetry"
import { EventEmitter } from "events"
import * as vscode from "vscode"
import { stripAnsi } from "./ansiUtils"
@@ -39,6 +40,7 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
}
if (terminal.shellIntegration && terminal.shellIntegration.executeCommand) {
// Track that we're using shell integration
const execution = terminal.shellIntegration.executeCommand(command)
const stream = execution.read()
// todo: need to handle errors
@@ -189,7 +191,19 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
// the command process is finished, let's check the output to see if we need to use the terminal capture fallback
if (!this.fullOutput.trim()) {
// No output captured via shell integration, trying fallback
telemetryService.captureTerminalOutputFailure(TerminalOutputFailureReason.TIMEOUT)
await returnCurrentTerminalContents()
// Check if fallback worked
const terminalSnapshot = await getLatestTerminalOutput()
if (terminalSnapshot && terminalSnapshot.trim()) {
telemetryService.captureTerminalExecution(true, "clipboard")
} else {
telemetryService.captureTerminalExecution(false, "none")
}
} else {
// Shell integration worked
telemetryService.captureTerminalExecution(true, "shell_integration")
}
// for now we don't want this delaying requests since we don't send diagnostics automatically anymore (previous: "even though the command is finished, we still want to consider it 'hot' in case so that api request stalls to let diagnostics catch up")
@@ -203,6 +217,7 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
this.emit("continue")
} else {
// no shell integration detected, we'll fallback to running the command and capturing the terminal's output after some time
telemetryService.captureTerminalOutputFailure(TerminalOutputFailureReason.NO_SHELL_INTEGRATION)
terminal.sendText(command, true)
// wait 3 seconds for the command to run
@@ -210,6 +225,13 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
// For terminals without shell integration, also try to capture terminal content
await returnCurrentTerminalContents()
// Check if clipboard fallback worked
const terminalSnapshot = await getLatestTerminalOutput()
if (terminalSnapshot && terminalSnapshot.trim()) {
telemetryService.captureTerminalExecution(true, "clipboard")
} else {
telemetryService.captureTerminalExecution(false, "none")
}
// For terminals without shell integration, we can't know when the command completes
// So we'll just emit the continue event after a delay
this.emit("completed")
+4 -2
View File
@@ -1,3 +1,4 @@
import { workspaceResolver } from "@core/workspace"
import { arePathsEqual } from "@utils/path"
import { globby, Options } from "globby"
import * as os from "os"
@@ -39,7 +40,7 @@ function isRestrictedPath(absolutePath: string): boolean {
}
function isTargetingHiddenDirectory(absolutePath: string): boolean {
const dirName = path.basename(absolutePath)
const dirName = workspaceResolver.getBasename(absolutePath, "Services.glob.isTargetingHiddenDirectory")
return dirName.startsWith(".")
}
@@ -57,7 +58,8 @@ function buildIgnorePatterns(absolutePath: string): string[] {
}
export async function listFiles(dirPath: string, recursive: boolean, limit: number): Promise<[string[], boolean]> {
const absolutePath = path.resolve(dirPath)
const absolutePathResult = workspaceResolver.resolveWorkspacePath(dirPath, "", "Services.glob.listFiles")
const absolutePath = typeof absolutePathResult === "string" ? absolutePathResult : absolutePathResult.absolutePath
// Do not allow listing files in root or home directory
if (isRestrictedPath(absolutePath)) {
+19 -41
View File
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, it } from "mocha"
import * as sinon from "sinon"
import * as vscode from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import { getDistinctId, initializeDistinctId, setDistinctId } from "@/services/logging/distinctId"
import { _GENERATED_MACHINE_ID_KEY, getDistinctId, initializeDistinctId, setDistinctId } from "@/services/logging/distinctId"
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
describe("distinctId", () => {
@@ -12,8 +12,9 @@ describe("distinctId", () => {
let mockGlobalState: any
const MOCK_GLOBAL_STATE_ID = "existing-distinct-id-123"
const MOCK_UUID = "mock-uuid-12345678-1234-1234-1234-123456789012"
const MOCK_MACHINE_ID = "machine-id-456"
const MOCK_UUID = "mock-uuid-12345678-1234-1234-1234-123456789012"
const GENERATED_MACHINE_ID = "cl-" + MOCK_UUID
const mockUuidGenerator = () => MOCK_UUID
@@ -21,32 +22,10 @@ describe("distinctId", () => {
sandbox = sinon.createSandbox()
// Mock global state
mockGlobalState = {
get: sandbox.stub(),
update: sandbox.stub(),
keys: sandbox.stub().returns([]),
setKeysForSync: sandbox.stub(),
}
mockGlobalState = { get: sandbox.stub(), update: sandbox.stub() }
// Mock extension context
mockContext = {
globalState: mockGlobalState,
workspaceState: {} as any,
secrets: {} as any,
extensionUri: {} as any,
extensionPath: "",
asAbsolutePath: sandbox.stub(),
storagePath: "",
globalStoragePath: "",
logPath: "",
extensionMode: vscode.ExtensionMode.Development,
subscriptions: [],
environmentVariableCollection: {} as any,
storageUri: {} as any,
globalStorageUri: {} as any,
logUri: {} as any,
extension: {} as any,
}
mockContext = { globalState: mockGlobalState } as unknown as vscode.ExtensionContext
// Mock vscode workspace
setVscodeHostProviderMock()
@@ -62,18 +41,17 @@ describe("distinctId", () => {
})
it("should use id from extension globalstate if it exists", async () => {
mockGlobalState.get.withArgs("cline.distinctId").returns(MOCK_GLOBAL_STATE_ID)
mockGlobalState.get.withArgs(_GENERATED_MACHINE_ID_KEY).returns(MOCK_GLOBAL_STATE_ID)
const getMachineIdStub = sandbox.stub(HostProvider.env, "getMachineId").resolves({ value: MOCK_MACHINE_ID })
await initializeDistinctId(mockContext, mockUuidGenerator)
expect(getDistinctId()).to.equal(MOCK_GLOBAL_STATE_ID)
expect(getMachineIdStub.calledOnce).to.be.true
expect(getMachineIdStub.notCalled).to.be.true
expect(mockGlobalState.update.notCalled).to.be.true
})
it("should use the host machine ID", async () => {
mockGlobalState.get.withArgs("cline.distinctId").returns(undefined)
// Mock getMachineId to return a machine ID
const getMachineIdStub = sandbox.stub(HostProvider.env, "getMachineId").resolves({ value: MOCK_MACHINE_ID })
@@ -81,11 +59,11 @@ describe("distinctId", () => {
expect(getDistinctId()).to.equal(MOCK_MACHINE_ID)
expect(getMachineIdStub.calledOnce).to.be.true
expect(mockGlobalState.update.notCalled).to.be.true
})
it("distinct ID should be stable", async () => {
mockGlobalState.get.withArgs("cline.distinctId").returns(undefined)
mockGlobalState.get.withArgs(_GENERATED_MACHINE_ID_KEY).returns(undefined)
// Mock getMachineId to return a machine ID
sandbox.stub(HostProvider.env, "getMachineId").resolves({ value: MOCK_MACHINE_ID })
@@ -94,31 +72,31 @@ describe("distinctId", () => {
await initializeDistinctId(mockContext, mockUuidGenerator)
expect(getDistinctId()).to.equal(MOCK_MACHINE_ID)
expect(mockGlobalState.update.notCalled).to.be.true
})
it("should generate a UUID if there is no host ID", async () => {
mockGlobalState.get.withArgs("cline.distinctId").returns(undefined)
it("should generate and store UUID if there is no host ID", async () => {
mockGlobalState.get.withArgs(_GENERATED_MACHINE_ID_KEY).returns(undefined)
// Mock getMachineId to return undefined
const getMachineIdStub = sandbox.stub(HostProvider.env, "getMachineId").resolves({ value: "" })
await initializeDistinctId(mockContext, mockUuidGenerator)
const distinctId = getDistinctId()
expect(distinctId).to.equal(MOCK_UUID)
expect(getDistinctId()).to.equal(GENERATED_MACHINE_ID)
expect(getMachineIdStub.calledOnce).to.be.true
expect(mockGlobalState.update.calledWith(_GENERATED_MACHINE_ID_KEY, GENERATED_MACHINE_ID)).to.be.true
})
it("should handle getMachineId errors gracefully", async () => {
mockGlobalState.get.withArgs("cline.distinctId").returns(undefined)
mockGlobalState.get.withArgs(_GENERATED_MACHINE_ID_KEY).returns(undefined)
// Mock getMachineId to throw an error
const getMachineIdStub = sandbox.stub(HostProvider.env, "getMachineId").rejects(new Error("Network error"))
await initializeDistinctId(mockContext, mockUuidGenerator)
const distinctId = getDistinctId()
expect(distinctId).to.equal(MOCK_UUID)
expect(getDistinctId()).to.equal(GENERATED_MACHINE_ID)
expect(getMachineIdStub.calledOnce).to.be.true
expect(mockGlobalState.update.calledWith(_GENERATED_MACHINE_ID_KEY, GENERATED_MACHINE_ID)).to.be.true
})
})
+20 -12
View File
@@ -2,29 +2,37 @@ import { v4 as uuidv4 } from "uuid"
import { ExtensionContext } from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import { EmptyRequest } from "@/shared/proto/cline/common"
import { Logger } from "./Logger"
/*
* Unique identifier for the current installation.
*/
let _distinctId: string = ""
/**
* Some environments don't return a value for the machine ID. For these situations we generated
* a unique ID and store it locally.
*/
export const _GENERATED_MACHINE_ID_KEY = "cline.generatedMachineId"
export async function initializeDistinctId(context: ExtensionContext, uuid: () => string = uuidv4) {
// NOTE: Backward compatibility in case where cline.distinctId was set in older versions
const existingId = context.globalState.get<string>("cline.distinctId")
const machineId = await getMachineId()
let distinctId = existingId || machineId
// Try to read the ID from storage.
let distinctId = context.globalState.get<string>(_GENERATED_MACHINE_ID_KEY)
if (!distinctId) {
console.warn("No machine ID found, generating UUID")
distinctId = uuid()
// Get the ID from the host environment.
distinctId = await getMachineId()
}
if (!distinctId) {
// Fallback to generating a unique ID and keeping in global storage.
console.warn("No machine ID found for telemetry, generating UUID")
// Add a prefix to the UUID so we can see in the telemetry how many clients are don't have a machine ID.
distinctId = "cl-" + uuid()
context.globalState.update(_GENERATED_MACHINE_ID_KEY, distinctId)
}
setDistinctId(distinctId)
if (process.env.IS_DEV) {
console.log("Telemetry distinct ID initialized:", distinctId)
}
console.log("Telemetry distinct ID initialized:", distinctId)
}
/*
@@ -34,8 +42,8 @@ async function getMachineId(): Promise<string | undefined> {
try {
const response = await HostProvider.env.getMachineId(EmptyRequest.create({}))
return response.value
} catch (e) {
Logger.warn(`Failed to get machine ID: ${e instanceof Error ? e.message : String(e)}`)
} catch (error) {
console.log("Failed to get machine ID", error)
return undefined
}
}
+7 -1
View File
@@ -1,4 +1,5 @@
import { ClineIgnoreController } from "@core/ignore/ClineIgnoreController"
import { workspaceResolver } from "@core/workspace"
import { fileExistsAtPath } from "@utils/fs"
import * as childProcess from "child_process"
import * as path from "path"
@@ -63,7 +64,12 @@ const MAX_RESULTS = 300
export async function getBinPath(vscodeAppRoot: string): Promise<string | undefined> {
const checkPath = async (pkgFolder: string) => {
const fullPath = path.join(vscodeAppRoot, pkgFolder, binName)
const fullPathResult = workspaceResolver.resolveWorkspacePath(
vscodeAppRoot,
path.join(pkgFolder, binName),
"Services.ripgrep.getBinPath",
)
const fullPath = typeof fullPathResult === "string" ? fullPathResult : fullPathResult.absolutePath
return (await fileExistsAtPath(fullPath)) ? fullPath : undefined
}
@@ -33,10 +33,7 @@ export class TelemetryProviderFactory {
return new PostHogTelemetryProvider(sharedClient)
}
return new NoOpTelemetryProvider()
case "none":
return new NoOpTelemetryProvider()
default:
console.error(`Unsupported telemetry provider type: ${config.type}`)
return new NoOpTelemetryProvider()
}
}
@@ -18,6 +18,33 @@ import { TelemetryProviderFactory } from "./TelemetryProviderFactory"
*/
type TelemetryCategory = "checkpoints" | "browser" | "focus_chain"
/**
* Enum for terminal output failure reasons
*/
export enum TerminalOutputFailureReason {
TIMEOUT = "timeout",
NO_SHELL_INTEGRATION = "no_shell_integration",
CLIPBOARD_FAILED = "clipboard_failed",
}
/**
* Enum for terminal user intervention actions
*/
export enum TerminalUserInterventionAction {
PROCESS_WHILE_RUNNING = "process_while_running",
MANUAL_PASTE = "manual_paste",
CANCELLED = "cancelled",
}
/**
* Enum for terminal hang stages
*/
export enum TerminalHangStage {
WAITING_FOR_COMPLETION = "waiting_for_completion",
BUFFER_STUCK = "buffer_stuck",
STREAM_TIMEOUT = "stream_timeout",
}
export type TelemetryMetadata = {
/** The extension or cline-core version. */
extension_version: string
@@ -126,6 +153,11 @@ export class TelemetryService {
AUTO_CONDENSE_TOGGLED: "task.auto_condense_toggled",
// Tracks task initialization timing
INITIALIZATION: "task.initialization",
// Terminal execution telemetry events
TERMINAL_EXECUTION: "task.terminal_execution",
TERMINAL_OUTPUT_FAILURE: "task.terminal_output_failure",
TERMINAL_USER_INTERVENTION: "task.terminal_user_intervention",
TERMINAL_HANG: "task.terminal_hang",
},
// UI interaction events for tracking user engagement
UI: {
@@ -917,6 +949,62 @@ export class TelemetryService {
})
}
// Terminal telemetry methods
/**
* Records terminal command execution outcomes
* @param success Whether the command output was successfully captured
* @param method The method used to capture output ("shell_integration" | "clipboard" | "none")
*/
public captureTerminalExecution(success: boolean, method: "shell_integration" | "clipboard" | "none") {
this.capture({
event: TelemetryService.EVENTS.TASK.TERMINAL_EXECUTION,
properties: {
success,
method,
},
})
}
/**
* Records when terminal output capture fails
* @param reason The reason for failure
*/
public captureTerminalOutputFailure(reason: TerminalOutputFailureReason) {
this.capture({
event: TelemetryService.EVENTS.TASK.TERMINAL_OUTPUT_FAILURE,
properties: {
reason,
},
})
}
/**
* Records when user has to intervene with terminal execution
* @param action The user action
*/
public captureTerminalUserIntervention(action: TerminalUserInterventionAction) {
this.capture({
event: TelemetryService.EVENTS.TASK.TERMINAL_USER_INTERVENTION,
properties: {
action,
},
})
}
/**
* Records when terminal execution hangs or gets stuck
* @param stage Where the hang occurred
*/
public captureTerminalHang(stage: TerminalHangStage) {
this.capture({
event: TelemetryService.EVENTS.TASK.TERMINAL_HANG,
properties: {
stage,
},
})
}
/**
* Checks if a specific telemetry category is enabled
* @param category The telemetry category to check
+7
View File
@@ -9,6 +9,13 @@ export {
type TelemetryProviderType,
} from "./TelemetryProviderFactory"
// Export the enums for terminal telemetry
export {
TerminalHangStage,
TerminalOutputFailureReason,
TerminalUserInterventionAction,
} from "./TelemetryService"
// Create a singleton instance for easy access throughout the application
import { TelemetryService } from "./TelemetryService"
@@ -188,6 +188,15 @@ export function convertClineMessageToProto(message: AppClineMessage): ProtoCline
endIndex: message.conversationHistoryDeletedRange[1],
}
: undefined,
// Additional optional fields for specific ask/say types
sayTool: undefined,
sayBrowserAction: undefined,
browserActionResult: undefined,
askUseMcpServer: undefined,
planModeResponse: undefined,
askQuestion: undefined,
askNewTask: undefined,
apiReqInfo: undefined,
}
return protoMessage
+11 -2
View File
@@ -26,5 +26,14 @@ const posthogDevEnvConfig = {
uiHost: "https://us.i.posthog.com",
} satisfies PostHogClientConfig
// NOTE: Ensure that dev environment is only used in CI or local dev, never in production
export const posthogConfig = process.env.CI || process.env.IS_DEV === "true" ? posthogDevEnvConfig : posthogProdConfig
const posthogTestEnvConfig = {
...posthogDevEnvConfig,
apiKey: undefined,
errorTrackingApiKey: undefined,
} satisfies PostHogClientConfig
const isTestEnv = process.env.IS_TEST === "true" || process.env.E2E_TEST
const isDevEnv = process.env.IS_DEV === "true" || process.env.CLINE_ENVIRONMENT === "local"
// NOTE: Ensure the correct config is used based on environment
export const posthogConfig = isTestEnv ? posthogTestEnvConfig : isDevEnv ? posthogDevEnvConfig : posthogProdConfig
+18
View File
@@ -35,6 +35,22 @@ export class E2ETestHelper {
return label ? path.join(testDir, label) : testDir
}
/**
* Generates a filename for gRPC recorder logs based on test information
* @param testTitle The title of the test
* @param projectName The name of the test project (optional)
* @returns A sanitized filename suitable for gRPC recorder logs
*/
public static generateTestFileName(testTitle: string, projectName?: string): string {
// Create a base name from the test title
const baseName = E2ETestHelper.escapeToPath(testTitle)
// Add project name if provided and different from default
const projectSuffix = projectName && projectName !== "e2e tests" ? `_${E2ETestHelper.escapeToPath(projectName)}` : ""
return `${baseName}${projectSuffix}`
}
public static async waitUntil(predicate: () => boolean | Promise<boolean>, maxDelay = 5000): Promise<void> {
let delay = 10
const start = Date.now()
@@ -220,6 +236,8 @@ export const e2e = test
TEMP_PROFILE: "true",
E2E_TEST: "true",
CLINE_ENVIRONMENT: "local",
GRPC_RECORDER_FILE_NAME: E2ETestHelper.generateTestFileName(testInfo.title, testInfo.project.name),
// GRPC_RECORDER_ENABLED: "true",
// IS_DEV: "true",
// DEV_WORKSPACE_FOLDER: E2ETestHelper.CODEBASE_ROOT_DIR,
},
+11 -1
View File
@@ -1,3 +1,4 @@
import { workspaceResolver } from "@core/workspace"
import fs from "fs/promises"
import * as path from "path"
@@ -116,7 +117,16 @@ export const readDirectory = async (directoryPath: string, excludedPaths: string
.readdir(directoryPath, { withFileTypes: true, recursive: true })
.then((entries) => entries.filter((entry) => !OS_GENERATED_FILES.includes(entry.name)))
.then((entries) => entries.filter((entry) => entry.isFile()))
.then((files) => files.map((file) => path.resolve(file.parentPath, file.name)))
.then((files) =>
files.map((file) => {
const resolvedPath = workspaceResolver.resolveWorkspacePath(
file.parentPath,
file.name,
"Utils.fs.readDirectory",
)
return typeof resolvedPath === "string" ? resolvedPath : resolvedPath.absolutePath
}),
)
.then((filePaths) =>
filePaths.filter((filePath) => {
if (excludedPaths.length === 0) {
+19 -5
View File
@@ -1,3 +1,4 @@
import { workspaceResolver } from "@core/workspace"
import os from "os"
import * as path from "path"
import { HostProvider } from "@/hosts/host-provider"
@@ -82,13 +83,15 @@ function normalizePath(p: string): string {
export function getReadablePath(cwd: string, relPath?: string): string {
relPath = relPath || ""
// path.resolve is flexible in that it will resolve relative paths like '../../' to the cwd and even ignore the cwd if the relPath is actually an absolute path
const absolutePath = path.resolve(cwd, relPath)
const absolutePathResult = workspaceResolver.resolveWorkspacePath(cwd, relPath, "Utils.path.getReadablePath")
const absolutePath = typeof absolutePathResult === "string" ? absolutePathResult : absolutePathResult.absolutePath
if (arePathsEqual(cwd, getDesktopDir())) {
// User opened vscode without a workspace, so cwd is the Desktop. Show the full absolute path to keep the user aware of where files are being created
return absolutePath.toPosix()
}
if (arePathsEqual(path.normalize(absolutePath), path.normalize(cwd))) {
return path.basename(absolutePath).toPosix()
const basenameResult = workspaceResolver.getBasename(absolutePath, "Utils.path.getReadablePath")
return basenameResult.toPosix()
} else {
// show the relative path to the cwd
const normalizedRelPath = path.relative(cwd, absolutePath)
@@ -108,7 +111,8 @@ export async function getCwd(defaultCwd = ""): Promise<string> {
}
export function getDesktopDir() {
return path.join(os.homedir(), "Desktop")
const desktopResult = workspaceResolver.resolveWorkspacePath(os.homedir(), "Desktop", "Utils.path.getDesktopDir")
return typeof desktopResult === "string" ? desktopResult : desktopResult.absolutePath
}
// Returns the workspace path of the file in the current editor.
@@ -131,7 +135,12 @@ export async function getWorkspacePath(defaultCwd = ""): Promise<string> {
export async function isLocatedInWorkspace(pathToCheck: string = ""): Promise<boolean> {
const workspacePaths = (await HostProvider.workspace.getWorkspacePaths({})).paths
for (const workspacePath of workspacePaths) {
const resolvedPath = path.resolve(workspacePath, pathToCheck)
const resolvedPathResult = workspaceResolver.resolveWorkspacePath(
workspacePath,
pathToCheck,
"Utils.path.isLocatedInWorkspace",
)
const resolvedPath = typeof resolvedPathResult === "string" ? resolvedPathResult : resolvedPathResult.absolutePath
if (isLocatedInPath(workspacePath, resolvedPath)) {
return true
}
@@ -149,7 +158,12 @@ export function isLocatedInPath(dirPath: string, pathToCheck: string): boolean {
return pathToCheck.startsWith(dirPath)
}
const relativePath = path.relative(path.resolve(dirPath), path.resolve(pathToCheck))
const resolvedDirResult = workspaceResolver.resolveWorkspacePath(dirPath, "", "Utils.path.isLocatedInPath")
const resolvedDir = typeof resolvedDirResult === "string" ? resolvedDirResult : resolvedDirResult.absolutePath
const resolvedCheckResult = workspaceResolver.resolveWorkspacePath(pathToCheck, "", "Utils.path.isLocatedInPath")
const resolvedCheck = typeof resolvedCheckResult === "string" ? resolvedCheckResult : resolvedCheckResult.absolutePath
const relativePath = path.relative(resolvedDir, resolvedCheck)
if (relativePath.startsWith("..")) {
return false
}
+10 -3
View File
@@ -1,5 +1,5 @@
import { workspaceResolver } from "@core/workspace"
import getFolderSize from "get-folder-size"
import path from "path"
/**
* Gets the total size of tasks and checkpoints directories
@@ -7,8 +7,15 @@ import path from "path"
* @returns The total size in bytes, or null if calculation fails
*/
export async function getTotalTasksSize(storagePath: string): Promise<number | null> {
const tasksDir = path.join(storagePath, "tasks")
const checkpointsDir = path.join(storagePath, "checkpoints")
const tasksDirResult = workspaceResolver.resolveWorkspacePath(storagePath, "tasks", "Utils.storage.getTotalTasksSize")
const checkpointsDirResult = workspaceResolver.resolveWorkspacePath(
storagePath,
"checkpoints",
"Utils.storage.getTotalTasksSize",
)
const tasksDir = typeof tasksDirResult === "string" ? tasksDirResult : tasksDirResult.absolutePath
const checkpointsDir = typeof checkpointsDirResult === "string" ? checkpointsDirResult : checkpointsDirResult.absolutePath
try {
const tasksSize = await getFolderSize.loose(tasksDir)
+3
View File
@@ -27,6 +27,9 @@
"@/*": [
"src/*"
],
"@api/*": [
"src/core/api/*"
],
"@core/*": [
"src/core/*"
],
+3 -3
View File
@@ -238,10 +238,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// Listen for local focusChatInput event
useEffect(() => {
const handleFocusChatInput = () => {
if (isHidden) {
navigateToChat()
// Only focus chat input box if user is currently viewing the chat (not hidden).
if (!isHidden) {
textAreaRef.current?.focus()
}
textAreaRef.current?.focus()
}
window.addEventListener("focusChatInput", handleFocusChatInput)
@@ -324,7 +324,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
}}>
History
</h3>
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
<VSCodeButton onClick={() => onDone()}>Done</VSCodeButton>
</div>
<div style={{ padding: "5px 17px 6px 17px" }}>
<div
@@ -393,19 +393,9 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
/>
</VSCodeRadioGroup>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "10px" }}>
<VSCodeButton
onClick={() => {
handleBatchHistorySelect(true)
}}>
Select All
</VSCodeButton>
<VSCodeButton
onClick={() => {
handleBatchHistorySelect(false)
}}>
Select None
</VSCodeButton>
<div className="flex justify-end gap-2.5">
<VSCodeButton onClick={() => handleBatchHistorySelect(true)}>Select All</VSCodeButton>
<VSCodeButton onClick={() => handleBatchHistorySelect(false)}>Select None</VSCodeButton>
</div>
</div>
</div>
@@ -7,7 +7,7 @@ import { useExtensionState } from "../../../context/ExtensionStateContext"
import { BrowserServiceClient } from "../../../services/grpc-client"
import { DebouncedTextField } from "../common/DebouncedTextField"
import Section from "../Section"
import { updateBrowserSetting } from "../utils/settingsHandlers"
import { updateSetting } from "../utils/settingsHandlers"
interface BrowserSettingsSectionProps {
renderSectionHeader: (tabId: string) => JSX.Element | null
@@ -131,9 +131,11 @@ export const BrowserSettingsSection: React.FC<BrowserSettingsSectionProps> = ({
const target = event.target as HTMLSelectElement
const selectedSize = BROWSER_VIEWPORT_PRESETS[target.value as keyof typeof BROWSER_VIEWPORT_PRESETS]
if (selectedSize) {
updateBrowserSetting("viewport", {
width: selectedSize.width,
height: selectedSize.height,
updateSetting("browserSettings", {
viewport: {
width: selectedSize.width,
height: selectedSize.height,
},
})
}
}
@@ -174,7 +176,9 @@ export const BrowserSettingsSection: React.FC<BrowserSettingsSectionProps> = ({
<div style={{ marginBottom: isSubSettingsOpen ? 0 : 10 }}>
<VSCodeCheckbox
checked={browserSettings.disableToolUse || false}
onChange={(e) => updateBrowserSetting("disableToolUse", (e.target as HTMLInputElement).checked)}>
onChange={(e) =>
updateSetting("browserSettings", { disableToolUse: (e.target as HTMLInputElement).checked })
}>
Disable browser tool usage
</VSCodeCheckbox>
<p
@@ -234,10 +238,10 @@ export const BrowserSettingsSection: React.FC<BrowserSettingsSectionProps> = ({
checked={browserSettings.remoteBrowserEnabled}
onChange={(e) => {
const enabled = (e.target as HTMLInputElement).checked
updateBrowserSetting("remoteBrowserEnabled", enabled)
updateSetting("browserSettings", { remoteBrowserEnabled: enabled })
// If disabling, also clear the host
if (!enabled) {
updateBrowserSetting("remoteBrowserHost", undefined)
updateSetting("browserSettings", { remoteBrowserHost: undefined })
}
}}>
Use remote browser connection
@@ -277,7 +281,9 @@ export const BrowserSettingsSection: React.FC<BrowserSettingsSectionProps> = ({
<div style={{ marginLeft: 0, marginTop: 8 }}>
<DebouncedTextField
initialValue={browserSettings.remoteBrowserHost || ""}
onChange={(value) => updateBrowserSetting("remoteBrowserHost", value || undefined)}
onChange={(value) =>
updateSetting("browserSettings", { remoteBrowserHost: value || undefined })
}
placeholder="http://localhost:9222"
style={{ width: "100%", marginBottom: 8 }}
/>
@@ -331,7 +337,7 @@ export const BrowserSettingsSection: React.FC<BrowserSettingsSectionProps> = ({
<DebouncedTextField
id="chrome-executable-path"
initialValue={browserSettings.chromeExecutablePath || ""}
onChange={(value) => updateBrowserSetting("chromeExecutablePath", value)}
onChange={(value) => updateSetting("browserSettings", { chromeExecutablePath: value })}
placeholder="e.g., /usr/bin/google-chrome or C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
style={{ width: "100%" }}
/>
@@ -354,7 +360,7 @@ export const BrowserSettingsSection: React.FC<BrowserSettingsSectionProps> = ({
<DebouncedTextField
id="custom-browser-args"
initialValue={browserSettings.customArgs || ""}
onChange={(value) => updateBrowserSetting("customArgs", value)}
onChange={(value) => updateSetting("browserSettings", { customArgs: value })}
placeholder="e.g., --no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage --disable-gpu --no-first-run --no-zygote"
style={{ width: "100%" }}
/>
@@ -1,4 +1,3 @@
import { StringRequest } from "@shared/proto/cline/common"
import { UpdateTerminalConnectionTimeoutResponse } from "@shared/proto/index.cline"
import { VSCodeCheckbox, VSCodeDropdown, VSCodeOption, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import React, { useState } from "react"
@@ -66,12 +65,8 @@ export const TerminalSettingsSection: React.FC<TerminalSettingsSectionProps> = (
const target = event.target as HTMLSelectElement
const profileId = target.value
// Save immediately - the backend will call postStateToWebview() to update our state
StateServiceClient.updateDefaultTerminalProfile({
value: profileId || "default",
} as StringRequest).catch((error) => {
console.error("Failed to update default terminal profile:", error)
})
// Save immediately using the consolidated updateSettings approach
updateSetting("defaultTerminalProfile", profileId || "default")
}
const profilesToShow = availableTerminalProfiles
@@ -1,6 +1,5 @@
import { UpdateBrowserSettingsRequest } from "@shared/proto/cline/browser"
import { McpDisplayMode, OpenaiReasoningEffort, UpdateSettingsRequest } from "@shared/proto/cline/state"
import { BrowserServiceClient, StateServiceClient } from "@/services/grpc-client"
import { StateServiceClient } from "@/services/grpc-client"
/**
* Converts values to their corresponding proto format
@@ -52,20 +51,3 @@ export const updateSetting = (field: keyof UpdateSettingsRequest, value: any) =>
console.error(`Failed to update setting ${field}:`, error)
})
}
/**
* Updates a single browser setting field.
*
* @param field - The field key to update
* @param value - The new value for the field
*/
export const updateBrowserSetting = (field: keyof UpdateBrowserSettingsRequest, value: any) => {
const updateRequest: Partial<UpdateBrowserSettingsRequest> = {
metadata: {},
[field]: value,
}
BrowserServiceClient.updateBrowserSettings(UpdateBrowserSettingsRequest.create(updateRequest)).catch((error) => {
console.error(`Failed to update browser setting ${field}:`, error)
})
}