mirror of
https://github.com/cline/cline.git
synced 2026-09-02 15:52:29 +08:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 290798ef0f | |||
| 0adb1046e4 | |||
| 324564af72 | |||
| 291287c8e4 | |||
| d69fb10cfd | |||
| 0f6eab2bfe | |||
| 016ac1eade | |||
| 2606ed7abf | |||
| 3ed4d55a36 | |||
| a3b4ad53dc | |||
| bd98955f57 | |||
| 297cfbb895 | |||
| b4c95fbeab | |||
| 5e8102e588 | |||
| 60f2c3a22b | |||
| 7472b1cb4a | |||
| 794d8bccd3 | |||
| 0db3cfe34d | |||
| 5c878b5b84 | |||
| e3bc5f0143 | |||
| f2dbab814e |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Adding telemetry for terminal command issue
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Adding a grpc recording class with its builder
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix issue where editor panel gets reset to chat view on each chat input focus event
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Adding a new standalone core api server script for local and integration testing purposes
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Setup Raw Structure for implementing multi-workspace support with WorkspaceRoot
|
||||
@@ -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
|
||||
|
||||
Vendored
+51
-7
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,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;
|
||||
|
||||
@@ -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.
|
||||
]
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
// }
|
||||
// ]
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
"@/*": [
|
||||
"src/*"
|
||||
],
|
||||
"@api/*": [
|
||||
"src/core/api/*"
|
||||
],
|
||||
"@core/*": [
|
||||
"src/core/*"
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user