Compare commits

..
Author SHA1 Message Date
Saoud Rizwan 74cbf31cc3 fix: clear streaming decorations via onFinalUpdate hook
The PR's safelyTruncateDocument() skips calling truncateDocument() when
there's nothing to truncate. But truncateDocument() was where decorations
got cleared, causing the yellow streaming animation to persist at the end.

Fix: Add onFinalUpdate() hook that's always called after the final update.
VscodeDiffViewProvider overrides it to clear decorations.
2026-01-16 17:02:40 -08:00
Robin Newhouse c2354c9832 fix: preserve trailing newlines in diff text ops
Align splitLines with JS split behavior and keep trailing
newline segments when replacing to end of document to avoid
dropping final line breaks.
2026-01-15 22:11:54 -08:00
Robin Newhouse df36557c04 fix: preserve trailing newlines in file edits
Trailing newlines were being incorrectly stripped during file edits due to
trimEnd() calls in handlers. This caused files to lose their final newline
even when the original file had one.

Changes:
- Remove trimEnd() from WriteToFileToolHandler and ApplyPatchHandler that
  was stripping trailing newlines before content reached the editor
- Remove dead code in DiffViewProvider.update() that tried to restore
  newlines after the document was already written
- Add trailing newline fix-up in VscodeDiffViewProvider to handle VS Code's
  applyEdit sometimes normalizing newlines on full-document replacements
- Fix FileEditProvider.replaceText() to preserve trailing newlines when
  replacing to end of document
2026-01-15 22:11:54 -08:00
Robin Newhouse dc9d3ae407 fix: DiffViewProvider line boundary validation and content concatenation
Two bugs in DiffViewProvider caused file editing failures:

1. **Line boundary validation errors (#8423, #8429)**
   JetBrains hosts using gRPC strictly validate line numbers. When
   truncateDocument() was called with a line number >= document line count,
   it caused "truncateDocument INTERNAL: Wrong line" errors. This occurred
   when new content had >= lines than the original, making truncation
   unnecessary but still attempted.

2. **Content concatenation on final update**
   When replacing content without a trailing newline, the old content at
   line N+1 was concatenated to the new content. For example, writing
   "Hello World" to a file containing "line1\nline2\n" resulted in
   "Hello Worldline2" instead of just "Hello World".

1. Added `getDocumentLineCount()` abstract method to all DiffViewProvider
   implementations to query the current document line count.

2. Added `safelyTruncateDocument()` private helper that validates line
   numbers before calling truncateDocument():
   ```typescript
   private async safelyTruncateDocument(lineNumber: number): Promise<void> {
     const lineCount = await this.getDocumentLineCount()
     if (lineNumber < lineCount) {
       await this.truncateDocument(lineNumber)
     }
   }
   ```

3. Extended the replacement range on final update to cover the entire
   document, preventing content concatenation:
   ```typescript
   const endLine = isFinal
     ? await this.getDocumentLineCount()
     : currentLine + 1
   ```

- src/integrations/editor/DiffViewProvider.ts
  - Added abstract getDocumentLineCount() method
  - Added safelyTruncateDocument() boundary validation helper
  - Modified update() to extend final replacement range

- src/hosts/vscode/VscodeDiffViewProvider.ts
  - Implemented getDocumentLineCount() using editor.document.lineCount

- src/hosts/external/ExternalDiffviewProvider.ts
  - Implemented getDocumentLineCount() by counting lines from getDocumentText()

- src/integrations/editor/FileEditProvider.ts
  - Implemented getDocumentLineCount() from documentContent

- src/integrations/editor/__tests__/DiffViewProvider.test.ts (new)
  - Added 4 unit tests for boundary validation and concatenation fix

Fixes #8423, #8429
2026-01-15 22:11:54 -08:00
98 changed files with 856 additions and 4492 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Expose --version in cline cli command
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
OpenAI GPT-5 Codex models are now using Apply Patch tool for diff edits.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix LiteLLM thinking configuration not showing for models (#8342)
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: add chat output on skill use
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Adding telemetry for background exec terminal
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Support native tool calling for LM Studio and Ollama provider
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Limite Vertex and LiteLLM options when they're remote configured
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix crash when the Context Menu has a type but no options
-5
View File
@@ -1,5 +0,0 @@
---
"cline": patch
---
Add git worktree management UI for running parallel Cline sessions
-193
View File
@@ -1,193 +0,0 @@
This file is the secret sauce for working effectively in this codebase. It captures tribal knowledge—the nuanced, non-obvious patterns that make the difference between a quick fix and hours of back-and-forth & human intervention.
**When to add to this file:**
- User had to intervene, correct, or hand-hold
- Multiple back-and-forth attempts were needed to get something working
- You discovered something that required reading many files to understand
- A change touched files you wouldn't have guessed
- Something worked differently than you expected
- User explicitly asks to "add this to CLAUDE.md"
**Proactively suggest additions** when any of the above happen—don't wait to be asked.
**What NOT to add:** Stuff you can figure out from reading a few files, obvious patterns, or standard practices. This file should be high-signal, not comprehensive.
## Miscellaneous
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `npm run compile`, not `npm run build`).
- When creating PRs, if the change is user-facing and significant enough to warrant a changelog entry, run `npm run changeset` and create a patch changeset. Never create minor or major version bumps. Skip changesets for trivial fixes, internal refactors, or minor UI tweaks that users wouldn't notice.
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
## gRPC/Protobuf Communication
The extension and webview communicate via gRPC-like protocol over VS Code message passing.
**Proto files live in `proto/`** (e.g., `proto/cline/task.proto`, `proto/cline/ui.proto`)
- Each feature domain has its own `.proto` file
- For simple data, use shared types in `proto/cline/common.proto` (`StringRequest`, `Empty`, `Int64Request`)
- For complex data, define custom messages in the feature's `.proto` file
- Naming: Services `PascalCaseService`, RPCs `camelCase`, Messages `PascalCase`
- For streaming responses, use `stream` keyword (see `subscribeToAuthCallback` in `account.proto`)
**Run `npm run protos`** after any proto changes—generates types in:
- `src/shared/proto/` - Shared type definitions
- `src/generated/grpc-js/` - Service implementations
- `src/generated/nice-grpc/` - Promise-based clients
- `src/generated/hosts/` - Generated handlers
**Adding new enum values** (like a new `ClineSay` type) requires updating conversion mappings in `src/shared/proto-conversions/cline-message.ts`
**Adding new RPC methods** requires:
- Handler in `src/core/controller/<domain>/`
- Call from webview via generated client: `UiServiceClient.scrollToSettings(StringRequest.create({ value: "browser" }))`
**Example—the `explain-changes` feature touched:**
- `proto/cline/task.proto` - Added `ExplainChangesRequest` message and `explainChanges` RPC
- `proto/cline/ui.proto` - Added `GENERATE_EXPLANATION = 29` to `ClineSay` enum
- `src/shared/ExtensionMessage.ts` - Added `ClineSayGenerateExplanation` type
- `src/shared/proto-conversions/cline-message.ts` - Added mapping for new say type
- `src/core/controller/task/explainChanges.ts` - Handler implementation
- `webview-ui/src/components/chat/ChatRow.tsx` - UI rendering
## Adding a New API Provider
When adding a new provider (e.g., "openai-codex"), you must update the proto conversion layer in THREE places or the provider will silently reset to Anthropic:
1. `proto/cline/models.proto` - Add to the `ApiProvider` enum (e.g., `OPENAI_CODEX = 40;`)
2. `convertApiProviderToProto()` in `src/shared/proto-conversions/models/api-configuration-conversion.ts` - Add case mapping string to proto enum
3. `convertProtoToApiProvider()` in the same file - Add case mapping proto enum back to string
**Why this matters:** Without these, the provider string hits the `default` case and returns `ANTHROPIC`. The webview, provider list, and handler all work fine, but the state silently resets when it round-trips through proto serialization. No error is thrown.
**Other files to update when adding a provider:**
- `src/shared/api.ts` - Add to `ApiProvider` union type, define models
- `src/shared/providers/providers.json` - Add to provider list for dropdown
- `src/core/api/index.ts` - Register handler in `createHandlerForProvider()`
- `webview-ui/src/components/settings/utils/providerUtils.ts` - Add cases in `getModelsForProvider()` and `normalizeApiConfiguration()`
- `webview-ui/src/utils/validate.ts` - Add validation case
- `webview-ui/src/components/settings/ApiOptions.tsx` - Render provider component
## Responses API Providers (OpenAI Codex, OpenAI Native)
Providers using OpenAI's Responses API require native tool calling. XML tools don't work with the Responses API.
**Symptoms of broken native tool calling:**
- Tools get called multiple times (e.g., `ask_followup_question` asks the same question twice)
- Tool arguments get duplicated or malformed
- The model responds but tools aren't recognized
**Root causes to check:**
1. **Provider missing from `isNextGenModelProvider()`** in `src/utils/model-utils.ts`. The native variant matchers (e.g., `native-gpt-5/config.ts`) call this function. If your provider isn't in the list, the matcher returns false and falls back to XML tools.
2. **Model missing `apiFormat: ApiFormat.OPENAI_RESPONSES`** in its model info (`src/shared/api.ts`). This property signals that the model requires native tool calling. The task runner in `src/core/task/index.ts` checks this and forces `enableNativeToolCalls: true` regardless of user settings.
**When adding a new Responses API provider:**
1. Add provider to `isNextGenModelProvider()` list in `src/utils/model-utils.ts`
2. Set `apiFormat: ApiFormat.OPENAI_RESPONSES` on all models that use the Responses API
3. The variant matcher and task runner will handle the rest automatically
## Adding Tools to System Prompt
This is tricky—multiple prompt variants and configs. **Always search for existing similar tools first and follow their pattern.** Look at the full chain from prompt definition → variant configs → handler → UI before implementing.
1. **Add to `ClineDefaultTool` enum** in `src/shared/tools.ts`
2. **Tool definition** in `src/core/prompts/system-prompt/tools/` (create file like `generate_explanation.ts`)
- Define variants for each `ModelFamily` (generic, next-gen, xs, etc.)
- Export variants array (e.g., `export const my_tool_variants = [GENERIC, NATIVE_NEXT_GEN, XS]`)
- **Fallback behavior**: If a variant isn't defined for a model family, `ClineToolSet.getToolByNameWithFallback()` automatically falls back to GENERIC. So you only need to export `[GENERIC]` unless the tool needs model-specific behavior.
3. **Register in `src/core/prompts/system-prompt/tools/init.ts`** - Import and spread into `allToolVariants`
4. **Add to variant configs** - Each model family has its own config in `src/core/prompts/system-prompt/variants/*/config.ts`. Add your tool's enum to the `.tools()` list:
- `generic/config.ts`, `next-gen/config.ts`, `gpt-5/config.ts`, `native-gpt-5/config.ts`, `native-gpt-5-1/config.ts`, `native-next-gen/config.ts`, `gemini-3/config.ts`, `glm/config.ts`, `hermes/config.ts`, `xs/config.ts`
- **Important**: If you add to a variant's config, make sure the tool spec exports a variant for that ModelFamily (or relies on GENERIC fallback)
5. **Create handler** in `src/core/task/tools/handlers/`
6. **Wire up in `ToolExecutor.ts`** if needed for execution flow
7. **Add to tool parsing** in `src/core/assistant-message/index.ts` if needed
8. **If tool has UI feedback**: add `ClineSay` enum in proto, update `src/shared/ExtensionMessage.ts`, update `src/shared/proto-conversions/cline-message.ts`, update `webview-ui/src/components/chat/ChatRow.tsx`
## Modifying System Prompt
**Read these first:** `src/core/prompts/system-prompt/README.md`, `tools/README.md`, `__tests__/README.md`
System prompt is modular: **components** (reusable sections) + **variants** (model-specific configs) + **templates** (with `{{PLACEHOLDER}}` resolution).
**Key directories:**
- `components/` - Shared sections: `rules.ts`, `capabilities.ts`, `editing_files.ts`, etc.
- `variants/` - Model-specific: `generic/`, `next-gen/`, `xs/`, `gpt-5/`, `gemini-3/`, `hermes/`, `glm/`, etc.
- `templates/` - Template engine and placeholder definitions
**Variant tiers (ask user which to modify):**
- **Next-gen** (Claude 4, GPT-5, Gemini 2.5): `next-gen/`, `native-next-gen/`, `native-gpt-5/`, `native-gpt-5-1/`, `gemini-3/`, `gpt-5/`
- **Standard** (default fallback): `generic/`
- **Local/small models**: `xs/`, `hermes/`, `glm/`
**How overrides work:** Variants can override components via `componentOverrides` in their `config.ts`, or provide a custom template in `template.ts` (e.g., `next-gen/template.ts` exports `rules_template`). If no override, the shared component from `components/` is used.
**Example: Adding a rule to RULES section**
1. Check if variant overrides rules: look for `rules_template` in `variants/*/template.ts` or `componentOverrides.RULES` in `config.ts`
2. If shared: modify `components/rules.ts`
3. If overridden: modify that variant's template
4. XS variant is special—has heavily condensed inline content in `template.ts`
**After any changes, regenerate snapshots:**
```bash
UPDATE_SNAPSHOTS=true npm run test:unit
```
Snapshots live in `__tests__/__snapshots__/`. Tests validate across model families and context variations (browser, MCP, focus chain).
## Modifying Default Slash Commands
Three places need updates:
- `src/core/slash-commands/index.ts` - Command definitions
- `src/core/prompts/commands.ts` - System prompt integration
- `webview-ui/src/utils/slash-commands.ts` - Webview autocomplete
## Adding New Global State Keys
Adding a new key to global state requires updates in multiple places. Missing any step causes silent failures.
Required steps:
1. Type definition in `src/shared/storage/state-keys.ts` - Add to `GlobalState` or `Settings` interface
2. Read from globalState in `src/core/storage/utils/state-helpers.ts`:
- Add `const myKey = context.globalState.get<GlobalStateAndSettings["myKey"]>("myKey")` in `readGlobalStateFromDisk()`
- Add to the return object: `myKey: myKey ?? defaultValue,`
3. StateManager handles read/write via `setGlobalState()`/`getGlobalStateKey()` after initialization
Common mistake: Adding only the return value without the `context.globalState.get()` call. This compiles but the value is always `undefined` on load.
## StateManager Cache vs Direct globalState Access
StateManager uses an in-memory cache populated during `StateManager.initialize(context)` in `common.ts`. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
Exception: State needed immediately at extension startup (before cache is ready)
When Window A sets state and immediately opens Window B, the new window's StateManager cache is populated from `context.globalState` during initialization. If you need to read state in Window B right at startup (e.g., in `common.ts` during `initialize()`), read directly from `context.globalState.get()` instead of StateManager's cache.
Example pattern (see `lastShownAnnouncementId` and `worktreeAutoOpenPath`):
```typescript
// Writing (normal pattern)
controller.stateManager.setGlobalState("myKey", value)
// Reading at startup in common.ts (bypass cache)
const value = context.globalState.get<string>("myKey")
```
This is only needed for cross-window state read during the brief startup window before StateManager cache is fully usable. Normal state access after initialization should use StateManager.
## ChatRow Cancelled/Interrupted States
When a ChatRow displays a loading/in-progress state (spinner), you must handle what happens when the task is cancelled. This is non-obvious because cancellation doesn't update the message content—you have to infer it from context.
**The pattern:**
1. A message has a `status` field (e.g., `"generating"`, `"complete"`, `"error"`) stored in `message.text` as JSON
2. When cancelled mid-operation, the status stays `"generating"` forever—no one updates it
3. To detect cancellation, check TWO conditions:
- `!isLast` — if this message is no longer the last message, something else happened after it (interrupted)
- `lastModifiedMessage?.ask === "resume_task" || "resume_completed_task"` — task was just cancelled and is waiting to resume
**Example from `generate_explanation`:**
```tsx
const wasCancelled =
explanationInfo.status === "generating" &&
(!isLast ||
lastModifiedMessage?.ask === "resume_task" ||
lastModifiedMessage?.ask === "resume_completed_task")
const isGenerating = explanationInfo.status === "generating" && !wasCancelled
```
**Why both checks?**
- `!isLast` catches: cancelled → resumed → did other stuff → this old message is stale
- `lastModifiedMessage?.ask === "resume_task"` catches: just cancelled, hasn't resumed yet, this message is still technically "last"
**See also:** `BrowserSessionRow.tsx` uses similar pattern with `isLastApiReqInterrupted` and `isLastMessageResume`.
**Backend side:** When streaming is cancelled, clean up properly (close tabs, clear comments, etc.) by checking `taskState.abort` after the streaming function returns.
-2
View File
@@ -1,8 +1,6 @@
# Default
.vscode/**
.vscode-test/**
.worktrees/**
CLAUDE.local.md
out/
dist-standalone/
node_modules/
+129 -2
View File
@@ -1,2 +1,129 @@
@.clinerules/general.md
@.clinerules/network.md
# CLAUDE.md
This file is the secret sauce for working effectively in this codebase. It captures tribal knowledge—the nuanced, non-obvious patterns that make the difference between a quick fix and hours of back-and-forth & human intervention.
**When to add to this file:**
- User had to intervene, correct, or hand-hold
- Multiple back-and-forth attempts were needed to get something working
- You discovered something that required reading many files to understand
- A change touched files you wouldn't have guessed
- Something worked differently than you expected
- User explicitly asks to "add this to CLAUDE.md"
**Proactively suggest additions** when any of the above happen—don't wait to be asked.
**What NOT to add:** Stuff you can figure out from reading a few files, obvious patterns, or standard practices. This file should be high-signal, not comprehensive.
## Miscellaneous
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `npm run compile`, not `npm run build`).
- When creating PRs, if the change is user-facing and significant enough to warrant a changelog entry, run `npm run changeset` and create a patch changeset. Never create minor or major version bumps. Skip changesets for trivial fixes, internal refactors, or minor UI tweaks that users wouldn't notice.
## gRPC/Protobuf Communication
The extension and webview communicate via gRPC-like protocol over VS Code message passing.
**Proto files live in `proto/`** (e.g., `proto/cline/task.proto`, `proto/cline/ui.proto`)
- Each feature domain has its own `.proto` file
- For simple data, use shared types in `proto/cline/common.proto` (`StringRequest`, `Empty`, `Int64Request`)
- For complex data, define custom messages in the feature's `.proto` file
- Naming: Services `PascalCaseService`, RPCs `camelCase`, Messages `PascalCase`
- For streaming responses, use `stream` keyword (see `subscribeToAuthCallback` in `account.proto`)
**Run `npm run protos`** after any proto changes—generates types in:
- `src/shared/proto/` - Shared type definitions
- `src/generated/grpc-js/` - Service implementations
- `src/generated/nice-grpc/` - Promise-based clients
- `src/generated/hosts/` - Generated handlers
**Adding new enum values** (like a new `ClineSay` type) requires updating conversion mappings in `src/shared/proto-conversions/cline-message.ts`
**Adding new RPC methods** requires:
- Handler in `src/core/controller/<domain>/`
- Call from webview via generated client: `UiServiceClient.scrollToSettings(StringRequest.create({ value: "browser" }))`
**Example—the `explain-changes` feature touched:**
- `proto/cline/task.proto` - Added `ExplainChangesRequest` message and `explainChanges` RPC
- `proto/cline/ui.proto` - Added `GENERATE_EXPLANATION = 29` to `ClineSay` enum
- `src/shared/ExtensionMessage.ts` - Added `ClineSayGenerateExplanation` type
- `src/shared/proto-conversions/cline-message.ts` - Added mapping for new say type
- `src/core/controller/task/explainChanges.ts` - Handler implementation
- `webview-ui/src/components/chat/ChatRow.tsx` - UI rendering
## Adding Tools to System Prompt
This is tricky—multiple prompt variants and configs. **Always search for existing similar tools first and follow their pattern.** Look at the full chain from prompt definition → variant configs → handler → UI before implementing.
1. **Add to `ClineDefaultTool` enum** in `src/shared/tools.ts`
2. **Tool definition** in `src/core/prompts/system-prompt/tools/` (create file like `generate_explanation.ts`)
- Define variants for each `ModelFamily` (generic, next-gen, xs, etc.)
- Export variants array (e.g., `export const my_tool_variants = [GENERIC, NATIVE_NEXT_GEN, XS]`)
- **Fallback behavior**: If a variant isn't defined for a model family, `ClineToolSet.getToolByNameWithFallback()` automatically falls back to GENERIC. So you only need to export `[GENERIC]` unless the tool needs model-specific behavior.
3. **Register in `src/core/prompts/system-prompt/tools/init.ts`** - Import and spread into `allToolVariants`
4. **Add to variant configs** - Each model family has its own config in `src/core/prompts/system-prompt/variants/*/config.ts`. Add your tool's enum to the `.tools()` list:
- `generic/config.ts`, `next-gen/config.ts`, `gpt-5/config.ts`, `native-gpt-5/config.ts`, `native-gpt-5-1/config.ts`, `native-next-gen/config.ts`, `gemini-3/config.ts`, `glm/config.ts`, `hermes/config.ts`, `xs/config.ts`
- **Important**: If you add to a variant's config, make sure the tool spec exports a variant for that ModelFamily (or relies on GENERIC fallback)
5. **Create handler** in `src/core/task/tools/handlers/`
6. **Wire up in `ToolExecutor.ts`** if needed for execution flow
7. **Add to tool parsing** in `src/core/assistant-message/index.ts` if needed
8. **If tool has UI feedback**: add `ClineSay` enum in proto, update `src/shared/ExtensionMessage.ts`, update `src/shared/proto-conversions/cline-message.ts`, update `webview-ui/src/components/chat/ChatRow.tsx`
## Modifying System Prompt
**Read these first:** `src/core/prompts/system-prompt/README.md`, `tools/README.md`, `__tests__/README.md`
System prompt is modular: **components** (reusable sections) + **variants** (model-specific configs) + **templates** (with `{{PLACEHOLDER}}` resolution).
**Key directories:**
- `components/` - Shared sections: `rules.ts`, `capabilities.ts`, `editing_files.ts`, etc.
- `variants/` - Model-specific: `generic/`, `next-gen/`, `xs/`, `gpt-5/`, `gemini-3/`, `hermes/`, `glm/`, etc.
- `templates/` - Template engine and placeholder definitions
**Variant tiers (ask user which to modify):**
- **Next-gen** (Claude 4, GPT-5, Gemini 2.5): `next-gen/`, `native-next-gen/`, `native-gpt-5/`, `native-gpt-5-1/`, `gemini-3/`, `gpt-5/`
- **Standard** (default fallback): `generic/`
- **Local/small models**: `xs/`, `hermes/`, `glm/`
**How overrides work:** Variants can override components via `componentOverrides` in their `config.ts`, or provide a custom template in `template.ts` (e.g., `next-gen/template.ts` exports `rules_template`). If no override, the shared component from `components/` is used.
**Example: Adding a rule to RULES section**
1. Check if variant overrides rules: look for `rules_template` in `variants/*/template.ts` or `componentOverrides.RULES` in `config.ts`
2. If shared: modify `components/rules.ts`
3. If overridden: modify that variant's template
4. XS variant is special—has heavily condensed inline content in `template.ts`
**After any changes, regenerate snapshots:**
```bash
UPDATE_SNAPSHOTS=true npm run test:unit
```
Snapshots live in `__tests__/__snapshots__/`. Tests validate across model families and context variations (browser, MCP, focus chain).
## Modifying Default Slash Commands
Three places need updates:
- `src/core/slash-commands/index.ts` - Command definitions
- `src/core/prompts/commands.ts` - System prompt integration
- `webview-ui/src/utils/slash-commands.ts` - Webview autocomplete
## ChatRow Cancelled/Interrupted States
When a ChatRow displays a loading/in-progress state (spinner), you must handle what happens when the task is cancelled. This is non-obvious because cancellation doesn't update the message content—you have to infer it from context.
**The pattern:**
1. A message has a `status` field (e.g., `"generating"`, `"complete"`, `"error"`) stored in `message.text` as JSON
2. When cancelled mid-operation, the status stays `"generating"` forever—no one updates it
3. To detect cancellation, check TWO conditions:
- `!isLast` — if this message is no longer the last message, something else happened after it (interrupted)
- `lastModifiedMessage?.ask === "resume_task" || "resume_completed_task"` — task was just cancelled and is waiting to resume
**Example from `generate_explanation`:**
```tsx
const wasCancelled =
explanationInfo.status === "generating" &&
(!isLast ||
lastModifiedMessage?.ask === "resume_task" ||
lastModifiedMessage?.ask === "resume_completed_task")
const isGenerating = explanationInfo.status === "generating" && !wasCancelled
```
**Why both checks?**
- `!isLast` catches: cancelled → resumed → did other stuff → this old message is stale
- `lastModifiedMessage?.ask === "resume_task"` catches: just cancelled, hasn't resumed yet, this message is still technically "last"
**See also:** `BrowserSessionRow.tsx` uses similar pattern with `isLastApiReqInterrupted` and `isLastMessageResume`.
**Backend side:** When streaming is cancelled, clean up properly (close tabs, clear comments, etc.) by checking `taskState.abort` after the streaming function returns.
+2 -5
View File
@@ -37,9 +37,8 @@ var (
func main() {
rootCmd := &cobra.Command{
Use: "cline [prompt]",
Short: "Cline CLI - AI-powered coding assistant",
Version: global.CliVersion,
Use: "cline [prompt]",
Short: "Cline CLI - AI-powered coding assistant",
Long: `A command-line interface for interacting with Cline AI coding assistant.
Start a new task by providing a prompt:
@@ -178,8 +177,6 @@ see the manual page: man cline`,
},
}
rootCmd.SetVersionTemplate(cli.VersionString())
rootCmd.PersistentFlags().StringVar(&coreAddress, "address", fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT), "Cline Core gRPC address")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output-format", "F", "rich", "output format (rich|json|plain)")
+11 -14
View File
@@ -8,19 +8,6 @@ import (
"github.com/spf13/cobra"
)
// VersionString returns the full version information string
func VersionString() string {
return fmt.Sprintf(`Cline CLI
Cline CLI Version: %s
Cline Core Version: %s
Commit: %s
Built: %s
Built by: %s
Go version: %s
OS/Arch: %s/%s
`, global.CliVersion, global.Version, global.Commit, global.Date, global.BuiltBy, runtime.Version(), runtime.GOOS, runtime.GOARCH)
}
// NewVersionCommand creates the version command
func NewVersionCommand() *cobra.Command {
var short bool
@@ -31,11 +18,21 @@ func NewVersionCommand() *cobra.Command {
Short: "Show version information",
Long: `Display version information for the Cline CLI.`,
RunE: func(cmd *cobra.Command, args []string) error {
// Versions are injected at build time via ldflags
if short {
fmt.Println(global.CliVersion)
return nil
}
fmt.Print(VersionString())
fmt.Printf("Cline CLI\n")
fmt.Printf("Cline CLI Version: %s\n", global.CliVersion)
fmt.Printf("Cline Core Version: %s\n", global.Version)
fmt.Printf("Commit: %s\n", global.Commit)
fmt.Printf("Built: %s\n", global.Date)
fmt.Printf("Built by: %s\n", global.BuiltBy)
fmt.Printf("Go version: %s\n", runtime.Version())
fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
return nil
},
}
-1
View File
@@ -177,7 +177,6 @@
"features/tasks/task-management"
]
},
"features/worktrees",
"features/yolo-mode"
]
},
+1 -4
View File
@@ -48,7 +48,7 @@ These labels match what you see in the Auto Approve menu.
| Execute all commands | Run commands marked as requiring approval | Requires “Execute safe commands” |
| Use the browser | Allows use of the browser tool for web fetching and searching | Proxy issues can apply |
| Use MCP servers | Use MCP tools and access MCP resources | Some servers also have per-tool auto-approve |
| Enable notifications | Notifies you about long-running auto-approved commands | Accessible directly in the Auto Approve menu |
| Enable notifications | Notifies you about long-running auto-approved commands | Helpful for terminal work |
<Warning>
“Read all files” and “Edit all files” only matter if their base toggle is enabled. They extend access outside your workspace.
@@ -92,9 +92,6 @@ These are examples, not guarantees.
Auto-approved actions can run for a while, especially long terminal commands. If you enable notifications, Cline can notify you when an auto-approved command has been running for a while and may need attention.
The **Enable notifications** toggle is located at the bottom of the Auto Approve menu, below a separator line. This puts the notification setting right where you manage your auto-approval permissions, making it easy to discover and adjust.
## Recommendations
A good default setup is:
-270
View File
@@ -1,270 +0,0 @@
---
title: "Worktrees"
sidebarTitle: "Worktrees"
---
Worktrees let you work on multiple branches simultaneously, each in its own folder. This enables Cline to work on tasks in parallel across separate VS Code windows, or lets Cline work independently while you continue coding in your main workspace.
<Frame>
<img
src="https://storage.googleapis.com/cline_public_images/docs/assets/worktrees-overview.png"
alt="Worktrees view showing multiple linked worktrees"
/>
</Frame>
## What Are Git Worktrees?
A Git worktree is a linked copy of your repository in a separate folder, checked out to a specific branch. All worktrees share the same Git history and `.git` directory, but each has its own working directory with different code checked out.
Key concepts:
- **Main worktree**: Your original repository folder where the `.git` directory lives
- **Linked worktrees**: Additional folders you create, each checked out to a different branch
- **Shared history**: All worktrees share commits, branches, and Git configuration
<Tip>
Unlike regular branch switching, worktrees let you have multiple branches checked out at the same time in different folders. This means you can have VS Code windows open for different features simultaneously.
</Tip>
## Why Use Worktrees with Cline?
Worktrees solve a common problem: **Cline takes over your VS Code window while working on a task**. With worktrees, you can:
1. **Run Cline in parallel** - Have Cline work on multiple tasks simultaneously, each in its own worktree and VS Code window
2. **Keep working while Cline works** - Let Cline handle a task in a separate worktree while you continue coding in your main workspace
3. **Isolate experimental changes** - Test risky changes in a worktree without affecting your main branch
4. **Quick context switching** - Jump between features without stashing or committing incomplete work
## Getting Started
### Quick Launch (Recommended)
The fastest way to start using worktrees is the **New Worktree Window** button on Cline's home screen:
1. Click **New Worktree Window** on the home screen
2. Enter a branch name and folder path (defaults are auto-filled)
3. Click **Create & Open**
A new VS Code window opens with your worktree, and Cline automatically opens ready to work.
<Tip>
The home screen also shows your current branch and worktree path. Click it to open the full Worktrees view.
</Tip>
### Full Worktrees View
For more control, open the full Worktrees view by clicking the **Worktrees** button in the Cline sidebar header, or by clicking your current branch info on the home screen:
<Steps>
<Step title="Create a New Worktree">
Click **New Worktree** at the bottom of the view. Enter a branch name and path (defaults are auto-filled).
</Step>
<Step title="Open in New Window">
Once created, click the **Open in new window** button to open the worktree in a separate VS Code window. Cline will automatically open in the new window.
</Step>
</Steps>
## Typical Workflow
Here's how a typical worktree session looks:
<Steps>
<Step title="Create a new worktree">
Click **New Worktree Window** on the home screen or use the Worktrees view. A new VS Code window opens with Cline ready to go.
</Step>
<Step title="Do your work">
Work on your feature or let Cline handle a task. Make commits as you go.
</Step>
<Step title="Close the worktree window">
When you're done, close the worktree's VS Code window.
</Step>
<Step title="Merge from your primary worktree">
Back in your main VS Code window, open the Worktrees view and click the **merge button** on the worktree you just worked in. This merges the branch and optionally deletes the worktree.
</Step>
</Steps>
## Managing Worktrees
### Viewing Worktrees
The Worktrees view shows all worktrees for your repository:
- **Current**: The worktree you're currently in (highlighted)
- **Main**: The primary worktree where your `.git` directory lives (cannot be deleted)
- **Locked**: Worktrees that are locked to prevent accidental deletion
### Opening Worktrees
Each worktree has two open options:
- **Open in current window**: Replace your current workspace with the worktree
- **Open in new window**: Open the worktree in a separate VS Code window (recommended for parallel Cline sessions)
Either way, Cline automatically opens in the new workspace, ready to start a task.
### Deleting Worktrees
Click the trash icon on any linked worktree to delete it. A confirmation dialog will show you exactly what will be deleted:
- The branch itself
- All project files in the worktree folder
<Warning>
Deleting a worktree permanently removes the branch and all files in that folder. Make sure any important changes are committed and pushed first.
</Warning>
<Note>
You cannot delete the main worktree. It's the primary repository where your `.git` directory lives.
</Note>
### Merging Worktrees
When you're done working in a worktree and ready to merge your changes back to the main branch:
1. Click the **merge icon** (git merge symbol) on any linked worktree
2. Review the merge details in the confirmation modal
3. Choose whether to delete the worktree after merging
4. Click **Merge**
<Frame>
<img
src="https://storage.googleapis.com/cline_public_images/docs/assets/worktrees-merge.png"
alt="Merge worktree modal"
/>
</Frame>
#### Handling Merge Conflicts
If your branch has conflicts with the main branch, Cline will detect them and show you the conflicting files. You have two options:
1. **Ask Cline to Resolve & Merge** - Creates a new Cline task with a prompt asking Cline to resolve the conflicts, complete the merge, and clean up the worktree
2. **Resolve Manually** - Close the modal and resolve conflicts yourself using your preferred Git tools
<Tip>
The "Ask Cline to Resolve" option is particularly useful for complex conflicts. Cline will analyze the conflicting files and attempt to merge them intelligently based on the intent of both branches.
</Tip>
## .worktreeinclude: Automatic File Copying
When you create a new worktree, it starts with a fresh checkout—no `node_modules`, no build artifacts, no IDE settings. This means you'd normally need to run `npm install` or similar setup commands.
The `.worktreeinclude` file solves this by automatically copying specified files to new worktrees.
### How It Works
1. Create a `.worktreeinclude` file in your repository root
2. Add glob patterns for files you want copied (using `.gitignore` syntax)
3. When Cline creates a new worktree, files matching **both** `.worktreeinclude` **and** `.gitignore` are copied automatically
<Note>
Only files that are both matched by `.worktreeinclude` AND listed in `.gitignore` are copied. This prevents accidentally duplicating tracked files.
</Note>
### Example `.worktreeinclude`
```gitignore
# Copy node_modules to avoid npm install
node_modules/
# Copy IDE settings
.vscode/
# Copy build cache
.next/
dist/
# Copy environment files (if gitignored)
.env.local
```
### Creating a `.worktreeinclude` File
The Worktrees view will show a tip if you don't have a `.worktreeinclude` file. If you have a `.gitignore`, you can click **Create from .gitignore** to create one pre-filled with your gitignore contents. Then edit it to keep only the patterns you want copied.
<Tip>
For most JavaScript/TypeScript projects, just including `node_modules/` in your `.worktreeinclude` saves significant setup time for each new worktree.
</Tip>
### Pro Tip: Symlink to .gitignore
Since `.gitignore` usually contains most of the files you'd want copied to new worktrees (dependencies, environment files, build caches, etc.), you can create a symlink so they stay in sync automatically:
```bash
# In your repository root
ln -s .gitignore .worktreeinclude
```
Now whenever you update your `.gitignore`, your `.worktreeinclude` will have the same patterns. This is especially useful for projects where gitignored files are exactly what you want copied—no need to maintain two separate files.
<Note>
If you need different patterns than your `.gitignore`, create a regular `.worktreeinclude` file instead of a symlink.
</Note>
## Best Practices
<AccordionGroup>
<Accordion title="For Parallel Cline Sessions">
1. **Create purpose-specific worktrees** - Name branches clearly (e.g., `cline/refactor-auth`, `cline/add-tests`)
2. **Open in new windows** - Always use "Open in new window" for true parallelism
3. **Use .worktreeinclude** - Set up automatic file copying to reduce setup time
</Accordion>
<Accordion title="For Solo Development">
1. **Keep your main branch clean** - Use worktrees for experimental or risky changes
2. **Quick feature switches** - Instead of stashing, create a worktree for interruptions
3. **Review in isolation** - Create worktrees to review PRs without disrupting your work
</Accordion>
<Accordion title="Worktree Hygiene">
1. **Delete unused worktrees** - Remove worktrees when their branches are merged
2. **Use meaningful names** - Branch names should indicate the worktree's purpose
3. **Check for stale worktrees** - Periodically review and clean up old worktrees
</Accordion>
</AccordionGroup>
## Limitations
Worktrees are not available in certain workspace configurations:
- **Multi-root workspaces**: If you have multiple folders open in VS Code, worktrees are disabled. Open a single repository folder instead.
- **Subfolder of a repository**: If you've opened a subfolder within a Git repository (not the root), worktrees are disabled. Open the repository root folder instead.
The Worktrees view will display a message explaining the limitation if either of these applies to your workspace.
## Troubleshooting
<AccordionGroup>
<Accordion title="Branch already exists error">
Git doesn't allow the same branch to be checked out in multiple worktrees. Either:
- Use a different branch name
- Delete the existing worktree using that branch
</Accordion>
<Accordion title="Worktree folder already exists">
The path you specified already contains files. Choose a different path or delete the existing folder first.
</Accordion>
<Accordion title="Can't delete worktree">
If a worktree is locked, you'll need to unlock it first using `git worktree unlock <path>` in the terminal. If the worktree has uncommitted changes, you may need to use force delete.
</Accordion>
<Accordion title=".worktreeinclude files not copying">
Make sure the files you want copied are:
1. Listed in your `.worktreeinclude` file
2. Also listed in your `.gitignore` (only gitignored files are copied)
3. Actually exist in your current worktree
</Accordion>
</AccordionGroup>
## Technical Details
<AccordionGroup>
<Accordion title="How Worktrees Work Internally">
- Worktrees are a native Git feature (`git worktree` command)
- All worktrees share the same `.git` directory and object database
- Each worktree has its own index, working directory, and HEAD
- Worktree list is stored in `.git/worktrees/`
</Accordion>
<Accordion title="Storage Considerations">
- Each worktree contains a full checkout of the repository
- `.worktreeinclude` can significantly increase worktree size (e.g., copying `node_modules`)
- Consider your disk space when creating many worktrees
</Accordion>
<Accordion title="Relationship with Checkpoints">
Worktrees are separate from Cline's [checkpoint system](/features/checkpoints). Each worktree has its own checkpoint history. Checkpoints track changes within a single worktree, while worktrees let you work across multiple branches simultaneously.
</Accordion>
</AccordionGroup>
Worktrees unlock true parallel development with Cline. Create a worktree, open it in a new window, and let Cline work independently while you continue coding!
-1
View File
@@ -484,7 +484,6 @@ message LiteLLMModelInfo {
repeated ModelTier tiers = 12;
optional double temperature = 13;
optional ApiFormat api_format = 14;
optional bool supports_reasoning = 15;
}
// Main ApiConfiguration message
-2
View File
@@ -277,7 +277,6 @@ message Settings {
optional int32 open_telemetry_log_batch_size = 169;
optional int32 open_telemetry_log_batch_timeout = 170;
optional int32 open_telemetry_log_max_queue_size = 171;
optional bool worktrees_enabled = 172;
}
message DictationSettings {
@@ -422,7 +421,6 @@ message UpdateSettingsRequest {
optional string oca_reasoning_effort = 37;
optional bool skills_enabled = 38;
optional bool opt_out_of_remote_config = 39;
optional bool worktrees_enabled = 40;
}
message UpdateTerminalConnectionTimeoutRequest {
-3
View File
@@ -256,9 +256,6 @@ service UiService {
// Subscribe to settings button clicked events
rpc subscribeToSettingsButtonClicked(EmptyRequest) returns (stream Empty);
// Subscribe to worktrees button clicked events
rpc subscribeToWorktreesButtonClicked(EmptyRequest) returns (stream Empty);
// Subscribe to partial message updates (streaming Cline messages as they're built)
rpc subscribeToPartialMessage(EmptyRequest) returns (stream ClineMessage);
-153
View File
@@ -1,153 +0,0 @@
syntax = "proto3";
package cline;
import "cline/common.proto";
option go_package = "github.com/cline/grpc-go/cline";
option java_multiple_files = true;
option java_package = "bot.cline.proto";
// Service for git worktree operations
service WorktreeService {
// Lists all worktrees in the current repository
rpc listWorktrees(EmptyRequest) returns (WorktreeList);
// Creates a new worktree
rpc createWorktree(CreateWorktreeRequest) returns (WorktreeResult);
// Deletes an existing worktree
rpc deleteWorktree(DeleteWorktreeRequest) returns (WorktreeResult);
// Switches to a different worktree (opens in VS Code)
rpc switchWorktree(SwitchWorktreeRequest) returns (WorktreeResult);
// Gets available branches for creating worktrees
rpc getAvailableBranches(EmptyRequest) returns (BranchList);
// Gets suggested defaults for creating a new worktree (auto-generated branch name and path)
rpc getWorktreeDefaults(EmptyRequest) returns (WorktreeDefaults);
// Gets the status of .worktreeinclude file and .gitignore contents for creating one
rpc getWorktreeIncludeStatus(EmptyRequest) returns (WorktreeIncludeStatus);
// Creates a .worktreeinclude file with the provided content
rpc createWorktreeInclude(CreateWorktreeIncludeRequest) returns (WorktreeResult);
// Switches to a different branch in the current worktree (git checkout)
rpc checkoutBranch(CheckoutBranchRequest) returns (WorktreeResult);
// Merges a worktree's branch into the target branch and optionally deletes the worktree
rpc mergeWorktree(MergeWorktreeRequest) returns (MergeWorktreeResult);
// Tracks when the worktrees view is opened (for telemetry)
rpc trackWorktreeViewOpened(TrackWorktreeViewOpenedRequest) returns (Empty);
}
// Represents a single git worktree
message Worktree {
string path = 1; // Absolute path to the worktree
string branch = 2; // Branch name (empty if detached)
string commit_hash = 3; // Current commit hash
bool is_current = 4; // Whether this is the current worktree
bool is_bare = 5; // Whether this is the bare repository
bool is_detached = 6; // Whether HEAD is detached
bool is_locked = 7; // Whether the worktree is locked
optional string lock_reason = 8; // Reason for lock if locked
}
// Response containing list of worktrees
message WorktreeList {
repeated Worktree worktrees = 1;
bool is_git_repo = 2; // Whether the current workspace is a git repo
string error = 3; // Error message if any
bool is_multi_root = 4; // Whether multiple workspace folders are open (worktrees not supported)
bool is_subfolder = 5; // Whether workspace is a subfolder of a git repo (not at repo root)
string git_root_path = 6; // The actual git root path (useful when is_subfolder is true)
}
// Request to create a new worktree
message CreateWorktreeRequest {
Metadata metadata = 1;
string path = 2; // Path for the new worktree
optional string branch = 3; // Branch name (creates new if doesn't exist)
optional string base_branch = 4; // Base branch for new branch creation
bool create_new_branch = 5; // Whether to create a new branch
}
// Request to delete a worktree
message DeleteWorktreeRequest {
Metadata metadata = 1;
string path = 2; // Path of the worktree to delete
bool force = 3; // Force deletion even if dirty
bool delete_branch = 4; // Also delete the branch
string branch_name = 5; // Name of the branch to delete (required if delete_branch is true)
}
// Request to switch to a worktree
message SwitchWorktreeRequest {
Metadata metadata = 1;
string path = 2; // Path of the worktree to switch to
bool new_window = 3; // Whether to open in a new window
}
// Result of worktree operations
message WorktreeResult {
bool success = 1;
string message = 2; // Success or error message
optional Worktree worktree = 3; // The affected worktree (for create)
}
// List of available branches
message BranchList {
repeated string local_branches = 1;
repeated string remote_branches = 2;
string current_branch = 3;
}
// Suggested defaults for creating a new worktree
message WorktreeDefaults {
string suggested_branch = 1; // Auto-generated branch name like "worktree/cline-abc12"
string suggested_path = 2; // Path in Documents/Cline/Worktrees/<project>-<suffix>
}
// Status of .worktreeinclude file
message WorktreeIncludeStatus {
bool exists = 1; // Whether .worktreeinclude exists
string gitignore_content = 2; // Content of .gitignore (for prefilling)
bool has_gitignore = 3; // Whether .gitignore exists
}
// Request to create .worktreeinclude file
message CreateWorktreeIncludeRequest {
string content = 1; // Content for the .worktreeinclude file
}
// Request to checkout a branch in the current worktree
message CheckoutBranchRequest {
Metadata metadata = 1;
string branch = 2; // Branch name to checkout
}
// Request to merge a worktree's branch into target branch
message MergeWorktreeRequest {
Metadata metadata = 1;
string worktree_path = 2; // Path of the worktree to merge
string target_branch = 3; // Branch to merge into (e.g., "main")
bool delete_after_merge = 4; // Whether to delete the worktree after successful merge
}
// Result of merge operation
message MergeWorktreeResult {
bool success = 1;
string message = 2; // Success or error message
bool has_conflicts = 3; // Whether merge resulted in conflicts
repeated string conflicting_files = 4; // List of files with conflicts
string source_branch = 5; // The branch that was merged
string target_branch = 6; // The branch merged into
}
// Request to track worktree view opened (for telemetry)
message TrackWorktreeViewOpenedRequest {
string source = 1; // Where the view was opened from: "home_page" or "menu_bar"
}
-13
View File
@@ -35,9 +35,6 @@ service WorkspaceService {
// Executes a command in a new terminal
rpc executeCommandInTerminal(ExecuteCommandInTerminalRequest) returns (ExecuteCommandInTerminalResponse);
// Opens a folder/workspace in the IDE
rpc openFolder(OpenFolderRequest) returns (OpenFolderResponse);
}
message GetWorkspacePathsRequest {
@@ -110,13 +107,3 @@ message ExecuteCommandInTerminalRequest {
message ExecuteCommandInTerminalResponse {
bool success = 1; // Whether the command was successfully sent to the terminal
}
// Request to open a folder/workspace
message OpenFolderRequest {
string path = 1; // The path to the folder to open
bool new_window = 2; // Whether to open in a new window
}
message OpenFolderResponse {
bool success = 1;
}
+4 -20
View File
@@ -22,34 +22,19 @@ echo ""
# Always rebuild CLI to ensure latest changes
echo -e "${CYAN}${NC} ${DIM}Rebuilding CLI binaries...${NC}"
cd "$PROJECT_ROOT"
rm -rf "$PROJECT_ROOT/cli/bin"
if command -v go >/dev/null 2>&1; then
GO_BIN_DIR="$(go env GOPATH 2>/dev/null)/bin"
if [ -d "$GO_BIN_DIR" ]; then
export PATH="$GO_BIN_DIR:$PATH"
fi
fi
if npm run compile-cli; then
if npm run compile-cli 2>&1 | grep -E "(built|error|Error)" || true; then
echo -e "${GREEN}${NC} CLI binaries rebuilt"
else
echo -e "${YELLOW}${NC} CLI build failed - aborting install"
exit 1
echo -e "${YELLOW}${NC} CLI build may have issues - check output above"
fi
# Always rebuild standalone to ensure latest cline-core.js
echo -e "${CYAN}${NC} ${DIM}Rebuilding standalone package (this may take ~30 seconds)...${NC}"
rm -rf "$PROJECT_ROOT/dist-standalone"
if npm run compile-standalone; then
if npm run compile-standalone 2>&1 | tail -5; then
echo -e "${GREEN}${NC} Standalone package rebuilt"
else
echo -e "${YELLOW}${NC} Standalone build failed - aborting install"
exit 1
echo -e "${YELLOW}${NC} Standalone build may have issues - check output above"
fi
# Ensure extension package.json is present for cline-core startup
mkdir -p "$PROJECT_ROOT/dist-standalone/extension"
cp "$PROJECT_ROOT/package.json" "$PROJECT_ROOT/dist-standalone/extension/package.json"
echo ""
echo -e "${CYAN}${NC} ${DIM}Installing to $INSTALL_DIR${NC}"
@@ -72,7 +57,6 @@ rsync -a --exclude='bin' "$PROJECT_ROOT/dist-standalone/" "$INSTALL_DIR/"
echo -e "${CYAN}${NC} ${DIM}Installing runtime dependencies...${NC}"
cd "$PROJECT_ROOT/standalone/runtime-files"
npm install --silent 2>/dev/null || npm install
rm -rf "$INSTALL_DIR/node_modules"
cp -r node_modules "$INSTALL_DIR/"
cp -r vscode "$INSTALL_DIR/node_modules/"
cd "$PROJECT_ROOT"
+1 -38
View File
@@ -24,7 +24,6 @@ import { telemetryService } from "./services/telemetry"
import { PostHogClientProvider } from "./services/telemetry/providers/posthog/PostHogClientProvider"
import { ShowMessageType } from "./shared/proto/host/window"
import { getLatestAnnouncementId } from "./utils/announcements"
import { arePathsEqual } from "./utils/path"
/**
* Performs intialization for Cline that is common to all platforms.
*
@@ -50,7 +49,7 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
// Setup the external services
await ErrorService.initialize()
await featureFlagsService.poll(null)
await featureFlagsService.poll()
// Migrate custom instructions to global Cline rules (one-time cleanup)
await migrateCustomInstructionsToGlobalRules(context)
@@ -77,9 +76,6 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
await showVersionUpdateAnnouncement(context)
// Check if this workspace was opened from worktree quick launch
await checkWorktreeAutoOpen(context)
// Initialize banner service (TEMPORARILY DISABLED - not fetching banners to prevent API hammering)
BannerService.initialize(webview.controller)
// DISABLED: .getActiveBanners(true)
@@ -121,39 +117,6 @@ async function showVersionUpdateAnnouncement(context: vscode.ExtensionContext) {
}
}
/**
* Checks if this workspace was opened from the worktree quick launch button.
* If so, opens the Cline sidebar and clears the state.
*/
async function checkWorktreeAutoOpen(context: vscode.ExtensionContext): Promise<void> {
try {
// Read directly from globalState (not StateManager cache) since this may have been
// set by another window right before this one opened
const worktreeAutoOpenPath = context.globalState.get<string>("worktreeAutoOpenPath")
if (!worktreeAutoOpenPath) {
return
}
// Get current workspace path
const workspacePaths = (await HostProvider.workspace.getWorkspacePaths({})).paths
if (workspacePaths.length === 0) {
return
}
const currentPath = workspacePaths[0]
// Check if current workspace matches the worktree path
if (arePathsEqual(currentPath, worktreeAutoOpenPath)) {
// Clear the state first to prevent re-triggering
await context.globalState.update("worktreeAutoOpenPath", undefined)
// Open the Cline sidebar
await HostProvider.workspace.openClineSidebarPanel({})
}
} catch (error) {
Logger.error("Error checking worktree auto-open", error)
}
}
/**
* Performs cleanup when Cline is deactivated that is common to all platforms.
*/
+15 -8
View File
@@ -122,10 +122,21 @@ export class Controller {
this.stateManager = StateManager.get()
StateManager.get().registerCallbacks({
onPersistenceError: async ({ error }: PersistenceErrorEvent) => {
// Just log - don't call reInitialize() (that sets isInitialized=false which
// breaks running tasks) and don't show a warning (data is safe in memory
// and will be retried automatically on the next debounced persistence).
Logger.error("[Controller] Storage persistence failed (will retry):", error)
Logger.error("[Controller] Cache persistence failed, recovering:", error)
try {
await StateManager.get().reInitialize(this.task?.taskId)
await this.postStateToWebview()
HostProvider.window.showMessage({
type: ShowMessageType.WARNING,
message: "Saving settings to storage failed.",
})
} catch (recoveryError) {
Logger.error("[Controller] Cache recovery failed:", recoveryError)
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "Failed to save settings. Please restart the extension.",
})
}
},
onSyncExternalChange: async () => {
await this.postStateToWebview()
@@ -947,10 +958,6 @@ export class Controller {
user: this.stateManager.getGlobalSettingsKey("clineWebToolsEnabled"),
featureFlag: featureFlagsService.getWebtoolsEnabled(),
},
worktreesEnabled: {
user: this.stateManager.getGlobalSettingsKey("worktreesEnabled"),
featureFlag: featureFlagsService.getWorktreesEnabled(),
},
hooksEnabled: this.stateManager.getGlobalSettingsKey("hooksEnabled"),
lastDismissedInfoBannerVersion,
lastDismissedModelBannerVersion,
@@ -52,10 +52,7 @@ export async function refreshLiteLlmModels(): Promise<Record<string, ModelInfo>>
description: undefined,
}
// Use litellm_params.model as the key since that's the actual model ID users select
// model_name may not include the region prefix (e.g., "us." for Bedrock models)
const modelId = rawModel.litellm_params?.model || rawModel.model_name
models[modelId] = modelInfo
models[rawModel.model_name] = modelInfo
}
}
} catch (error) {
@@ -195,11 +195,6 @@ export async function updateSettings(controller: Controller, request: UpdateSett
controller.stateManager.setGlobalState("clineWebToolsEnabled", request.clineWebToolsEnabled)
}
// Update worktrees setting
if (request.worktreesEnabled !== undefined) {
controller.stateManager.setGlobalState("worktreesEnabled", request.worktreesEnabled)
}
if (request.dictationSettings !== undefined) {
// Convert from protobuf format (snake_case) to TypeScript format (camelCase)
const dictationSettings = {
@@ -65,7 +65,6 @@ export async function updateSettingsCli(controller: Controller, request: UpdateS
yoloModeToggled,
useAutoCondense,
clineWebToolsEnabled,
worktreesEnabled,
focusChainSettings,
browserSettings,
defaultTerminalProfile,
@@ -168,11 +167,6 @@ export async function updateSettingsCli(controller: Controller, request: UpdateS
controller.stateManager.setGlobalState("clineWebToolsEnabled", clineWebToolsEnabled)
}
// Update worktrees setting
if (worktreesEnabled !== undefined) {
controller.stateManager.setGlobalState("worktreesEnabled", worktreesEnabled)
}
// Update focus chain settings (requires telemetry on state change)
if (focusChainSettings !== undefined) {
const currentSettings = controller.stateManager.getGlobalSettingsKey("focusChainSettings")
@@ -1,60 +0,0 @@
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { getRequestRegistry, StreamingResponseHandler } from "../grpc-handler"
import { Controller } from "../index"
// Keep track of active worktrees button clicked subscriptions
const activeWorktreesButtonClickedSubscriptions = new Set<StreamingResponseHandler<Empty>>()
/**
* Subscribe to worktrees button clicked events
* @param controller The controller instance
* @param request The empty request
* @param responseStream The streaming response handler
* @param requestId The ID of the request (passed by the gRPC handler)
*/
export async function subscribeToWorktreesButtonClicked(
_controller: Controller,
_request: EmptyRequest,
responseStream: StreamingResponseHandler<Empty>,
requestId?: string,
): Promise<void> {
// Add this subscription to the active subscriptions
activeWorktreesButtonClickedSubscriptions.add(responseStream)
// Register cleanup when the connection is closed
const cleanup = () => {
activeWorktreesButtonClickedSubscriptions.delete(responseStream)
}
// Register the cleanup function with the request registry if we have a requestId
if (requestId) {
getRequestRegistry().registerRequest(
requestId,
cleanup,
{ type: "worktrees_button_clicked_subscription" },
responseStream,
)
}
}
/**
* Send a worktrees button clicked event to all active subscribers
*/
export async function sendWorktreesButtonClickedEvent(): Promise<void> {
// Send the event to all active subscribers
const promises = Array.from(activeWorktreesButtonClickedSubscriptions).map(async (responseStream) => {
try {
const event = Empty.create({})
await responseStream(
event,
false, // Not the last message
)
} catch (error) {
console.error("Error sending worktrees button clicked event:", error)
// Remove the subscription if there was an error
activeWorktreesButtonClickedSubscriptions.delete(responseStream)
}
})
await Promise.all(promises)
}
@@ -1,45 +0,0 @@
import { CheckoutBranchRequest, WorktreeResult } from "@shared/proto/cline/worktree"
import { getWorkspacePath } from "@utils/path"
import simpleGit from "simple-git"
import { Controller } from ".."
/**
* Checks out a branch in the current worktree (git checkout)
* @param controller The controller instance
* @param request The checkout branch request containing the branch name
* @returns WorktreeResult indicating success or failure
*/
export async function checkoutBranch(_controller: Controller, request: CheckoutBranchRequest): Promise<WorktreeResult> {
const cwd = await getWorkspacePath()
if (!cwd) {
return WorktreeResult.create({
success: false,
message: "No workspace folder found",
})
}
const { branch } = request
if (!branch) {
return WorktreeResult.create({
success: false,
message: "Branch name is required",
})
}
try {
const git = simpleGit(cwd)
await git.checkout(branch)
return WorktreeResult.create({
success: true,
message: `Switched to branch '${branch}'`,
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return WorktreeResult.create({
success: false,
message: `Failed to checkout branch: ${errorMessage}`,
})
}
}
@@ -1,64 +0,0 @@
import { CreateWorktreeRequest, WorktreeResult } from "@shared/proto/cline/worktree"
import { createWorktree as createWorktreeUtil, listWorktrees } from "@utils/git-worktree"
import { getWorkspacePath } from "@utils/path"
import { telemetryService } from "@/services/telemetry"
import { Controller } from ".."
/**
* Creates a new git worktree
* @param controller The controller instance
* @param request The request containing path and branch information
* @returns WorktreeResult with success status and created worktree info
*/
export async function createWorktree(_controller: Controller, request: CreateWorktreeRequest): Promise<WorktreeResult> {
const cwd = await getWorkspacePath()
if (!cwd) {
return WorktreeResult.create({
success: false,
message: "No workspace folder open",
})
}
try {
const result = await createWorktreeUtil(cwd, request.path, {
branch: request.branch,
baseBranch: request.baseBranch,
createNewBranch: request.createNewBranch,
})
// Track worktree creation with count of total worktrees
if (result.success) {
try {
const { worktrees } = await listWorktrees(cwd)
telemetryService.captureWorktreeCreated(true, worktrees.length)
} catch {
telemetryService.captureWorktreeCreated(true)
}
} else {
telemetryService.captureWorktreeCreated(false)
}
return WorktreeResult.create({
success: result.success,
message: result.message,
worktree: result.worktree
? {
path: result.worktree.path,
branch: result.worktree.branch,
commitHash: result.worktree.commitHash,
isCurrent: result.worktree.isCurrent,
isBare: result.worktree.isBare,
isDetached: result.worktree.isDetached,
isLocked: result.worktree.isLocked,
lockReason: result.worktree.lockReason,
}
: undefined,
})
} catch (error) {
console.error(`Error creating worktree: ${JSON.stringify(error)}`)
return WorktreeResult.create({
success: false,
message: error instanceof Error ? error.message : String(error),
})
}
}
@@ -1,39 +0,0 @@
import { CreateWorktreeIncludeRequest, WorktreeResult } from "@shared/proto/cline/worktree"
import { getWorkspacePath } from "@utils/path"
import * as fs from "fs/promises"
import * as path from "path"
import { Controller } from ".."
/**
* Creates a .worktreeinclude file with the provided content
* @param controller The controller instance
* @param request The request containing the file content
* @returns WorktreeResult with success status
*/
export async function createWorktreeInclude(
_controller: Controller,
request: CreateWorktreeIncludeRequest,
): Promise<WorktreeResult> {
const cwd = await getWorkspacePath()
if (!cwd) {
return WorktreeResult.create({
success: false,
message: "No workspace folder open",
})
}
try {
const filePath = path.join(cwd, ".worktreeinclude")
await fs.writeFile(filePath, request.content, "utf-8")
return WorktreeResult.create({
success: true,
message: "Created .worktreeinclude file",
})
} catch (error) {
return WorktreeResult.create({
success: false,
message: `Failed to create .worktreeinclude: ${error instanceof Error ? error.message : String(error)}`,
})
}
}
@@ -1,71 +0,0 @@
import { DeleteWorktreeRequest, WorktreeResult } from "@shared/proto/cline/worktree"
import { deleteWorktree as deleteWorktreeUtil } from "@utils/git-worktree"
import { getWorkspacePath } from "@utils/path"
import { rm } from "fs/promises"
import path from "path"
import simpleGit from "simple-git"
import { HostProvider } from "@/hosts/host-provider"
import { hashWorkingDir } from "@/integrations/checkpoints/CheckpointUtils"
import { Controller } from ".."
/**
* Deletes an existing git worktree
* @param controller The controller instance
* @param request The request containing path and force flag
* @returns WorktreeResult with success status
*/
export async function deleteWorktree(_controller: Controller, request: DeleteWorktreeRequest): Promise<WorktreeResult> {
const cwd = await getWorkspacePath()
if (!cwd) {
return WorktreeResult.create({
success: false,
message: "No workspace folder open",
})
}
try {
const result = await deleteWorktreeUtil(cwd, request.path, request.force)
if (!result.success) {
return WorktreeResult.create({
success: result.success,
message: result.message,
})
}
// Clean up checkpoint data (shadow git repo) for the deleted worktree
try {
const cwdHash = hashWorkingDir(request.path)
const checkpointDir = path.join(HostProvider.get().globalStorageFsPath, "checkpoints", cwdHash)
await rm(checkpointDir, { recursive: true, force: true })
} catch (error) {
// Log but don't fail - checkpoint cleanup is best-effort
console.log(`Failed to cleanup checkpoints for deleted worktree: ${error}`)
}
// Delete the branch if requested
if (request.deleteBranch && request.branchName) {
try {
const git = simpleGit(cwd)
await git.deleteLocalBranch(request.branchName)
} catch {
// Branch deletion failed, but worktree was deleted successfully
return WorktreeResult.create({
success: true,
message: `${result.message}, but failed to delete branch '${request.branchName}'`,
})
}
}
return WorktreeResult.create({
success: result.success,
message: request.deleteBranch ? `${result.message} and deleted branch '${request.branchName}'` : result.message,
})
} catch (error) {
console.error(`Error deleting worktree: ${JSON.stringify(error)}`)
return WorktreeResult.create({
success: false,
message: error instanceof Error ? error.message : String(error),
})
}
}
@@ -1,39 +0,0 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { BranchList } from "@shared/proto/cline/worktree"
import { getAvailableBranches as getAvailableBranchesUtil } from "@utils/git-worktree"
import { getWorkspacePath } from "@utils/path"
import { Controller } from ".."
/**
* Gets available branches for creating worktrees
* @param controller The controller instance
* @param request Empty request
* @returns BranchList containing local and remote branches
*/
export async function getAvailableBranches(_controller: Controller, _request: EmptyRequest): Promise<BranchList> {
const cwd = await getWorkspacePath()
if (!cwd) {
return BranchList.create({
localBranches: [],
remoteBranches: [],
currentBranch: "",
})
}
try {
const result = await getAvailableBranchesUtil(cwd)
return BranchList.create({
localBranches: result.localBranches,
remoteBranches: result.remoteBranches,
currentBranch: result.currentBranch,
})
} catch (error) {
console.error(`Error getting available branches: ${JSON.stringify(error)}`)
return BranchList.create({
localBranches: [],
remoteBranches: [],
currentBranch: "",
})
}
}
@@ -1,49 +0,0 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { WorktreeDefaults } from "@shared/proto/cline/worktree"
import { getWorkspacePath } from "@utils/path"
import path from "path"
import { getDocumentsPath } from "@/core/storage/disk"
import { Controller } from ".."
/**
* Generates a random suffix for worktree names
* Returns a 5-character alphanumeric string
*/
function generateRandomSuffix(): string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
let result = ""
for (let i = 0; i < 5; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
/**
* Gets suggested defaults for creating a new worktree
* @param controller The controller instance
* @param request Empty request
* @returns WorktreeDefaults with suggested branch name and path
*/
export async function getWorktreeDefaults(_controller: Controller, _request: EmptyRequest): Promise<WorktreeDefaults> {
const suffix = generateRandomSuffix()
// Generate suggested branch name
const suggestedBranch = `worktree/cline-${suffix}`
// Generate suggested path in Documents/Cline/Worktrees/<project-name>-<suffix>
const documentsPath = await getDocumentsPath()
const cwd = await getWorkspacePath()
// Get project name from workspace path
let projectName = "project"
if (cwd) {
projectName = path.basename(cwd)
}
const suggestedPath = path.join(documentsPath, "Cline", "Worktrees", `${projectName}-${suffix}`)
return WorktreeDefaults.create({
suggestedBranch,
suggestedPath,
})
}
@@ -1,48 +0,0 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { WorktreeIncludeStatus } from "@shared/proto/cline/worktree"
import { getWorkspacePath } from "@utils/path"
import * as fs from "fs/promises"
import * as path from "path"
import { Controller } from ".."
/**
* Gets the status of .worktreeinclude file and .gitignore contents
* @param controller The controller instance
* @param request Empty request
* @returns WorktreeIncludeStatus with exists flag and gitignore content
*/
export async function getWorktreeIncludeStatus(_controller: Controller, _request: EmptyRequest): Promise<WorktreeIncludeStatus> {
const cwd = await getWorkspacePath()
if (!cwd) {
return WorktreeIncludeStatus.create({
exists: false,
hasGitignore: false,
gitignoreContent: "",
})
}
// Check if .worktreeinclude exists
let exists = false
try {
await fs.access(path.join(cwd, ".worktreeinclude"))
exists = true
} catch {
exists = false
}
// Read .gitignore content if it exists
let gitignoreContent = ""
let hasGitignore = false
try {
gitignoreContent = await fs.readFile(path.join(cwd, ".gitignore"), "utf-8")
hasGitignore = true
} catch {
hasGitignore = false
}
return WorktreeIncludeStatus.create({
exists,
hasGitignore,
gitignoreContent,
})
}
@@ -1,88 +0,0 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { WorktreeList } from "@shared/proto/cline/worktree"
import { getGitRootPath, listWorktrees as listWorktreesUtil } from "@utils/git-worktree"
import { arePathsEqual, getWorkspacePath } from "@utils/path"
import { HostProvider } from "@/hosts/host-provider"
import { Controller } from ".."
/**
* Lists all git worktrees in the current repository
* @param controller The controller instance
* @param request Empty request
* @returns WorktreeList containing all worktrees
*/
export async function listWorktrees(_controller: Controller, _request: EmptyRequest): Promise<WorktreeList> {
// Check for multi-root workspace
const workspacePaths = (await HostProvider.workspace.getWorkspacePaths({})).paths
const isMultiRoot = workspacePaths.length > 1
if (isMultiRoot) {
return WorktreeList.create({
worktrees: [],
isGitRepo: false,
isMultiRoot: true,
isSubfolder: false,
gitRootPath: "",
error: "",
})
}
const cwd = await getWorkspacePath()
if (!cwd) {
return WorktreeList.create({
worktrees: [],
isGitRepo: false,
isMultiRoot: false,
isSubfolder: false,
gitRootPath: "",
error: "No workspace folder open",
})
}
// Check if workspace is a subfolder of a git repo (not at repo root)
const gitRootPath = await getGitRootPath(cwd)
const isSubfolder = gitRootPath !== null && !arePathsEqual(cwd, gitRootPath)
if (isSubfolder) {
return WorktreeList.create({
worktrees: [],
isGitRepo: true,
isMultiRoot: false,
isSubfolder: true,
gitRootPath: gitRootPath || "",
error: "",
})
}
try {
const result = await listWorktreesUtil(cwd)
return WorktreeList.create({
worktrees: result.worktrees.map((wt) => ({
path: wt.path,
branch: wt.branch,
commitHash: wt.commitHash,
isCurrent: wt.isCurrent,
isBare: wt.isBare,
isDetached: wt.isDetached,
isLocked: wt.isLocked,
lockReason: wt.lockReason,
})),
isGitRepo: result.isGitRepo,
isMultiRoot: false,
isSubfolder: false,
gitRootPath: gitRootPath || "",
error: result.error || "",
})
} catch (error) {
console.error(`Error listing worktrees: ${JSON.stringify(error)}`)
return WorktreeList.create({
worktrees: [],
isGitRepo: false,
isMultiRoot: false,
isSubfolder: false,
gitRootPath: "",
error: error instanceof Error ? error.message : String(error),
})
}
}
@@ -1,215 +0,0 @@
import { MergeWorktreeRequest, MergeWorktreeResult } from "@shared/proto/cline/worktree"
import { listWorktrees } from "@utils/git-worktree"
import { getWorkspacePath } from "@utils/path"
import simpleGit from "simple-git"
import { telemetryService } from "@/services/telemetry"
import { Controller } from ".."
/**
* Merges a worktree's branch into the target branch and optionally deletes the worktree
* @param controller The controller instance
* @param request The merge worktree request
* @returns MergeWorktreeResult indicating success, failure, or conflicts
*/
export async function mergeWorktree(_controller: Controller, request: MergeWorktreeRequest): Promise<MergeWorktreeResult> {
const cwd = await getWorkspacePath()
if (!cwd) {
return MergeWorktreeResult.create({
success: false,
message: "No workspace folder found",
hasConflicts: false,
conflictingFiles: [],
})
}
const { worktreePath, targetBranch, deleteAfterMerge } = request
if (!worktreePath) {
return MergeWorktreeResult.create({
success: false,
message: "Worktree path is required",
hasConflicts: false,
conflictingFiles: [],
})
}
if (!targetBranch) {
return MergeWorktreeResult.create({
success: false,
message: "Target branch is required",
hasConflicts: false,
conflictingFiles: [],
})
}
try {
// Find the worktree that has the target branch checked out
// This is where we need to perform the merge
const { worktrees } = await listWorktrees(cwd)
const targetWorktree = worktrees.find((w) => w.branch === targetBranch)
if (!targetWorktree) {
return MergeWorktreeResult.create({
success: false,
message: `Target branch '${targetBranch}' is not checked out in any worktree. Please checkout the branch first.`,
hasConflicts: false,
conflictingFiles: [],
})
}
// Use the target worktree's path for merge operations
const targetWorktreePath = targetWorktree.path
const git = simpleGit(targetWorktreePath)
const worktreeGit = simpleGit(worktreePath)
// Get the branch name of the worktree
let sourceBranch: string
try {
sourceBranch = await worktreeGit.revparse(["--abbrev-ref", "HEAD"])
sourceBranch = sourceBranch.trim()
} catch {
return MergeWorktreeResult.create({
success: false,
message: "Failed to get branch name from worktree",
hasConflicts: false,
conflictingFiles: [],
})
}
if (sourceBranch === "HEAD") {
return MergeWorktreeResult.create({
success: false,
message: "Cannot merge a detached HEAD worktree",
hasConflicts: false,
conflictingFiles: [],
sourceBranch,
targetBranch,
})
}
// Check for uncommitted changes in the source worktree
try {
const status = await worktreeGit.status()
if (!status.isClean()) {
return MergeWorktreeResult.create({
success: false,
message: `Worktree has uncommitted changes. Please commit or stash them first.`,
hasConflicts: false,
conflictingFiles: [],
sourceBranch,
targetBranch,
})
}
} catch {
// If status check fails, continue anyway
}
// Check for uncommitted changes in the target worktree
try {
const targetStatus = await git.status()
if (!targetStatus.isClean()) {
return MergeWorktreeResult.create({
success: false,
message: `Target worktree (${targetBranch}) has uncommitted changes. Please commit or stash them first.`,
hasConflicts: false,
conflictingFiles: [],
sourceBranch,
targetBranch,
})
}
} catch {
// If status check fails, continue anyway
}
// Attempt the merge in the target worktree (which already has targetBranch checked out)
try {
await git.merge([sourceBranch, "--no-edit"])
} catch (error) {
// Check if it's a merge conflict
try {
const diffResult = await git.diff(["--name-only", "--diff-filter=U"])
const conflictingFiles = diffResult
.trim()
.split("\n")
.filter((f) => f)
if (conflictingFiles.length > 0) {
// Abort the merge so we don't leave the repo in a conflicted state
try {
await git.merge(["--abort"])
} catch {
// Ignore abort errors
}
telemetryService.captureWorktreeMergeAttempted(false, true, deleteAfterMerge)
return MergeWorktreeResult.create({
success: false,
message: `Merge conflict detected. ${conflictingFiles.length} file(s) have conflicts.`,
hasConflicts: true,
conflictingFiles,
sourceBranch,
targetBranch,
})
}
} catch {
// If conflict check fails, return the original error
}
const errorMessage = error instanceof Error ? error.message : String(error)
telemetryService.captureWorktreeMergeAttempted(false, false, deleteAfterMerge)
return MergeWorktreeResult.create({
success: false,
message: `Merge failed: ${errorMessage}`,
hasConflicts: false,
conflictingFiles: [],
sourceBranch,
targetBranch,
})
}
// Delete worktree if requested
if (deleteAfterMerge) {
try {
await git.raw(["worktree", "remove", worktreePath, "--force"])
} catch (error) {
// Merge succeeded but deletion failed - still return success
const errorMessage = error instanceof Error ? error.message : String(error)
return MergeWorktreeResult.create({
success: true,
message: `Merged '${sourceBranch}' into '${targetBranch}' successfully, but failed to delete worktree: ${errorMessage}`,
hasConflicts: false,
conflictingFiles: [],
sourceBranch,
targetBranch,
})
}
// Optionally delete the branch too
try {
await git.deleteLocalBranch(sourceBranch)
} catch {
// Branch deletion is optional, don't fail if it doesn't work
}
}
telemetryService.captureWorktreeMergeAttempted(true, false, deleteAfterMerge)
return MergeWorktreeResult.create({
success: true,
message: deleteAfterMerge
? `Successfully merged '${sourceBranch}' into '${targetBranch}' and removed worktree`
: `Successfully merged '${sourceBranch}' into '${targetBranch}'`,
hasConflicts: false,
conflictingFiles: [],
sourceBranch,
targetBranch,
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return MergeWorktreeResult.create({
success: false,
message: `Unexpected error: ${errorMessage}`,
hasConflicts: false,
conflictingFiles: [],
})
}
}
@@ -1,45 +0,0 @@
import { SwitchWorktreeRequest, WorktreeResult } from "@shared/proto/cline/worktree"
import { HostProvider } from "@/hosts/host-provider"
import { Controller } from ".."
/**
* Switches to a different worktree by opening it in VS Code
* @param controller The controller instance
* @param request The request containing the worktree path
* @returns WorktreeResult with success status
*/
export async function switchWorktree(controller: Controller, request: SwitchWorktreeRequest): Promise<WorktreeResult> {
try {
// Set state so Cline auto-opens when the worktree folder loads
controller.stateManager.setGlobalState("worktreeAutoOpenPath", request.path)
// When opening in current window, the window reloads immediately and StateManager's
// 500ms debounce won't complete. Flush to ensure state is persisted before reload.
if (!request.newWindow) {
await controller.stateManager.flushPendingState()
}
const result = await HostProvider.workspace.openFolder({
path: request.path,
newWindow: request.newWindow,
})
if (!result.success) {
return WorktreeResult.create({
success: false,
message: `Failed to open worktree at ${request.path}`,
})
}
return WorktreeResult.create({
success: true,
message: `Switched to worktree at ${request.path}`,
})
} catch (error) {
console.error(`Error switching worktree: ${JSON.stringify(error)}`)
return WorktreeResult.create({
success: false,
message: error instanceof Error ? error.message : String(error),
})
}
}
@@ -1,16 +0,0 @@
import { Empty } from "@shared/proto/cline/common"
import { TrackWorktreeViewOpenedRequest } from "@shared/proto/cline/worktree"
import { telemetryService } from "@/services/telemetry"
import { Controller } from ".."
/**
* Tracks when the worktrees view is opened (for telemetry)
* @param controller The controller instance
* @param request The request containing the source of the navigation
* @returns Empty response
*/
export async function trackWorktreeViewOpened(_controller: Controller, request: TrackWorktreeViewOpenedRequest): Promise<Empty> {
const source = request.source === "home_page" ? "home_page" : "menu_bar"
telemetryService.captureWorktreeViewOpened(source)
return Empty.create({})
}
@@ -53,15 +53,19 @@
{
"type": "function",
"function": {
"name": "apply_patch",
"description": "This is a custom utility that makes it more convenient to add, remove, move, or edit code in a single file. `apply_patch` effectively allows you to execute a diff/patch against a file, but the format of the diff specification is unique to this task, so pay careful attention to these instructions. To use the `apply_patch` command, you should pass a message of the following structure as \"input\":\n\n%%bash\napply_patch <<\"EOF\"\n*** Begin Patch\n[YOUR_PATCH]\n*** End Patch\nEOF\n\nWhere [YOUR_PATCH] is the actual content of your patch, specified in the following V4A diff format.\n\n*** [ACTION] File: [path/to/file] -> ACTION can be one of Add, Update, or Delete. \n\nIn a Add File section, every line of the new file (including blank/empty lines) MUST start with a `+` prefix. Do not include any unprefixed lines inside an Add section\nIn a Update/Delete section, repeat the following for each snippet of code that needs to be changed:\n[context_before] -> See below for further instructions on context.\n- [old_code] -> Precede the old code with a minus sign.\n+ [new_code] -> Precede the new, replacement code with a plus sign.\n[context_after] -> See below for further instructions on context.\n\nFor instructions on [context_before] and [context_after]:\n- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first changes [context_after] lines in the second changes [context_before] lines.\n- If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have:\n@@ class BaseClass\n[3 lines of pre-context]\n- [old_code]\n+ [new_code]\n[3 lines of post-context]\n\n- If a code block is repeated so many times in a class or function such that even a single @@ statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right context. For instance:\n\n@@ class BaseClass\n@@ \tdef method():\n[3 lines of pre-context]\n- [old_code]\n+ [new_code]\n[3 lines of post-context]\n\nNote, then, that we do not use line numbers in this diff format, as the context is enough to uniquely identify code. An example of a message that you might pass as \"input\" to this function, in order to apply a patch, is shown below.\n\n%%bash\napply_patch <<\"EOF\"\n*** Begin Patch\n*** Update File: pygorithm/searching/binary_search.py\n@@ class BaseClass\n@@ def search():\n- pass\n+ raise NotImplementedError()\n\n@@ class Subclass\n@@ def search():\n- pass\n+ raise NotImplementedError()\n\n*** End Patch\nEOF",
"name": "write_to_file",
"description": "[IMPORTANT: Always output the absolutePath first] Request to write content to a file at the specified path. If the file exists, it will be overwritten with the provided content. If the file doesn't exist, it will be created. This tool will automatically create any directories needed to write the file.",
"strict": false,
"parameters": {
"type": "object",
"properties": {
"input": {
"absolutePath": {
"type": "string",
"description": "The apply_patch command that you wish to execute."
"description": "The absolute path to the file to write to."
},
"content": {
"type": "string",
"description": "After providing the path so a file can be created, then use this to provide the content to write to the file."
},
"task_progress": {
"type": "string",
@@ -69,7 +73,38 @@
}
},
"required": [
"input"
"absolutePath",
"content"
],
"additionalProperties": false
}
}
},
{
"type": "function",
"function": {
"name": "replace_in_file",
"description": "[IMPORTANT: Always output the absolutePath first] Request to replace sections of content in an existing file using SEARCH/REPLACE blocks that define exact changes to specific parts of the file. This tool should be used when you need to make targeted changes to specific parts of a file.",
"strict": false,
"parameters": {
"type": "object",
"properties": {
"absolutePath": {
"type": "string",
"description": "The absolute path to the file to write to."
},
"diff": {
"type": "string",
"description": "One or more SEARCH/REPLACE blocks following this exact format:\n ```\n ------- SEARCH\n [exact content to find]\n =======\n [new content to replace with]\n +++++++ REPLACE\n ```\n Critical rules:\n 1. SEARCH content must match the associated file section to find EXACTLY:\n\t * Match character-for-character including whitespace, indentation, line endings\n\t * Include all comments, docstrings, etc.\n 2. SEARCH/REPLACE blocks will ONLY replace the first match occurrence.\n\t * Including multiple unique SEARCH/REPLACE blocks if you need to make multiple changes.\n\t * Include *just* enough lines in each SEARCH section to uniquely match each set of lines that need to change.\n\t * When using multiple SEARCH/REPLACE blocks, list them in the order they appear in the file.\n 3. Keep SEARCH/REPLACE blocks concise:\n\t * Break large SEARCH/REPLACE blocks into a series of smaller blocks that each change a small portion of the file.\n\t * Include just the changing lines, and a few surrounding lines if needed for uniqueness.\n\t * Do not include long runs of unchanging lines in SEARCH/REPLACE blocks.\n\t * Each line must be complete. Never truncate lines mid-way through as this can cause matching failures.\n 4. Special operations:\n\t * To move code: Use two SEARCH/REPLACE blocks (one to delete from original + one to insert at new location)\n\t * To delete code: Use empty REPLACE section"
},
"task_progress": {
"type": "string",
"description": "A checklist showing task progress after this tool use is completed. The task_progress parameter must be included as a separate parameter inside of the parent tool call, it must be separate from other parameters such as content, arguments, etc. (See 'UPDATING TASK PROGRESS' section for more details)"
}
},
"required": [
"absolutePath",
"diff"
],
"additionalProperties": false
}
+1 -1
View File
@@ -16,6 +16,6 @@ export { validateVariant } from "./variants/variant-validator"
export async function getSystemPrompt(context: SystemPromptContext) {
const registry = PromptRegistry.getInstance()
const systemPrompt = await registry.get(context)
const tools = context.enableNativeToolCalls ? registry.nativeTools : undefined
const tools = registry.nativeTools
return { systemPrompt, tools }
}
@@ -24,12 +24,6 @@ export const config = createVariant(ModelFamily.NATIVE_GPT_5)
}
const providerInfo = context.providerInfo
const modelId = providerInfo.model.id
if (!isNextGenModelProvider(providerInfo)) {
return false
}
if (modelId.includes("gpt-oss")) {
return true
}
return (
isGPT5ModelFamily(modelId) &&
// Exclude gpt-5.1 and gpt-5.2 models except for codex variants
@@ -58,9 +52,9 @@ export const config = createVariant(ModelFamily.NATIVE_GPT_5)
ClineDefaultTool.BASH,
ClineDefaultTool.FILE_READ,
// Should disable FILE_NEW and FILE_EDIT when enabled
ClineDefaultTool.APPLY_PATCH,
// ClineDefaultTool.FILE_NEW, // Replaced by APPLY_PATCH
// ClineDefaultTool.FILE_EDIT, // Replaced by APPLY_PATCH
// ClineDefaultTool.APPLY_PATCH,
ClineDefaultTool.FILE_NEW, // Replaced by APPLY_PATCH
ClineDefaultTool.FILE_EDIT, // Replaced by APPLY_PATCH
ClineDefaultTool.SEARCH,
ClineDefaultTool.LIST_FILES,
ClineDefaultTool.LIST_CODE_DEF,
@@ -11,25 +11,20 @@ import { baseTemplate } from "./template"
export const config = createVariant(ModelFamily.XS)
.description("Prompt for models with a small context window.")
.version(1)
.tags("local", "xs", "compact", "native_tools")
.tags("local", "xs", "compact")
.labels({
stable: 1,
production: 1,
advanced: 1,
use_native_tools: 1,
})
.matcher((context) => {
const providerInfo = context.providerInfo
if (!isLocalModel(providerInfo)) {
return false
}
// Match compact local models
return providerInfo.customPrompt === "compact"
return providerInfo.customPrompt === "compact" && isLocalModel(providerInfo)
})
.template(baseTemplate)
.components(
SystemPromptSection.AGENT_ROLE,
SystemPromptSection.TOOL_USE,
SystemPromptSection.RULES,
SystemPromptSection.ACT_VS_PLAN,
SystemPromptSection.CLI_SUBAGENTS,
@@ -46,40 +41,24 @@ export const config = createVariant(ModelFamily.XS)
ClineDefaultTool.FILE_NEW,
ClineDefaultTool.FILE_EDIT,
ClineDefaultTool.SEARCH,
ClineDefaultTool.LIST_FILES,
ClineDefaultTool.ASK,
ClineDefaultTool.ATTEMPT,
ClineDefaultTool.NEW_TASK,
ClineDefaultTool.PLAN_MODE,
ClineDefaultTool.GENERATE_EXPLANATION,
ClineDefaultTool.USE_SKILL,
)
.placeholders({
MODEL_FAMILY: ModelFamily.XS,
})
.overrideComponent(SystemPromptSection.AGENT_ROLE, {
template: xsComponentOverrides.AGENT_ROLE,
})
.overrideComponent(SystemPromptSection.TOOL_USE, {
template: xsComponentOverrides.TOOL_USE,
})
.overrideComponent(SystemPromptSection.RULES, {
template: xsComponentOverrides.RULES,
})
.overrideComponent(SystemPromptSection.CLI_SUBAGENTS, {
template: xsComponentOverrides.CLI_SUBAGENTS,
})
.overrideComponent(SystemPromptSection.ACT_VS_PLAN, {
template: xsComponentOverrides.ACT_VS_PLAN,
})
.overrideComponent(SystemPromptSection.CAPABILITIES, {
template: xsComponentOverrides.CAPABILITIES,
})
.overrideComponent(SystemPromptSection.OBJECTIVE, {
template: xsComponentOverrides.OBJECTIVE,
})
.overrideComponent(SystemPromptSection.EDITING_FILES, {
template: xsComponentOverrides.EDITING_FILES,
})
.config({})
.build()
// Apply component overrides after building the base configuration
// This is necessary because the builder pattern doesn't support bulk overrides
Object.assign(config.componentOverrides, xsComponentOverrides)
// Compile-time validation
const validationResult = validateVariant({ ...config, id: ModelFamily.XS }, { strict: true })
if (!validationResult.isValid) {
@@ -1,4 +1,5 @@
import { SystemPromptContext } from "../../types"
import { SystemPromptSection } from "../../templates/placeholders"
import { PromptVariant } from "../../types"
const XS_EDITING_FILES = `FILE EDITING RULES
- Default: replace_in_file; write_to_file for new files or full rewrites.
@@ -39,10 +40,7 @@ const XS_OBJECTIVES = `EXECUTION FLOW
- Prefer replace_in_file; respect final formatted state.
- When all steps succeed and are confirmed, call attempt_completion (optional demo command).`
const XS_CLI_SUBAGENTS = (context: SystemPromptContext) =>
context.enableNativeToolCalls
? ""
: `USING THE CLINE CLI TOOL
const XS_CLI_SUBAGENTS = `USING THE CLINE CLI TOOL
The Cline CLI tool is installed and available for you to use to handle focused tasks without polluting your main context window. This can be done using
\`\`\`bash
@@ -54,74 +52,48 @@ Example:
cline t o "find all React components that use the useState hook and list their names"
\`\`\``
const XS_TOOLS_OVERRIDE = (context: SystemPromptContext) =>
context.enableNativeToolCalls
? `TOOLS
You have access to a set of tools that you are expected to use to resolve the task.`
: `TOOLS
**execute_command** — Run CLI in {{CWD}}.
Params: command, requires_approval.
Key: If output doesnt stream, assume success unless critical; else ask user to paste via ask_followup_question.
*Example:*
<execute_command>
<command>npm run build</command>
<requires_approval>false</requires_approval>
</execute_command>
**read_file** — Read file. Param: path.
*Example:* <read_file><path>src/App.tsx</path></read_file>
**write_to_file** — Create/overwrite file. Params: path, content (complete).
**replace_in_file** — Targeted edits. Params: path, diff.
*Example:*
<replace_in_file>
<path>src/index.ts</path>
<diff>
------- SEARCH
console.log('Hi');
=======
console.log('Hello');
+++++++ REPLACE
</diff>
</replace_in_file>
**search_files** — Regex search. Params: path, regex, file_pattern (optional).
**list_files** — List directory. Params: path, recursive (optional).
Key: Dont use to “confirm” writes; rely on returned tool results.
**ask_followup_question** — Get missing info. Params: question, options (25).
*Example:*
<ask_followup_question>
<question>Which package manager?</question>
<options>["npm","yarn","pnpm"]</options>
</ask_followup_question>
Key: Never include an option to toggle modes.
**attempt_completion** — Final result (no questions). Params: result, command (optional demo).
*Example:*
<attempt_completion>
<result>Feature X implemented with tests and docs.</result>
<command>npm run preview</command>
</attempt_completion>
**Gate:** Ask yourself inside <thinking> whether all prior tool uses were user-confirmed. If not, do **not** call.
**new_task** — Create a new task with context. Param: context (Current Work; Key Concepts; Relevant Files/Code; Problem Solving; Pending & Next).
**plan_mode_respond** — PLAN-only reply. Params: response, needs_more_exploration (optional).
Include options/trade-offs when helpful, ask if plan matches, then add the exact mode-switch line.`
export const xsComponentOverrides = {
AGENT_ROLE:
"You are Cline, a senior software engineer + precise task runner. Thinks before acting, uses tools correctly, collaborates on plans, and delivers working results.",
RULES: XS_RULES,
CLI_SUBAGENTS: XS_CLI_SUBAGENTS,
ACT_VS_PLAN: XS_ACT_PLAN_MODE,
CAPABILITIES: XS_CAPABILITIES,
OBJECTIVE: XS_OBJECTIVES,
EDITING_FILES: XS_EDITING_FILES,
TOOL_USE: XS_TOOLS_OVERRIDE,
} as const
export const xsComponentOverrides: PromptVariant["componentOverrides"] = {
[SystemPromptSection.AGENT_ROLE]: {
template:
"You are Cline, a senior software engineer + precise task runner. Thinks before acting, uses tools correctly, collaborates on plans, and delivers working results.",
},
[SystemPromptSection.TOOL_USE]: {
enabled: false, // XS variant includes tools inline in the template
},
[SystemPromptSection.TOOLS]: {
enabled: false, // XS variant includes tools inline in the template
},
[SystemPromptSection.MCP]: {
enabled: false, // XS variant includes MCP tools inline in the template
},
[SystemPromptSection.TODO]: {
enabled: false,
},
[SystemPromptSection.RULES]: {
template: XS_RULES,
},
[SystemPromptSection.CLI_SUBAGENTS]: {
template: XS_CLI_SUBAGENTS,
},
[SystemPromptSection.ACT_VS_PLAN]: {
template: XS_ACT_PLAN_MODE,
},
[SystemPromptSection.CAPABILITIES]: {
template: XS_CAPABILITIES,
},
[SystemPromptSection.OBJECTIVE]: {
template: XS_OBJECTIVES,
},
[SystemPromptSection.EDITING_FILES]: {
template: XS_EDITING_FILES,
},
[SystemPromptSection.SYSTEM_INFO]: {
enabled: true, // Use default system info
},
[SystemPromptSection.USER_INSTRUCTIONS]: {
enabled: true, // Use default user instructions
},
[SystemPromptSection.FEEDBACK]: {
enabled: false,
},
}
@@ -14,7 +14,60 @@ export const baseTemplate = `{{${SystemPromptSection.AGENT_ROLE}}}
## {{${SystemPromptSection.EDITING_FILES}}}
## {{${SystemPromptSection.TOOL_USE}}}
## TOOLS
**execute_command** — Run CLI in {{CWD}}.
Params: command, requires_approval.
Key: If output doesnt stream, assume success unless critical; else ask user to paste via ask_followup_question.
*Example:*
<execute_command>
<command>npm run build</command>
<requires_approval>false</requires_approval>
</execute_command>
**read_file** — Read file. Param: path.
*Example:* <read_file><path>src/App.tsx</path></read_file>
**write_to_file** — Create/overwrite file. Params: path, content (complete).
**replace_in_file** — Targeted edits. Params: path, diff.
*Example:*
<replace_in_file>
<path>src/index.ts</path>
<diff>
------- SEARCH
console.log('Hi');
=======
console.log('Hello');
+++++++ REPLACE
</diff>
</replace_in_file>
**search_files** — Regex search. Params: path, regex, file_pattern (optional).
**list_files** — List directory. Params: path, recursive (optional).
Key: Dont use to “confirm” writes; rely on returned tool results.
**ask_followup_question** — Get missing info. Params: question, options (25).
*Example:*
<ask_followup_question>
<question>Which package manager?</question>
<options>["npm","yarn","pnpm"]</options>
</ask_followup_question>
Key: Never include an option to toggle modes.
**attempt_completion** — Final result (no questions). Params: result, command (optional demo).
*Example:*
<attempt_completion>
<result>Feature X implemented with tests and docs.</result>
<command>npm run preview</command>
</attempt_completion>
**Gate:** Ask yourself inside <thinking> whether all prior tool uses were user-confirmed. If not, do **not** call.
**new_task** — Create a new task with context. Param: context (Current Work; Key Concepts; Relevant Files/Code; Problem Solving; Pending & Next).
**plan_mode_respond** — PLAN-only reply. Params: response, needs_more_exploration (optional).
Include options/trade-offs when helpful, ask if plan matches, then add the exact mode-switch line.
## {{${SystemPromptSection.OBJECTIVE}}}
+1 -5
View File
@@ -375,12 +375,8 @@ export class ToolExecutor {
this.isPlanModeToolRestricted(block.name)
) {
const errorMessage = `Tool '${block.name}' is not available in PLAN MODE. This tool is restricted to ACT MODE for file modifications. Only use tools available for PLAN MODE when in that mode.`
await this.removeLastPartialMessageIfExistsWithType("say", "error")
await this.say("error", errorMessage)
// Only push the final error message when the streaming is done.
if (!block.partial) {
this.pushToolResult(formatResponse.toolError(errorMessage), block)
}
this.pushToolResult(formatResponse.toolError(errorMessage), block)
return true
}
-15
View File
@@ -9,7 +9,6 @@ import { sendChatButtonClickedEvent } from "./core/controller/ui/subscribeToChat
import { sendHistoryButtonClickedEvent } from "./core/controller/ui/subscribeToHistoryButtonClicked"
import { sendMcpButtonClickedEvent } from "./core/controller/ui/subscribeToMcpButtonClicked"
import { sendSettingsButtonClickedEvent } from "./core/controller/ui/subscribeToSettingsButtonClicked"
import { sendWorktreesButtonClickedEvent } from "./core/controller/ui/subscribeToWorktreesButtonClicked"
import { WebviewProvider } from "./core/webview"
import { createClineAPI } from "./exports"
import { Logger } from "./services/logging/Logger"
@@ -45,7 +44,6 @@ import { ExtensionRegistryInfo } from "./registry"
import { AuthService } from "./services/auth/AuthService"
import { LogoutReason } from "./services/auth/types"
import { telemetryService } from "./services/telemetry"
import { ClineTempManager } from "./services/temp"
import { SharedUriHandler } from "./services/uri/SharedUriHandler"
import { ShowMessageType } from "./shared/proto/host/window"
import { fileExistsAtPath } from "./utils/fs"
@@ -89,9 +87,6 @@ export async function activate(context: vscode.ExtensionContext) {
const webview = (await initialize(context)) as VscodeWebviewProvider
// Clean up old temp files in background (non-blocking) and start periodic cleanup every 24 hours
ClineTempManager.startPeriodicCleanup()
Logger.log("Cline extension activated")
const testModeWatchers = await initializeTestMode(webview)
@@ -145,13 +140,6 @@ export async function activate(context: vscode.ExtensionContext) {
}),
)
context.subscriptions.push(
vscode.commands.registerCommand(commands.WorktreesButton, () => {
// Send event to all subscribers using the gRPC streaming method
sendWorktreesButtonClickedEvent()
}),
)
/*
We use the text document content provider API to show the left side for diff view by creating a
virtual document for the original content. This makes it readonly so users know to edit the right
@@ -501,9 +489,6 @@ async function getBinaryLocation(name: string): Promise<string> {
export async function deactivate() {
Logger.log("Cline extension deactivating, cleaning up resources...")
// Stop periodic temp file cleanup
ClineTempManager.stopPeriodicCleanup()
tearDown()
// Clean up test mode
@@ -28,34 +28,20 @@ describe("Hostbridge - Window - getOpenTabs", () => {
})
}
async function waitForAllTabsClosed(): Promise<void> {
await vscode.commands.executeCommand("workbench.action.closeAllEditors")
// Wait for tabs to actually close (Windows can be slow to process this)
await pWaitFor(
async () => {
const request = GetOpenTabsRequest.create({})
const response = await getOpenTabs(request)
return response.paths.length === 0
},
{
timeout: 5000,
interval: 50,
},
)
}
beforeEach(async () => {
// Clean up any existing editors and wait for cleanup to complete
await waitForAllTabsClosed()
// Clean up any existing editors
await vscode.commands.executeCommand("workbench.action.closeAllEditors")
})
afterEach(async () => {
// Clean up test documents and editors
await waitForAllTabsClosed()
await vscode.commands.executeCommand("workbench.action.closeAllEditors")
})
it("should return empty array when no tabs are open", async () => {
// beforeEach already ensures no tabs are open
// Ensure no tabs are open
await vscode.commands.executeCommand("workbench.action.closeAllEditors")
const request = GetOpenTabsRequest.create({})
const response = await getOpenTabs(request)
@@ -1,13 +0,0 @@
import * as vscode from "vscode"
import { OpenFolderRequest, OpenFolderResponse } from "@/shared/proto/host/workspace"
export async function openFolder(request: OpenFolderRequest): Promise<OpenFolderResponse> {
try {
const uri = vscode.Uri.file(request.path)
await vscode.commands.executeCommand("vscode.openFolder", uri, { forceNewWindow: request.newWindow })
return OpenFolderResponse.create({ success: true })
} catch (error) {
console.error("Failed to open folder:", error)
return OpenFolderResponse.create({ success: false })
}
}
@@ -18,9 +18,10 @@ import { formatResponse } from "@core/prompts/responses"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { Logger } from "@services/logging/Logger"
import { TerminalHangStage, TerminalUserInterventionAction, telemetryService } from "@services/telemetry"
import { ClineTempManager } from "@services/temp"
import { COMMAND_CANCEL_TOKEN } from "@shared/ExtensionMessage"
import * as fs from "fs"
import * as os from "os"
import * as path from "path"
import {
BUFFER_STUCK_TIMEOUT_MS,
CHUNK_BYTE_SIZE,
@@ -254,8 +255,8 @@ export async function orchestrateCommandExecution(
chunkTimer = null
}
// Set up file logging using ClineTempManager for proper cleanup
largeOutputLogPath = ClineTempManager.createTempFilePath("large-output")
// Set up file logging
largeOutputLogPath = path.join(os.tmpdir(), `cline-large-output-${Date.now()}.log`)
largeOutputLogStream = fs.createWriteStream(largeOutputLogPath, { flags: "a" })
// Write all existing lines to file in a single batch to reduce I/O overhead
@@ -12,8 +12,9 @@
* - Provides summary for environment details
*/
import { ClineTempManager } from "@services/temp"
import * as fs from "fs"
import * as os from "os"
import * as path from "path"
import {
BACKGROUND_COMMAND_TIMEOUT_MS,
DEFAULT_SUBAGENT_TERMINAL_OUTPUT_LINE_LIMIT,
@@ -430,8 +431,7 @@ export class StandaloneTerminalManager implements ITerminalManager {
existingOutput: string[] = [],
): BackgroundCommand {
const id = `background-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
// Use ClineTempManager for proper temp file management and cleanup
const logFilePath = ClineTempManager.createTempFilePath("background")
const logFilePath = path.join(os.tmpdir(), `cline-${id}.log`)
const backgroundCommand: BackgroundCommand = {
id,
@@ -155,18 +155,17 @@ export class StandaloneTerminalProcess extends EventEmitter<TerminalProcessEvent
this.isHot = false
}
// Track terminal execution telemetry with exit code for failure diagnosis
// Track terminal execution telemetry
const success = code === 0 || code === null
telemetryService.captureTerminalExecution(success, "standalone", "child_process", code)
telemetryService.captureTerminalExecution(success, "standalone", "child_process")
this.emit("completed")
this.emit("continue")
})
// Handle process errors (spawn failures)
// Handle process errors
this.childProcess.on("error", (error: Error) => {
// Track terminal execution error telemetry
// method: "child_process_error" already indicates spawn failure
telemetryService.captureTerminalExecution(false, "standalone", "child_process_error")
this.emit("error", error)
})
-1
View File
@@ -14,7 +14,6 @@ const ClineCommands = {
SettingsButton: prefix + ".settingsButtonClicked",
HistoryButton: prefix + ".historyButtonClicked",
AccountButton: prefix + ".accountButtonClicked",
WorktreesButton: prefix + ".worktreesButtonClicked",
TerminalOutput: prefix + ".addTerminalOutputToChat",
AddToChat: prefix + ".addToChat",
FixWithCline: prefix + ".fixWithCline",
+2 -2
View File
@@ -408,10 +408,10 @@ export class AuthService {
if (this._clineAuthInfo?.userInfo?.id) {
telemetryService.identifyAccount(this._clineAuthInfo.userInfo)
// Poll feature flags immediately for authenticated users to ensure cache is populated
await featureFlagsService.poll(this._clineAuthInfo.userInfo?.id)
await featureFlagsService.poll(this._clineAuthInfo?.userInfo?.id)
} else {
// Poll feature flags for unauthenticated state
await featureFlagsService.poll(null)
await featureFlagsService.poll(undefined)
}
// Update state in webviews once per unique controller
@@ -28,30 +28,25 @@ export class FeatureFlagsService {
/**
* Poll all known feature flags to update their cached values
*/
public async poll(userId: string | null): Promise<void> {
public async poll(userId?: string): Promise<void> {
// Do not update cache if last update was less than an hour ago
const timesNow = Date.now()
if (timesNow - this.cacheInfo.updateTime < DEFAULT_CACHE_TTL && this.cache.size) {
// Skip fetch if within TTL and user context is unchanged
if (this.cacheInfo.userId === userId) {
// If time is within TTL, only skip if user context (userId) is unchanged.
// If userId changed (including from/to undefined/null), refresh cache.
if (userId && this.cacheInfo.userId === userId) {
return
}
}
for (const flag of FEATURE_FLAGS) {
const payload = await this.getFeatureFlag(flag).catch(() => false)
this.cache.set(flag, payload ?? false)
}
// Only update timestamp after successfully populating cache
this.cacheInfo = { updateTime: timesNow, userId: userId || null }
try {
for (const flag of FEATURE_FLAGS) {
const payload = await this.getFeatureFlag(flag).catch(() => false)
this.cache.set(flag, payload ?? false)
}
} catch (error) {
// On error, clear cache info to force refresh on next poll
this.cacheInfo = { updateTime: 0, userId: null }
throw error
}
getClineOnboardingModels() // Refresh onboarding models cache if relevant flag changed
}
@@ -92,6 +87,10 @@ export class FeatureFlagsService {
return this.cache.get(flagName) === true
}
public getDoNothingFlag(): boolean {
return this.getBooleanFlagEnabled(FeatureFlag.DO_NOTHING)
}
public getHooksEnabled(): boolean {
return this.getBooleanFlagEnabled(FeatureFlag.HOOKS)
}
@@ -100,10 +99,6 @@ export class FeatureFlagsService {
return this.getBooleanFlagEnabled(FeatureFlag.WEBTOOLS)
}
public getWorktreesEnabled(): boolean {
return this.getBooleanFlagEnabled(FeatureFlag.WORKTREES)
}
public getOnboardingOverrides() {
const payload = this.cache.get(FeatureFlag.ONBOARDING_MODELS)
// Check if payload is object
+1 -6
View File
@@ -37,11 +37,6 @@ export function resetFeatureFlagsService(): void {
export const featureFlagsService = new Proxy({} as FeatureFlagsService, {
get(_target, prop, _receiver) {
const service = getFeatureFlagsService()
const value = Reflect.get(service, prop, service)
// Bind methods to the service instance to preserve `this` context
if (typeof value === "function") {
return value.bind(service)
}
return value
return Reflect.get(service, prop, service)
},
})
+2 -69
View File
@@ -312,15 +312,6 @@ export class TelemetryService {
// Tracks when hook discovery completes
DISCOVERY_COMPLETED: "hooks.discovery_completed",
},
// Worktree-related events for tracking worktree feature usage
WORKTREE: {
// Tracks when user opens worktrees view from home page
VIEW_OPENED: "worktree.view_opened",
// Tracks when a worktree is created
CREATED: "worktree.created",
// Tracks when a worktree merge is attempted
MERGE_ATTEMPTED: "worktree.merge_attempted",
},
}
public static async create(): Promise<TelemetryService> {
@@ -1673,31 +1664,18 @@ export class TelemetryService {
* @param success Whether the command output was successfully captured
* @param terminalType The type of terminal ("standalone")
* @param method The standalone-specific method used to capture output
* @param exitCode The process exit code (useful for diagnosing failure types: 1=error, 127=not found, 126=permission denied)
*/
public captureTerminalExecution(
success: boolean,
terminalType: "standalone",
method: StandaloneOutputMethod,
exitCode?: number | null,
): void
public captureTerminalExecution(success: boolean, terminalType: "standalone", method: StandaloneOutputMethod): void
/**
* Implementation of captureTerminalExecution
*/
public captureTerminalExecution(
success: boolean,
terminalType: TerminalType,
method: TerminalOutputMethod,
exitCode?: number | null,
): void {
public captureTerminalExecution(success: boolean, terminalType: TerminalType, method: TerminalOutputMethod): void {
this.capture({
event: TelemetryService.EVENTS.TASK.TERMINAL_EXECUTION,
properties: {
success,
terminalType,
method,
// Only include exitCode for standalone terminals when it's a meaningful value
...(terminalType === "standalone" && exitCode !== undefined && exitCode !== null && { exitCode }),
},
})
}
@@ -1897,51 +1875,6 @@ export class TelemetryService {
})
}
/**
* Records when user opens the worktrees view
* @param source Where the user opened the view from (home_page or menu_bar)
*/
public captureWorktreeViewOpened(source: "home_page" | "menu_bar") {
this.capture({
event: TelemetryService.EVENTS.WORKTREE.VIEW_OPENED,
properties: {
source,
},
})
}
/**
* Records when a worktree is created
* @param success Whether the creation was successful
* @param worktreeCount Total number of worktrees after creation (to track power users)
*/
public captureWorktreeCreated(success: boolean, worktreeCount?: number) {
this.capture({
event: TelemetryService.EVENTS.WORKTREE.CREATED,
properties: {
success,
worktree_count: worktreeCount,
},
})
}
/**
* Records when a worktree merge is attempted
* @param success Whether the merge was successful
* @param hasConflicts Whether merge conflicts were detected
* @param deleteAfterMerge Whether user chose to delete worktree after merge
*/
public captureWorktreeMergeAttempted(success: boolean, hasConflicts: boolean, deleteAfterMerge: boolean) {
this.capture({
event: TelemetryService.EVENTS.WORKTREE.MERGE_ATTEMPTED,
properties: {
success,
has_conflicts: hasConflicts,
delete_after_merge: deleteAfterMerge,
},
})
}
/**
* Checks if a specific telemetry category is enabled
* @param category The telemetry category to check
-224
View File
@@ -1,224 +0,0 @@
/**
* ClineTempManager - Manages temporary files for Cline with automatic cleanup.
*
* Simple approach:
* - Uses a "cline" subdirectory inside the system temp dir (falls back to system temp if creation fails)
* - Cleans up files older than 50 hours on extension activation
* - Enforces 2GB total size cap to prevent disk bloat
* - Cross-platform (macOS, Windows, Linux)
*/
import { Logger } from "@services/logging/Logger"
import * as fs from "fs"
import * as os from "os"
import * as path from "path"
// Configuration constants
const MAX_TOTAL_SIZE_BYTES = 2 * 1024 * 1024 * 1024 // 2GB
const MAX_FILE_AGE_MS = 50 * 60 * 60 * 1000 // 50 hours
const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000 // 24 hours
interface TempFileInfo {
path: string
size: number
mtime: number
}
/**
* Singleton manager for Cline's temporary files.
*/
class ClineTempManagerImpl {
private readonly tempDir: string
private cleanupIntervalId: NodeJS.Timeout | null = null
constructor() {
// Uses system temp directory with a dedicated "cline" subdirectory when possible:
// macOS: /var/folders/xx/.../T/cline
// Windows: C:\Users\{user}\AppData\Local\Temp\cline
// Linux: /tmp/cline
const baseTempDir = os.tmpdir()
const clineTempDir = path.join(baseTempDir, "cline")
try {
fs.mkdirSync(clineTempDir, { recursive: true })
this.tempDir = clineTempDir
} catch {
this.tempDir = baseTempDir
}
}
private ensureTempDirExists(): void {
try {
fs.mkdirSync(this.tempDir, { recursive: true })
} catch {
// If creation fails, we fall back to whatever tempDir currently is.
}
}
/**
* Get the temp directory path.
*/
getTempDir(): string {
return this.tempDir
}
/**
* Create a new temp file path with the given prefix.
* Does NOT create the file - just returns the path.
*
* @param prefix Prefix for the filename (e.g., "large-output", "background")
* @returns Full path to the temp file
*/
createTempFilePath(prefix: string): string {
this.ensureTempDirExists()
const filename = `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}.log`
return path.join(this.tempDir, filename)
}
/**
* Clean up old Cline temp files based on age and total size constraints.
* Called on extension activation.
*
* Strategy:
* 1. Scan the Cline temp directory
* 2. Delete all files older than 50 hours
* 3. If still over 2GB total, delete oldest files until under limit
*/
async cleanup(): Promise<{ deletedCount: number; freedBytes: number }> {
let deletedCount = 0
let freedBytes = 0
try {
this.ensureTempDirExists()
let files: string[]
try {
files = await fs.promises.readdir(this.tempDir)
} catch {
return { deletedCount: 0, freedBytes: 0 }
}
const fileInfos: TempFileInfo[] = []
for (const file of files) {
const filePath = path.join(this.tempDir, file)
try {
const stats = await fs.promises.stat(filePath)
if (stats.isFile()) {
fileInfos.push({
path: filePath,
size: stats.size,
mtime: stats.mtimeMs,
})
}
} catch {
// File might have been deleted by another process
}
}
const now = Date.now()
const remainingFiles: TempFileInfo[] = []
for (const fileInfo of fileInfos) {
const age = now - fileInfo.mtime
if (age > MAX_FILE_AGE_MS) {
try {
await fs.promises.unlink(fileInfo.path)
deletedCount++
freedBytes += fileInfo.size
Logger.info(
`Cleaned up old temp file: ${path.basename(fileInfo.path)} (age: ${Math.round(age / 3600000)}h)`,
)
} catch {
// File might have been deleted by another process
}
} else {
remainingFiles.push(fileInfo)
}
}
let totalSize = remainingFiles.reduce((sum, f) => sum + f.size, 0)
if (totalSize > MAX_TOTAL_SIZE_BYTES) {
remainingFiles.sort((a, b) => a.mtime - b.mtime)
for (const fileInfo of remainingFiles) {
if (totalSize <= MAX_TOTAL_SIZE_BYTES) {
break
}
try {
await fs.promises.unlink(fileInfo.path)
totalSize -= fileInfo.size
deletedCount++
freedBytes += fileInfo.size
Logger.info(
`Cleaned up temp file for space: ${path.basename(fileInfo.path)} (${Math.round(fileInfo.size / 1024)}KB)`,
)
} catch {
// File might have been deleted by another process
}
}
}
if (deletedCount > 0) {
Logger.info(`Cline temp cleanup: deleted ${deletedCount} files, freed ${Math.round(freedBytes / 1024 / 1024)}MB`)
}
} catch (error) {
Logger.error("Error during Cline temp cleanup", error)
}
return { deletedCount, freedBytes }
}
/**
* Delete a specific temp file.
*
* @param filePath Path to the file to delete
*/
async deleteFile(filePath: string): Promise<void> {
try {
await fs.promises.unlink(filePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
Logger.error(`Failed to delete temp file: ${filePath}`, error)
}
}
}
/**
* Start periodic cleanup every 24 hours.
* Call this on extension activation.
*/
startPeriodicCleanup(): void {
// Don't start multiple intervals
if (this.cleanupIntervalId) {
return
}
this.cleanup().catch((error) => {
Logger.error("Failed to clean up temp files", error)
})
this.cleanupIntervalId = setInterval(() => {
this.cleanup().catch((error) => {
Logger.error("Periodic temp cleanup failed", error)
})
}, CLEANUP_INTERVAL_MS)
// Use unref() so this interval doesn't prevent Node from exiting
this.cleanupIntervalId.unref()
}
/**
* Stop periodic cleanup.
* Call this on extension deactivation.
*/
stopPeriodicCleanup(): void {
if (this.cleanupIntervalId) {
clearInterval(this.cleanupIntervalId)
this.cleanupIntervalId = null
}
}
}
// Export singleton instance
export const ClineTempManager = new ClineTempManagerImpl()
-1
View File
@@ -1 +0,0 @@
export { ClineTempManager } from "./ClineTempManager"
-2
View File
@@ -89,7 +89,6 @@ export interface ExtensionState {
yoloModeToggled?: boolean
useAutoCondense?: boolean
clineWebToolsEnabled?: ClineFeatureSetting
worktreesEnabled?: ClineFeatureSetting
focusChainSettings: FocusChainSettings
dictationSettings: DictationSettings
customPrompt?: string
@@ -203,7 +202,6 @@ export interface ClineSayTool {
| "webFetch"
| "webSearch"
| "summarizeTask"
| "useSkill"
path?: string
diff?: string
content?: string
@@ -160,7 +160,6 @@ function convertLiteLLMModelInfoToProto(info: AppLiteLLMModelInfo | undefined):
description: info.description,
tiers: info.tiers || [],
temperature: info.temperature,
supportsReasoning: info.supportsReasoning,
}
}
@@ -184,7 +183,6 @@ function convertProtoToLiteLLMModelInfo(info: LiteLLMModelInfo | undefined): App
description: info.description,
tiers: info.tiers.length > 0 ? info.tiers : undefined,
temperature: info.temperature,
supportsReasoning: info.supportsReasoning,
}
}
@@ -112,7 +112,6 @@ export function fromProtobufLiteLLMModelInfo(protoInfo: ProtoLiteLLMModelInfo):
contextWindow: protoInfo.contextWindow,
supportsImages: protoInfo.supportsImages,
supportsPromptCache: protoInfo.supportsPromptCache,
supportsReasoning: protoInfo.supportsReasoning,
inputPrice: protoInfo.inputPrice,
outputPrice: protoInfo.outputPrice,
cacheWritesPrice: protoInfo.cacheWritesPrice,
+1 -1
View File
@@ -26,7 +26,7 @@
},
{
"value": "vscode-lm",
"label": "GitHub Copilot"
"label": "VS Code LM API"
},
{
"value": "deepseek",
@@ -4,17 +4,17 @@ export enum FeatureFlag {
CUSTOM_INSTRUCTIONS = "custom-instructions",
DICTATION = "dictation",
FOCUS_CHAIN_CHECKLIST = "focus_chain_checklist",
DO_NOTHING = "do_nothing",
HOOKS = "hooks",
WEBTOOLS = "webtools",
WORKTREES = "worktree-exp",
// Feature flag for showing the new onboarding flow or old welcome view.
ONBOARDING_MODELS = "onboarding_models",
}
export const FeatureFlagDefaultValue: Partial<Record<FeatureFlag, FeatureFlagPayload>> = {
[FeatureFlag.DO_NOTHING]: false,
[FeatureFlag.HOOKS]: false,
[FeatureFlag.WEBTOOLS]: false,
[FeatureFlag.WORKTREES]: false,
[FeatureFlag.ONBOARDING_MODELS]: process.env.E2E_TEST === "true" ? { models: {} } : undefined,
}
-3
View File
@@ -83,8 +83,6 @@ const GLOBAL_STATE_FIELDS = {
remoteRulesToggles: { default: {} as ClineRulesToggles },
remoteWorkflowToggles: { default: {} as ClineRulesToggles },
dismissedBanners: { default: [] as Array<{ bannerId: string; dismissedAt: number }> },
// Path to worktree that should auto-open Cline sidebar when launched
worktreeAutoOpenPath: { default: undefined as string | undefined },
} satisfies FieldDefinitions
// Fields that map directly to ApiHandlerOptions in @shared/api.ts
@@ -251,7 +249,6 @@ const USER_SETTINGS_FIELDS = {
yoloModeToggled: { default: false as boolean },
useAutoCondense: { default: false as boolean },
clineWebToolsEnabled: { default: true as boolean },
worktreesEnabled: { default: false as boolean },
preferredLanguage: { default: "English" as string },
openaiReasoningEffort: { default: "medium" as OpenaiReasoningEffort },
mode: { default: "act" as Mode },
+1 -1
View File
@@ -59,7 +59,7 @@ e2e("Views - can set up API keys and navigate to Settings from Chat", async ({ s
// Verify What's New Section is showing and starts with first banner,
// and the navigation buttons work
await expect(sidebar.locator('[aria-label="Announcements"]')).toBeVisible()
await expect(sidebar.locator(".animate-fade-in")).toBeVisible()
await expect(
sidebar
.locator("div")
+1 -1
View File
@@ -15,7 +15,7 @@ e2e("Chat - can send messages and switch between modes", async ({ helper, sideba
// Starting a new task should clear the current chat view and show the recent tasks
await sidebar.getByRole("button", { name: "New Task", exact: true }).first().click()
await expect(sidebar.getByText("Recent")).toBeVisible()
await expect(sidebar.getByText("Recent Tasks")).toBeVisible()
await expect(sidebar.getByText("Hello, Cline!")).toBeVisible()
// Makes sure the act and plan switches are working correctly
+1 -1
View File
@@ -18,7 +18,7 @@ e2e.describe("Diff Editor", () => {
// Back to home page with history
await sidebar.getByRole("button", { name: "Start New Task" }).click()
await expect(sidebar.getByText("Recent")).toBeVisible()
await expect(sidebar.getByText("Recent Tasks")).toBeVisible()
await expect(sidebar.getByText("Hello, Cline!")).toBeVisible() // History with the previous sent message
// Submit a file edit request
-324
View File
@@ -1,324 +0,0 @@
import * as path from "path"
import simpleGit from "simple-git"
import { copyWorktreeIncludeFiles } from "./worktree-include"
export interface Worktree {
path: string
branch: string
commitHash: string
isCurrent: boolean
isBare: boolean
isDetached: boolean
isLocked: boolean
lockReason?: string
}
export interface WorktreeResult {
success: boolean
message: string
worktree?: Worktree
}
export interface BranchInfo {
localBranches: string[]
remoteBranches: string[]
currentBranch: string
}
/**
* Check if git is installed
*/
async function checkGitInstalled(): Promise<boolean> {
try {
await simpleGit().version()
return true
} catch (_error) {
return false
}
}
/**
* Check if a directory is a git repository
*/
async function checkGitRepo(cwd: string): Promise<boolean> {
try {
const git = simpleGit(cwd)
return await git.checkIsRepo()
} catch (_error) {
return false
}
}
/**
* Get the current worktree path (same as git root for main worktree)
*/
async function getCurrentWorktreePath(cwd: string): Promise<string> {
try {
const git = simpleGit(cwd)
const root = await git.revparse(["--show-toplevel"])
return root.trim()
} catch (_error) {
return cwd
}
}
/**
* Get the git repository root path for a given directory.
* Returns null if not in a git repository.
*/
export async function getGitRootPath(cwd: string): Promise<string | null> {
const isInstalled = await checkGitInstalled()
if (!isInstalled) {
return null
}
try {
const git = simpleGit(cwd)
const isRepo = await git.checkIsRepo()
if (!isRepo) {
return null
}
const root = await git.revparse(["--show-toplevel"])
return root.trim()
} catch (_error) {
return null
}
}
/**
* List all worktrees in the repository
*/
export async function listWorktrees(cwd: string): Promise<{ worktrees: Worktree[]; isGitRepo: boolean; error?: string }> {
const isInstalled = await checkGitInstalled()
if (!isInstalled) {
return { worktrees: [], isGitRepo: false, error: "Git is not installed" }
}
const isRepo = await checkGitRepo(cwd)
if (!isRepo) {
return { worktrees: [], isGitRepo: false, error: "Not a git repository" }
}
try {
const currentPath = await getCurrentWorktreePath(cwd)
const git = simpleGit(cwd)
const stdout = await git.raw(["worktree", "list", "--porcelain"])
const worktrees: Worktree[] = []
const entries = stdout.trim().split("\n\n").filter(Boolean)
for (const entry of entries) {
const lines = entry.split("\n")
const worktree: Partial<Worktree> = {
isLocked: false,
isDetached: false,
isBare: false,
isCurrent: false,
}
for (const line of lines) {
if (line.startsWith("worktree ")) {
worktree.path = line.substring(9)
worktree.isCurrent = worktree.path === currentPath
} else if (line.startsWith("HEAD ")) {
worktree.commitHash = line.substring(5)
} else if (line.startsWith("branch ")) {
// Branch ref like "refs/heads/main" -> "main"
const branchRef = line.substring(7)
worktree.branch = branchRef.replace("refs/heads/", "")
} else if (line === "bare") {
worktree.isBare = true
} else if (line === "detached") {
worktree.isDetached = true
worktree.branch = ""
} else if (line === "locked") {
worktree.isLocked = true
} else if (line.startsWith("locked ")) {
worktree.isLocked = true
worktree.lockReason = line.substring(7)
}
}
if (worktree.path) {
worktrees.push(worktree as Worktree)
}
}
return { worktrees, isGitRepo: true }
} catch (error) {
return {
worktrees: [],
isGitRepo: true,
error: `Failed to list worktrees: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
/**
* Create a new worktree
*/
export async function createWorktree(
cwd: string,
worktreePath: string,
options: {
branch?: string
baseBranch?: string
createNewBranch?: boolean
} = {},
): Promise<WorktreeResult> {
const isInstalled = await checkGitInstalled()
if (!isInstalled) {
return { success: false, message: "Git is not installed" }
}
const isRepo = await checkGitRepo(cwd)
if (!isRepo) {
return { success: false, message: "Not a git repository" }
}
try {
const git = simpleGit(cwd)
const args: string[] = ["worktree", "add"]
if (options.createNewBranch && options.branch) {
// Create a new branch and worktree
args.push("-b", options.branch, worktreePath)
if (options.baseBranch) {
args.push(options.baseBranch)
}
} else if (options.branch) {
// Checkout existing branch
args.push(worktreePath, options.branch)
} else {
// Create detached worktree at HEAD
args.push("--detach", worktreePath)
}
await git.raw(args)
// Resolve the absolute path of the new worktree
const absoluteWorktreePath = path.isAbsolute(worktreePath) ? worktreePath : path.resolve(cwd, worktreePath)
// Copy files matched by .worktreeinclude (if it exists)
const { copiedCount, errors: copyErrors } = await copyWorktreeIncludeFiles(cwd, absoluteWorktreePath)
// Get the created worktree info
const { worktrees } = await listWorktrees(cwd)
const createdWorktree = worktrees.find((w) => w.path === absoluteWorktreePath)
let message = `Worktree created at ${worktreePath}`
if (copiedCount > 0) {
message += ` (copied ${copiedCount} file${copiedCount === 1 ? "" : "s"} from .worktreeinclude)`
}
if (copyErrors.length > 0) {
message += `. Some files failed to copy: ${copyErrors.slice(0, 3).join(", ")}`
if (copyErrors.length > 3) {
message += ` and ${copyErrors.length - 3} more`
}
}
return {
success: true,
message,
worktree: createdWorktree,
}
} catch (error) {
return {
success: false,
message: `Failed to create worktree: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
/**
* Delete a worktree
*/
export async function deleteWorktree(cwd: string, path: string, force: boolean = false): Promise<WorktreeResult> {
const isInstalled = await checkGitInstalled()
if (!isInstalled) {
return { success: false, message: "Git is not installed" }
}
const isRepo = await checkGitRepo(cwd)
if (!isRepo) {
return { success: false, message: "Not a git repository" }
}
try {
const git = simpleGit(cwd)
const args = force ? ["worktree", "remove", "--force", path] : ["worktree", "remove", path]
await git.raw(args)
return {
success: true,
message: `Worktree at ${path} has been removed`,
}
} catch (error) {
return {
success: false,
message: `Failed to remove worktree: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
/**
* Get available branches for creating worktrees
*/
export async function getAvailableBranches(cwd: string): Promise<BranchInfo> {
const isInstalled = await checkGitInstalled()
if (!isInstalled) {
return { localBranches: [], remoteBranches: [], currentBranch: "" }
}
const isRepo = await checkGitRepo(cwd)
if (!isRepo) {
return { localBranches: [], remoteBranches: [], currentBranch: "" }
}
try {
const git = simpleGit(cwd)
// Get current branch
let currentBranch = ""
try {
currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
currentBranch = currentBranch.trim()
if (currentBranch === "HEAD") {
// Detached HEAD state
currentBranch = ""
}
} catch {
// Detached HEAD state
currentBranch = ""
}
// Get all branches using branchLocal and branch -r
const branchSummary = await git.branchLocal()
const localBranches = branchSummary.all
// Get remote branches
const remoteBranchSummary = await git.branch(["-r"])
const remoteBranches = remoteBranchSummary.all.filter((b) => !b.includes("HEAD"))
// Filter out branches that already have worktrees
const { worktrees } = await listWorktrees(cwd)
const usedBranches = new Set(worktrees.map((w) => w.branch).filter(Boolean))
const availableLocalBranches = localBranches.filter((b) => !usedBranches.has(b))
const availableRemoteBranches = remoteBranches.filter((b) => {
// Remote branches like "origin/main" -> check if "main" is used
const shortName = b.split("/").slice(1).join("/")
return !usedBranches.has(shortName)
})
return {
localBranches: availableLocalBranches,
remoteBranches: availableRemoteBranches,
currentBranch,
}
} catch (error) {
console.error("Error getting available branches:", error)
return { localBranches: [], remoteBranches: [], currentBranch: "" }
}
}
-1
View File
@@ -14,7 +14,6 @@ export function isNextGenModelProvider(providerInfo: ApiProviderInfo): boolean {
"openai",
"minimax",
"openai-native",
"openai-compatible",
"baseten",
"vercel-ai-gateway",
"oca",
-139
View File
@@ -1,139 +0,0 @@
import * as fs from "fs/promises"
import { after, describe, it } from "mocha"
import * as os from "os"
import * as path from "path"
import "should"
import { copyWorktreeIncludeFiles, hasWorktreeInclude } from "./worktree-include"
describe("Worktree Include Utilities", () => {
const tmpDir = path.join(os.tmpdir(), "cline-worktree-test-" + Math.random().toString(36).slice(2))
const sourceDir = path.join(tmpDir, "source")
const targetDir = path.join(tmpDir, "target")
// Clean up after tests
after(async () => {
try {
await fs.rm(tmpDir, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
})
describe("hasWorktreeInclude", () => {
it("should return true when .worktreeinclude exists", async () => {
const testDir = path.join(tmpDir, "has-include")
await fs.mkdir(testDir, { recursive: true })
await fs.writeFile(path.join(testDir, ".worktreeinclude"), "node_modules/")
const result = await hasWorktreeInclude(testDir)
result.should.be.true()
})
it("should return false when .worktreeinclude does not exist", async () => {
const testDir = path.join(tmpDir, "no-include")
await fs.mkdir(testDir, { recursive: true })
const result = await hasWorktreeInclude(testDir)
result.should.be.false()
})
})
describe("copyWorktreeIncludeFiles", () => {
it("should return empty result when no .worktreeinclude file exists", async () => {
const src = path.join(tmpDir, "no-worktreeinclude-src")
const tgt = path.join(tmpDir, "no-worktreeinclude-tgt")
await fs.mkdir(src, { recursive: true })
await fs.mkdir(tgt, { recursive: true })
const result = await copyWorktreeIncludeFiles(src, tgt)
result.copiedCount.should.equal(0)
result.errors.should.be.empty()
})
it("should return empty result when no .gitignore file exists", async () => {
const src = path.join(tmpDir, "no-gitignore-src")
const tgt = path.join(tmpDir, "no-gitignore-tgt")
await fs.mkdir(src, { recursive: true })
await fs.mkdir(tgt, { recursive: true })
await fs.writeFile(path.join(src, ".worktreeinclude"), "node_modules/")
const result = await copyWorktreeIncludeFiles(src, tgt)
result.copiedCount.should.equal(0)
result.errors.should.be.empty()
})
it("should copy individual files matching both patterns", async () => {
const src = path.join(tmpDir, "file-copy-src")
const tgt = path.join(tmpDir, "file-copy-tgt")
// Setup source with files
await fs.mkdir(src, { recursive: true })
await fs.mkdir(tgt, { recursive: true })
await fs.writeFile(path.join(src, ".worktreeinclude"), "*.log\nbuild/")
await fs.writeFile(path.join(src, ".gitignore"), "*.log\nbuild/")
await fs.writeFile(path.join(src, "test.log"), "log content")
await fs.writeFile(path.join(src, "test.txt"), "txt content") // Should not be copied
const result = await copyWorktreeIncludeFiles(src, tgt)
result.copiedCount.should.equal(1)
result.errors.should.be.empty()
// Verify the log file was copied
const logExists = await fs.access(path.join(tgt, "test.log")).then(
() => true,
() => false,
)
logExists.should.be.true()
// Verify the txt file was NOT copied
const txtExists = await fs.access(path.join(tgt, "test.txt")).then(
() => true,
() => false,
)
txtExists.should.be.false()
})
it("should copy entire directories using native cp", async () => {
const src = path.join(tmpDir, "dir-copy-src")
const tgt = path.join(tmpDir, "dir-copy-tgt")
// Setup source with directory
await fs.mkdir(path.join(src, "node_modules", "pkg"), { recursive: true })
await fs.mkdir(tgt, { recursive: true })
await fs.writeFile(path.join(src, ".worktreeinclude"), "node_modules/")
await fs.writeFile(path.join(src, ".gitignore"), "node_modules/")
await fs.writeFile(path.join(src, "node_modules", "pkg", "index.js"), "module code")
await fs.writeFile(path.join(src, "node_modules", "file.txt"), "file in node_modules")
const result = await copyWorktreeIncludeFiles(src, tgt)
result.copiedCount.should.be.greaterThan(0)
result.errors.should.be.empty()
// Verify the directory was copied
const pkgExists = await fs.access(path.join(tgt, "node_modules", "pkg", "index.js")).then(
() => true,
() => false,
)
pkgExists.should.be.true()
})
it("should only copy files that are in both .worktreeinclude AND .gitignore", async () => {
const src = path.join(tmpDir, "intersection-src")
const tgt = path.join(tmpDir, "intersection-tgt")
await fs.mkdir(src, { recursive: true })
await fs.mkdir(tgt, { recursive: true })
await fs.writeFile(path.join(src, ".worktreeinclude"), "*.log")
await fs.writeFile(path.join(src, ".gitignore"), "*.tmp") // Different pattern
await fs.writeFile(path.join(src, "test.log"), "log")
await fs.writeFile(path.join(src, "test.tmp"), "tmp")
const result = await copyWorktreeIncludeFiles(src, tgt)
// Neither file should be copied since there's no intersection
result.copiedCount.should.equal(0)
})
})
})
-253
View File
@@ -1,253 +0,0 @@
import { exec } from "child_process"
import * as fs from "fs/promises"
import ignore from "ignore"
import * as path from "path"
import { promisify } from "util"
const execAsync = promisify(exec)
/** Batch size for parallel file operations */
const COPY_BATCH_SIZE = 100
/**
* Parses a .gitignore-style file and returns the patterns
*/
async function parseIgnoreFile(filePath: string): Promise<string[]> {
try {
const content = await fs.readFile(filePath, "utf-8")
return content
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"))
} catch {
return []
}
}
/**
* Check if a pattern represents a directory (ends with / or is a bare name that exists as a directory)
*/
async function isDirectoryPattern(sourceDir: string, pattern: string): Promise<string | null> {
// Normalize pattern - remove trailing slash
const cleanPattern = pattern.replace(/\/$/, "")
// Skip patterns with wildcards - these need file-by-file matching
if (cleanPattern.includes("*") || cleanPattern.includes("?") || cleanPattern.includes("[")) {
return null
}
// Check if this is a top-level directory
const dirPath = path.join(sourceDir, cleanPattern)
try {
const stat = await fs.stat(dirPath)
if (stat.isDirectory()) {
return cleanPattern
}
} catch {
// Path doesn't exist or can't be accessed
}
return null
}
/**
* Copy a directory using native cp -r (much faster than recursive Node.js copy)
*/
async function copyDirectoryNative(source: string, target: string): Promise<void> {
// Create parent directory if needed
await fs.mkdir(path.dirname(target), { recursive: true })
// Use native cp for performance (10-20x faster than Node.js)
const isWindows = process.platform === "win32"
if (isWindows) {
// Windows: use robocopy or xcopy
await execAsync(`xcopy "${source}" "${target}" /E /I /H /Y /Q`)
} else {
// Unix: use cp -r
await execAsync(`cp -r "${source}" "${target}"`)
}
}
/**
* Recursively gets all files in a directory (parallelized)
*/
async function getAllFiles(dir: string, baseDir: string): Promise<string[]> {
try {
const entries = await fs.readdir(dir, { withFileTypes: true })
const results = await Promise.all(
entries.map(async (entry) => {
const fullPath = path.join(dir, entry.name)
const relativePath = path.relative(baseDir, fullPath)
if (entry.isDirectory()) {
// Skip .git directory
if (entry.name === ".git") return []
return getAllFiles(fullPath, baseDir)
} else {
return [relativePath]
}
}),
)
return results.flat()
} catch {
// Directory doesn't exist or can't be read
return []
}
}
/**
* Copy files in parallel batches
*/
async function copyFilesInBatches(
files: string[],
sourceDir: string,
targetDir: string,
): Promise<{ copiedCount: number; errors: string[] }> {
const errors: string[] = []
let copiedCount = 0
// Process in batches for controlled parallelism
for (let i = 0; i < files.length; i += COPY_BATCH_SIZE) {
const batch = files.slice(i, i + COPY_BATCH_SIZE)
const results = await Promise.allSettled(
batch.map(async (file) => {
const sourcePath = path.join(sourceDir, file)
const targetPath = path.join(targetDir, file)
// Create target directory if it doesn't exist
await fs.mkdir(path.dirname(targetPath), { recursive: true })
// Copy the file
await fs.copyFile(sourcePath, targetPath)
return file
}),
)
for (const result of results) {
if (result.status === "fulfilled") {
copiedCount++
} else {
errors.push(result.reason?.message || "Unknown error")
}
}
}
return { copiedCount, errors }
}
/**
* Copies files matched by .worktreeinclude patterns that are also in .gitignore.
* Uses optimized strategies for performance:
* - Native cp -r for entire directories (10-20x faster)
* - Parallel file copying with batches (5-10x faster)
*
* @param sourceDir The source worktree directory (original repo)
* @param targetDir The target worktree directory (newly created)
* @returns Object with copied files count and any errors
*/
export async function copyWorktreeIncludeFiles(
sourceDir: string,
targetDir: string,
): Promise<{ copiedCount: number; errors: string[] }> {
const errors: string[] = []
let copiedCount = 0
// Read .worktreeinclude file
const worktreeIncludePath = path.join(sourceDir, ".worktreeinclude")
const includePatterns = await parseIgnoreFile(worktreeIncludePath)
if (includePatterns.length === 0) {
return { copiedCount: 0, errors: [] }
}
// Read .gitignore file
const gitignorePath = path.join(sourceDir, ".gitignore")
const gitignorePatterns = await parseIgnoreFile(gitignorePath)
if (gitignorePatterns.length === 0) {
return { copiedCount: 0, errors: [] }
}
// Create ignore matchers
const includeMatcher = ignore().add(includePatterns)
const gitignoreMatcher = ignore().add(gitignorePatterns)
// Separate patterns into directory patterns and file patterns
const directoryPatterns: string[] = []
const filePatterns: string[] = []
for (const pattern of includePatterns) {
const dirName = await isDirectoryPattern(sourceDir, pattern)
if (dirName) {
// Verify the directory is also gitignored
if (gitignoreMatcher.ignores(dirName) || gitignoreMatcher.ignores(dirName + "/")) {
directoryPatterns.push(dirName)
}
} else {
filePatterns.push(pattern)
}
}
// Handle directory patterns with native cp -r (fast path)
for (const dir of directoryPatterns) {
const sourcePath = path.join(sourceDir, dir)
const targetPath = path.join(targetDir, dir)
try {
await copyDirectoryNative(sourcePath, targetPath)
// Count files in the copied directory
const files = await getAllFiles(sourcePath, sourcePath)
copiedCount += files.length
} catch (error) {
errors.push(`Failed to copy directory ${dir}: ${error instanceof Error ? error.message : String(error)}`)
}
}
// Handle file patterns with parallel copying (if any remain)
if (filePatterns.length > 0) {
// Create matcher for just file patterns
const fileMatcher = ignore().add(filePatterns)
// Get all files, excluding already-copied directories
const dirSet = new Set(directoryPatterns)
const allFiles = await getAllFiles(sourceDir, sourceDir)
// Filter files that:
// 1. Are not in already-copied directories
// 2. Match file patterns
// 3. Are gitignored
const filesToCopy = allFiles.filter((file) => {
// Skip if in an already-copied directory
const topDir = file.split(path.sep)[0]
if (dirSet.has(topDir)) return false
// Must match both file patterns and gitignore
const isIncluded = fileMatcher.ignores(file) || includeMatcher.ignores(file)
const isGitignored = gitignoreMatcher.ignores(file)
return isIncluded && isGitignored
})
if (filesToCopy.length > 0) {
const result = await copyFilesInBatches(filesToCopy, sourceDir, targetDir)
copiedCount += result.copiedCount
errors.push(...result.errors)
}
}
return { copiedCount, errors }
}
/**
* Checks if a .worktreeinclude file exists in the given directory
*/
export async function hasWorktreeInclude(dir: string): Promise<boolean> {
try {
await fs.access(path.join(dir, ".worktreeinclude"))
return true
} catch {
return false
}
}
-1
View File
@@ -21,7 +21,6 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.3.tgz",
"integrity": "sha512-FTXHdOoPbZrBjlVLHuKbDZnsTxXv2BlHF57xw6LuThXacXvtkahEPED0CKMk6obZDf65Hv4k3z62eyPNpvinIg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.7.13",
"@js-sdsl/ordered-map": "^4.4.2"
+1 -5
View File
@@ -7,7 +7,6 @@ import McpView from "./components/mcp/configuration/McpConfigurationView"
import OnboardingView from "./components/onboarding/OnboardingView"
import SettingsView from "./components/settings/SettingsView"
import WelcomeView from "./components/welcome/WelcomeView"
import WorktreesView from "./components/worktrees/WorktreesView"
import { useClineAuth } from "./context/ClineAuthContext"
import { useExtensionState } from "./context/ExtensionStateContext"
import { Providers } from "./Providers"
@@ -24,7 +23,6 @@ const AppContent = () => {
settingsTargetSection,
showHistory,
showAccount,
showWorktrees,
showAnnouncement,
onboardingModels,
setShowAnnouncement,
@@ -34,7 +32,6 @@ const AppContent = () => {
hideSettings,
hideHistory,
hideAccount,
hideWorktrees,
hideAnnouncement,
} = useExtensionState()
@@ -76,11 +73,10 @@ const AppContent = () => {
organizations={organizations}
/>
)}
{showWorktrees && <WorktreesView onDone={hideWorktrees} />}
{/* Do not conditionally load ChatView, it's expensive and there's state we don't want to lose (user input, disableInput, askResponse promise, etc.) */}
<ChatView
hideAnnouncement={hideAnnouncement}
isHidden={showSettings || showHistory || showMcp || showAccount || showWorktrees}
isHidden={showSettings || showHistory || showMcp || showAccount}
showAnnouncement={showAnnouncement}
showHistoryView={navigateToHistory}
/>
+169 -27
View File
@@ -25,6 +25,7 @@ import {
LightbulbIcon,
Link2Icon,
LoaderCircleIcon,
LucideIcon,
PencilIcon,
RefreshCwIcon,
SearchIcon,
@@ -49,6 +50,7 @@ import { findMatchingResourceOrTemplate, getMcpServerDisplayName } from "@/utils
import CodeAccordian, { cleanPathPrefix } from "../common/CodeAccordian"
import { CommandOutputContent, CommandOutputRow } from "./CommandOutputRow"
import { CompletionOutputRow } from "./CompletionOutputRow"
import { getIconByToolName } from "./chat-view"
import { DiffEditRow } from "./DiffEditRow"
import ErrorRow from "./ErrorRow"
import HookMessage from "./HookMessage"
@@ -57,9 +59,9 @@ import NewTaskPreview from "./NewTaskPreview"
import PlanCompletionOutputRow from "./PlanCompletionOutputRow"
import QuoteButton from "./QuoteButton"
import ReportBugPreview from "./ReportBugPreview"
import { RequestStartRow } from "./RequestStartRow"
import SearchResultsDisplay from "./SearchResultsDisplay"
import { ThinkingRow } from "./ThinkingRow"
import { TypewriterText } from "./TypewriterText"
import UserMessage from "./UserMessage"
// State type for api_req_started rendering
@@ -676,18 +678,6 @@ export const ChatRowContent = memo(
</div>
</div>
)
case "useSkill":
return (
<div>
<div className={HEADER_CLASSNAMES}>
<LightbulbIcon className="size-2" />
<span className="font-bold">Cline loaded the skill:</span>
</div>
<div className="bg-code border border-editor-group-border overflow-hidden rounded-xs py-[9px] px-2.5">
<span className="ph-no-capture font-medium">{tool.path}</span>
</div>
</div>
)
default:
return <InvisibleSpacer />
}
@@ -799,21 +789,173 @@ export const ChatRowContent = memo(
switch (message.type) {
case "say":
switch (message.say) {
case "api_req_started":
case "api_req_started": {
// Derive explicit state
const hasError = !!(apiRequestFailedMessage || apiReqStreamingFailedMessage)
const hasCost = cost != null
const hasReasoning = !!reasoningContent
const hasResponseStarted = !!responseStarted
const apiReqState: ApiReqState = hasError
? "error"
: hasCost
? "final"
: hasReasoning
? "thinking"
: "pre"
// While reasoning is streaming, keep the Brain ThinkingBlock exactly as-is.
// Once response content starts (any text/tool/command), collapse into a compact
// "🧠 Thinking" row that can be expanded to show the reasoning only.
const showStreamingThinking = hasReasoning && !hasResponseStarted && !hasError && !hasCost
const showCollapsedThinking = hasReasoning && !showStreamingThinking
// Find all exploratory tool activities from the PREVIOUS completed API request.
// This shows what Cline just ingested while waiting for the next response.
// Includes action verbiage and icons for each tool type.
// Memoized to avoid iterating through all messages on every render.
const currentActivities = useMemo(() => {
const activities: { icon: LucideIcon; text: string }[] = []
// Helper to format search regex for display - show all terms separated by |
const formatSearchRegex = (regex: string, path: string, filePattern?: string): string => {
const terms = regex
.split("|")
.map((t) => t.trim().replace(/\\b/g, "").replace(/\\s\?/g, " "))
.filter(Boolean)
let result = `"${terms.join(" | ")}" in ${cleanPathPrefix(path)}/`
if (filePattern && filePattern !== "*") {
result += ` (${filePattern})`
}
return result
}
// Find the most recent api_req_started (the current one being rendered)
// Then find the PREVIOUS api_req_started that has a cost (completed)
// Collect all low-stakes tools between those two
let currentApiReqIndex = -1
let prevCompletedApiReqIndex = -1
// Find the current api_req_started (most recent)
for (let i = clineMessages.length - 1; i >= 0; i--) {
if (clineMessages[i].say === "api_req_started") {
currentApiReqIndex = i
break
}
}
if (currentApiReqIndex === -1) {
return activities
}
// Find the previous api_req_started that is completed (has cost)
for (let i = currentApiReqIndex - 1; i >= 0; i--) {
const msg = clineMessages[i]
if (msg.say === "api_req_started" && msg.text) {
try {
const info = JSON.parse(msg.text)
if (info.cost != null) {
prevCompletedApiReqIndex = i
break
}
} catch {
// ignore parse errors
}
}
}
if (prevCompletedApiReqIndex === -1) {
return activities
}
// Collect all low-stakes tools between prevCompletedApiReq and currentApiReq
for (let i = prevCompletedApiReqIndex + 1; i < currentApiReqIndex; i++) {
const msg = clineMessages[i]
if (msg.say === "tool" || msg.ask === "tool") {
try {
const tool = JSON.parse(msg.text || "{}") as ClineSayTool
const toolIcon = getIconByToolName(tool.tool)
// Exploratory tools - collect activity with icon and action verbiage
if (tool.tool === "readFile" && tool.path) {
activities.push({
icon: toolIcon,
text: `Reading ${cleanPathPrefix(tool.path)}...`,
})
} else if (tool.tool === "listFilesTopLevel" && tool.path) {
activities.push({
icon: toolIcon,
text: `Exploring ${cleanPathPrefix(tool.path)}/...`,
})
} else if (tool.tool === "listFilesRecursive" && tool.path) {
activities.push({
icon: toolIcon,
text: `Exploring ${cleanPathPrefix(tool.path)}/...`,
})
} else if (tool.tool === "searchFiles" && tool.regex && tool.path) {
activities.push({
icon: toolIcon,
text: `Searching ${formatSearchRegex(tool.regex, tool.path, tool.filePattern)}...`,
})
} else if (tool.tool === "listCodeDefinitionNames" && tool.path) {
activities.push({
icon: toolIcon,
text: `Analyzing ${cleanPathPrefix(tool.path)}/...`,
})
}
// Non-exploratory tools are ignored (they have their own UI)
} catch {
// ignore parse errors
}
}
}
return activities
}, [clineMessages])
return (
<RequestStartRow
apiReqStreamingFailedMessage={apiReqStreamingFailedMessage}
apiRequestFailedMessage={apiRequestFailedMessage}
clineMessages={clineMessages}
cost={cost}
handleToggle={handleToggle}
isExpanded={isExpanded}
message={message}
mode={mode}
reasoningContent={reasoningContent}
responseStarted={responseStarted}
/>
<div>
{apiReqState === "pre" && (
<div className="flex items-center text-description w-full text-sm">
<div className="ml-1 flex-1 w-full h-full">
{currentActivities.length > 0 ? (
<div className="flex flex-col gap-0.5 w-full min-h-1">
{currentActivities.map((activity, _) => (
<div
className="flex items-center gap-2 h-auto w-full overflow-hidden"
key={activity.text}>
<activity.icon className="size-2 text-foreground shrink-0" />
<TypewriterText speed={15} text={activity.text} />
</div>
))}
</div>
) : (
<TypewriterText text={mode === "plan" ? "Planning..." : "Thinking..."} />
)}
</div>
</div>
)}
{reasoningContent && (
<ThinkingRow
isExpanded={isExpanded || showStreamingThinking || showCollapsedThinking}
isVisible={true}
onToggle={handleToggle}
reasoningContent={reasoningContent}
showTitle={false}
/>
)}
{apiReqState === "error" && (
<ErrorRow
apiReqStreamingFailedMessage={apiReqStreamingFailedMessage}
apiRequestFailedMessage={apiRequestFailedMessage}
errorType="error"
message={message}
/>
)}
</div>
)
}
case "api_req_finished":
return <InvisibleSpacer /> // we should never see this message type
case "mcp_server_response":
@@ -1017,7 +1159,7 @@ export const ChatRowContent = memo(
)}
<div className="flex flex-col bg-quote p-0 rounded-[3px] text-[12px]">
<div className="flex items-center mb-1">
{isFailed && !isRequestInProgress ? (
{isFailed ? (
<TriangleAlertIcon className="mr-2 size-2" />
) : (
<RefreshCwIcon className="mr-2 size-2 animate-spin" />
@@ -0,0 +1,17 @@
/* Use theme-aware background and border colors for better contrast in all themes */
.completion-output-content pre {
background-color: rgba(0, 0, 0, 0.15) !important;
border-top: 1px solid var(--vscode-editorWidget-border, #cccccc);
border-bottom: 1px solid var(--vscode-editorWidget-border, #cccccc);
}
.completion-output-content code {
background-color: rgba(0, 0, 0, 0.15) !important;
border-top: 1px solid var(--vscode-editorWidget-border, #cccccc);
border-bottom: 1px solid var(--vscode-editorWidget-border, #cccccc);
}
.completion-output-content pre > code {
background-color: transparent;
border: none;
}
@@ -1,6 +1,7 @@
import { memo } from "react"
import { cn } from "@/lib/utils"
import { MarkdownRow } from "./MarkdownRow"
import "./CompletionOutputRow.css"
import { Int64Request } from "@shared/proto/cline/common"
import { CheckIcon } from "lucide-react"
import { PLATFORM_CONFIG, PlatformType } from "@/config/platform.config"
@@ -36,6 +37,9 @@ export const CompletionOutputRow = memo(
messageTs,
handleQuoteClick,
}: CompletionOutputRowProps) => {
const outputLines = text.split("\n")
const lineCount = outputLines.length
const shouldAutoShow = lineCount <= 5
return (
<div>
<div className="rounded-sm border border-success/20 overflow-visible bg-success/10 p-2 pt-3">
@@ -48,8 +52,15 @@ export const CompletionOutputRow = memo(
<CopyButton className="text-success" textToCopy={text} />
</div>
{/* Content */}
<div className="w-full relative border-t-1 border-description/20 rounded-b-sm">
<div className="completion-output-content p-2 pt-3 w-full [&_hr]:opacity-20 [&_p:last-child]:mb-0 rounded-sm">
<div className="w-full relative overflow-hidden border-t-1 border-description/20 rounded-b-sm">
<div
className={cn(
"completion-output-content",
"scroll-smooth p-2 pt-3 overflow-y-auto w-full [&_hr]:opacity-20 [&_p:last-child]:mb-0 rounded-sm max-h-[400px]",
{
"overflow-y-visible": shouldAutoShow,
},
)}>
<MarkdownRow markdown={text} />
{quoteButtonState.visible && (
<QuoteButton left={quoteButtonState.left} onClick={handleQuoteClick} top={quoteButtonState.top} />
@@ -233,9 +233,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
<ScreenReaderAnnounce message={announcement} />
<div
aria-activedescendant={
filteredOptions.length > selectedIndex &&
selectedIndex > -1 &&
isOptionSelectable(filteredOptions[selectedIndex])
filteredOptions.length > 0 && selectedIndex > -1 && isOptionSelectable(filteredOptions[selectedIndex])
? `context-menu-item-${selectedIndex}`
: undefined
}
@@ -1,5 +1,5 @@
import { NotepadTextIcon } from "lucide-react"
import { memo } from "react"
import { memo, useMemo } from "react"
import { CopyButton } from "@/components/common/CopyButton"
import MarkdownBlock from "@/components/common/MarkdownBlock"
import { cn } from "@/lib/utils"
@@ -15,6 +15,12 @@ interface PlanCompletionOutputProps {
* Uses grayscale colors to distinguish from Act Mode's green success theme
*/
const PlanCompletionOutputRow = memo(({ text, headClassNames }: PlanCompletionOutputProps) => {
const { shouldAutoShow } = useMemo(() => {
const lineCount = text?.split("\n")?.length || 0
const shouldAutoShow = lineCount <= 5
return { lineCount, shouldAutoShow }
}, [text])
return (
<div className="rounded-sm border border-description/50 overflow-visible bg-code p-2 pt-3">
{/* Header */}
@@ -27,9 +33,16 @@ const PlanCompletionOutputRow = memo(({ text, headClassNames }: PlanCompletionOu
</div>
{/* Content */}
<div className="w-full relative border-t-1 border-description/20 rounded-b-sm">
<div className="plan-completion-content p-2 pt-3 w-full [&_hr]:opacity-20 [&_p:last-child]:mb-0">
<div className="wrap-anywhere [&_hr]:opacity-20">
<div className="w-full relative overflow-hidden border-t-1 border-description/20 rounded-b-sm">
<div
className={cn(
"plan-completion-content",
"scroll-smooth p-2 pt-3 overflow-y-auto w-full [&_hr]:opacity-20 [&_p:last-child]:mb-0 max-h-[400px]",
{
"overflow-y-visible": shouldAutoShow,
},
)}>
<div className="wrap-anywhere -mb-4 overflow-hidden [&_hr]:opacity-20">
<MarkdownBlock markdown={text} />
</div>
</div>
@@ -1,214 +0,0 @@
import { ClineMessage, ClineSayTool } from "@shared/ExtensionMessage"
import { Mode } from "@shared/storage/types"
import { LucideIcon } from "lucide-react"
import type React from "react"
import { useMemo } from "react"
import { cleanPathPrefix } from "../common/CodeAccordian"
import { getIconByToolName } from "./chat-view"
import ErrorRow from "./ErrorRow"
import { ThinkingRow } from "./ThinkingRow"
import { TypewriterText } from "./TypewriterText"
interface RequestStartRowProps {
message: ClineMessage
apiRequestFailedMessage?: string
apiReqStreamingFailedMessage?: string
cost?: number
reasoningContent?: string
responseStarted?: boolean
clineMessages: ClineMessage[]
mode?: Mode
classNames?: string
isExpanded: boolean
handleToggle: () => void
}
// State type for api_req_started rendering
type ApiReqState = "pre" | "thinking" | "error" | "final"
// Helper to format search regex for display - show all terms separated by |
const formatSearchRegex = (regex: string, path: string, filePattern?: string): string => {
const cleanedPath = cleanPathPrefix(path)
const terms = regex
.split("|")
.map((t) => t.trim().replace(/\\b/g, "").replace(/\\s\?/g, " "))
.filter(Boolean)
.join(" | ")
return filePattern && filePattern !== "*" ? `"${terms}" in ${cleanedPath}/ (${filePattern})` : `"${terms}" in ${cleanedPath}/`
}
// Format activity text based on tool type
const getActivityText = (tool: ClineSayTool): string | null => {
const cleanedPath = cleanPathPrefix(tool.path || "")
switch (tool.tool) {
case "readFile":
return tool.path ? `Reading ${cleanedPath}...` : null
case "listFilesTopLevel":
case "listFilesRecursive":
return tool.path ? `Exploring ${cleanedPath}/...` : null
case "searchFiles":
return tool.regex && tool.path ? `Searching ${formatSearchRegex(tool.regex, tool.path, tool.filePattern)}...` : null
case "listCodeDefinitionNames":
return tool.path ? `Analyzing ${cleanedPath}/...` : null
default:
return null
}
}
// Collect tools in a given range, with optional stop condition
const collectToolsInRange = (
messages: ClineMessage[],
startIdx: number,
endIdx: number,
stopCondition?: (msg: ClineMessage) => boolean,
): { icon: LucideIcon; text: string }[] => {
const activities: { icon: LucideIcon; text: string }[] = []
for (let i = startIdx; i < endIdx; i++) {
const msg = messages[i]
if (stopCondition?.(msg)) {
break
}
if (msg.say !== "tool" && msg.ask !== "tool") {
continue
}
try {
const tool = JSON.parse(msg.text || "{}") as ClineSayTool
const activityText = getActivityText(tool)
if (activityText) {
const toolIcon = getIconByToolName(tool.tool)
activities.push({ icon: toolIcon, text: activityText })
}
} catch {
// ignore parse errors
}
}
return activities
}
// Find current api_req and determine if it has cost
const findCurrentApiReq = (messages: ClineMessage[]): { index: number; hasCost: boolean } | null => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.say === "api_req_started" && msg.text) {
try {
const info = JSON.parse(msg.text)
return { index: i, hasCost: info.cost != null }
} catch {
return null
}
}
}
return null
}
// Find the most recent completed api_req before the given index
const findPrevCompletedApiReq = (messages: ClineMessage[], beforeIdx: number): number => {
for (let i = beforeIdx - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.say === "api_req_started" && msg.text) {
try {
const info = JSON.parse(msg.text)
if (info.cost != null) {
return i
}
} catch {
// ignore parse errors
}
}
}
return -1
}
/**
* Displays the current state of an active tool operation,
*/
export const RequestStartRow: React.FC<RequestStartRowProps> = ({
apiRequestFailedMessage,
apiReqStreamingFailedMessage,
cost,
reasoningContent,
responseStarted,
clineMessages,
mode,
handleToggle,
isExpanded,
message,
}) => {
// Derive explicit state
const hasError = !!(apiRequestFailedMessage || apiReqStreamingFailedMessage)
const hasCost = cost != null
const hasReasoning = !!reasoningContent
const hasResponseStarted = !!responseStarted
const apiReqState: ApiReqState = hasError ? "error" : hasCost ? "final" : hasReasoning ? "thinking" : "pre"
// While reasoning is streaming, keep the Brain ThinkingBlock exactly as-is.
// Once response content starts (any text/tool/command), collapse into a compact
// "🧠 Thinking" row that can be expanded to show the reasoning only.
const showStreamingThinking = hasReasoning && !hasResponseStarted && !hasError && !hasCost
const showCollapsedThinking = hasReasoning && !showStreamingThinking
// Find all exploratory tool activities that are currently in flight.
// Only show tools between the previous completed API request and the current incomplete one.
// Once an API request completes (has cost), tool messages that follow belong to the next cycle.
const currentActivities = useMemo(() => {
const currentApiReq = findCurrentApiReq(clineMessages)
if (!currentApiReq) {
return []
}
if (!currentApiReq.hasCost) {
// CASE A: Current api_req is INCOMPLETE
const prevIdx = findPrevCompletedApiReq(clineMessages, currentApiReq.index)
if (prevIdx === -1) {
return []
}
return collectToolsInRange(clineMessages, prevIdx + 1, currentApiReq.index)
}
// CASE B: Current api_req is COMPLETE - no activities to show
return []
}, [clineMessages])
return (
<div>
{apiReqState === "pre" && (
<div className="flex items-center text-description w-full text-sm">
<div className="ml-1 flex-1 w-full h-full">
{currentActivities.length > 0 ? (
<div className="flex flex-col gap-0.5 w-full min-h-1">
{currentActivities.map((activity, _) => (
<div className="flex items-center gap-2 h-auto w-full overflow-hidden" key={activity.text}>
<activity.icon className="size-2 text-foreground shrink-0" />
<TypewriterText speed={15} text={activity.text} />
</div>
))}
</div>
) : (
<TypewriterText
text={message.partial !== false ? (mode === "plan" ? "Planning..." : "Thinking...") : ""}
/>
)}
</div>
</div>
)}
{reasoningContent && (
<ThinkingRow
isExpanded={isExpanded || showStreamingThinking || showCollapsedThinking}
isVisible={true}
onToggle={handleToggle}
reasoningContent={reasoningContent}
showTitle={false}
/>
)}
{apiReqState === "error" && (
<ErrorRow
apiReqStreamingFailedMessage={apiReqStreamingFailedMessage}
apiRequestFailedMessage={apiRequestFailedMessage}
errorType="error"
message={message}
/>
)}
</div>
)
}
@@ -1,20 +1,14 @@
import { BANNER_DATA, BannerAction, BannerActionType, BannerCardData } from "@shared/cline/banner"
import { EmptyRequest } from "@shared/proto/cline/common"
import type { Worktree } from "@shared/proto/cline/worktree"
import { TrackWorktreeViewOpenedRequest } from "@shared/proto/cline/worktree"
import { GitBranch } from "lucide-react"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import BannerCarousel from "@/components/common/BannerCarousel"
import WhatsNewModal from "@/components/common/WhatsNewModal"
import HistoryPreview from "@/components/history/HistoryPreview"
import { useApiConfigurationHandlers } from "@/components/settings/utils/useApiConfigurationHandlers"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import HomeHeader from "@/components/welcome/HomeHeader"
import { SuggestedTasks } from "@/components/welcome/SuggestedTasks"
import CreateWorktreeModal from "@/components/worktrees/CreateWorktreeModal"
import { useClineAuth } from "@/context/ClineAuthContext"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { AccountServiceClient, StateServiceClient, UiServiceClient, WorktreeServiceClient } from "@/services/grpc-client"
import { AccountServiceClient, StateServiceClient, UiServiceClient } from "@/services/grpc-client"
import { convertBannerData } from "@/utils/bannerUtils"
import { getCurrentPlatform } from "@/utils/platformUtils"
import { WelcomeSectionProps } from "../../types/chatTypes"
@@ -37,35 +31,8 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
const [hasShownWhatsNewModal, setHasShownWhatsNewModal] = useState(false)
const [showWhatsNewModal, setShowWhatsNewModal] = useState(false)
// Quick launch worktree modal
const [showCreateWorktreeModal, setShowCreateWorktreeModal] = useState(false)
const [isGitRepo, setIsGitRepo] = useState<boolean | null>(null)
const [currentWorktree, setCurrentWorktree] = useState<Worktree | null>(null)
// Check if we're in a git repo and get current worktree info on mount
useEffect(() => {
WorktreeServiceClient.listWorktrees(EmptyRequest.create({}))
.then((result) => {
const canUseWorktrees = result.isGitRepo && !result.isMultiRoot && !result.isSubfolder
setIsGitRepo(canUseWorktrees)
if (canUseWorktrees) {
const current = result.worktrees.find((w) => w.isCurrent)
setCurrentWorktree(current || null)
}
})
.catch(() => setIsGitRepo(false))
}, [])
const { clineUser } = useClineAuth()
const {
openRouterModels,
setShowChatModelSelector,
navigateToSettings,
navigateToWorktrees,
subagentsEnabled,
worktreesEnabled,
banners,
} = useExtensionState()
const { openRouterModels, setShowChatModelSelector, navigateToSettings, subagentsEnabled, banners } = useExtensionState()
const { handleFieldsChange } = useApiConfigurationHandlers()
// Show modal when there's a new announcement and we haven't shown it this session
@@ -82,14 +49,6 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
hideAnnouncement()
}, [hideAnnouncement])
// Handle click on home page worktree element with telemetry
const handleWorktreeClick = useCallback(() => {
WorktreeServiceClient.trackWorktreeViewOpened(TrackWorktreeViewOpenedRequest.create({ source: "home_page" })).catch(
console.error,
)
navigateToWorktrees()
}, [navigateToWorktrees])
/**
* Check if a banner has been dismissed based on its version
*/
@@ -236,65 +195,18 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
<HomeHeader shouldShowQuickWins={shouldShowQuickWins} />
{!showWhatsNewModal && (
<>
<BannerCarousel banners={activeBanners} />
{!shouldShowQuickWins && taskHistory.length > 0 && <HistoryPreview showHistoryView={showHistoryView} />}
{/* Quick launch worktree button */}
{isGitRepo && worktreesEnabled?.featureFlag && worktreesEnabled?.user && (
<div className="flex flex-col items-center gap-3 mt-2 mb-4 px-5">
{/* TODO: Re-enable once worktree creation is stable
<Tooltip>
<TooltipTrigger asChild>
<button
className="flex items-center gap-2 px-4 py-2 rounded-full border border-[var(--vscode-foreground)]/30 text-[var(--vscode-foreground)] bg-transparent hover:bg-[var(--vscode-list-hoverBackground)] active:opacity-80 text-sm font-medium cursor-pointer"
onClick={() => setShowCreateWorktreeModal(true)}
type="button">
<span className="codicon codicon-empty-window"></span>
New Worktree Window
</button>
</TooltipTrigger>
<TooltipContent side="top">
Create a new git worktree and open it in a separate window. Great for running parallel
Cline tasks.
</TooltipContent>
</Tooltip>
*/}
{currentWorktree && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="flex flex-col items-center gap-0.5 text-xs text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)] cursor-pointer bg-transparent border-none p-1 rounded"
onClick={handleWorktreeClick}
type="button">
<div className="flex items-center gap-1.5 text-xs">
<GitBranch className="w-3 h-3 stroke-[2.5] flex-shrink-0" />
<span className="break-all text-center">
<span className="font-semibold">Current:</span>{" "}
{currentWorktree.branch || "detached HEAD"}
</span>
</div>
<span className="break-all text-center max-w-[300px]">
{currentWorktree.path}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
View and manage git worktrees. Great for running parallel Cline tasks.
</TooltipContent>
</Tooltip>
)}
<div className="animate-fade-in">
<BannerCarousel banners={activeBanners} />
</div>
{!shouldShowQuickWins && taskHistory.length > 0 && (
<div className="animate-fade-in opacity-0">
<HistoryPreview showHistoryView={showHistoryView} />
</div>
)}
</>
)}
</div>
<SuggestedTasks shouldShowQuickWins={shouldShowQuickWins} />
{/* Quick launch worktree modal */}
<CreateWorktreeModal
onClose={() => setShowCreateWorktreeModal(false)}
open={showCreateWorktreeModal}
openAfterCreate={true}
/>
</div>
)
}
@@ -5,7 +5,7 @@ import ChatRow from "@/components/chat/ChatRow"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { cn } from "@/lib/utils"
import { MessageHandlers } from "../../types/chatTypes"
import { findReasoningForApiReq, isTextMessagePendingToolCall, isToolGroup } from "../../utils/messageUtils"
import { findReasoningForApiReq, isApiReqAbsorbable, isTextMessagePendingToolCall, isToolGroup } from "../../utils/messageUtils"
import { ToolGroupRenderer } from "./ToolGroupRenderer"
interface MessageRendererProps {
@@ -38,8 +38,7 @@ export const MessageRenderer: React.FC<MessageRendererProps> = ({
messageHandlers,
}) => {
const { mode } = useExtensionState()
const isLastMessage = useMemo(() => index === groupedMessages?.length - 1, [groupedMessages, index])
const isLastMessage = index === groupedMessages?.length - 1
// Get reasoning content and response status for api_req_started messages
const reasoningData = useMemo(() => {
@@ -82,6 +81,23 @@ export const MessageRenderer: React.FC<MessageRendererProps> = ({
)
}
// Determine if this is the last message for status display purposes
const nextMessage = index < groupedMessages.length - 1 && groupedMessages[index + 1]
const isNextCheckpoint = !Array.isArray(nextMessage) && nextMessage && nextMessage?.say === "checkpoint_created"
const isLastMessageGroup = isNextCheckpoint && index === groupedMessages.length - 2
const isLastMessageOrGroup = isLastMessage || isLastMessageGroup
// Deterministic flash fix:
// If this api_req_started is meant to be absorbed into a low-stakes tool group,
// never render it as a standalone row.
// BUT: Only absorb if this isn't the last/only message (to avoid hiding completed task api_reqs)
if (
messageOrGroup.say === "api_req_started" &&
(!isLastMessageOrGroup || isApiReqAbsorbable(messageOrGroup.ts, modifiedMessages))
) {
return null
}
// Regular message
return (
<div
@@ -384,11 +384,10 @@ export function isTextMessagePendingToolCall(textTs: number, allMessages: ClineM
* Check if a tool group should be hidden because its tools are currently being
* displayed in the loading state animation.
*
* Returns true when:
* 1. (Case A) The MOST RECENT api_req_started overall has no cost (loading state is active) AND
* this tool group falls in the "current activities" range (between the previous completed api_req and the current one)
* 2. (Case B) The MOST RECENT api_req_started overall has cost (is complete) AND
* this tool group appears after it (just arrived, waiting to be shown as "in flight")
* Returns true ONLY when:
* 1. The MOST RECENT api_req_started overall has no cost (loading state is active)
* 2. This tool group falls in the "current activities" range (between the previous
* completed api_req and the current one)
*
* This mirrors the ChatRow currentActivities logic - we only hide tools that are
* actively being shown in the loading state, not older tool groups.
@@ -397,7 +396,6 @@ export function isToolGroupInFlight(toolGroupMessages: ClineMessage[], allMessag
if (toolGroupMessages.length === 0) {
return false
}
// Step 1: Find the MOST RECENT api_req_started overall (search backwards)
let mostRecentApiReq: ClineMessage | null = null
let mostRecentApiReqIndex = -1
@@ -412,17 +410,41 @@ export function isToolGroupInFlight(toolGroupMessages: ClineMessage[], allMessag
if (!mostRecentApiReq?.text) {
return false
}
// Step 2: Determine if most recent api_req is complete (has cost) or incomplete (no cost)
let mostRecentHasCost = false
// Step 2: Check if it's in "pre" state (no cost = loading state active)
try {
const info = JSON.parse(mostRecentApiReq.text)
mostRecentHasCost = info.cost != null
if (info.cost != null) {
// Loading state is NOT active - show all tool groups in ToolGroupRenderer
return false
}
} catch {
return false
}
// Find the last tool in this group
// Step 3: Loading state IS active. Find the previous COMPLETED api_req.
let prevCompletedApiReqIndex = -1
for (let i = mostRecentApiReqIndex - 1; i >= 0; i--) {
const msg = allMessages[i]
if (msg.say === "api_req_started" && msg.text) {
try {
const prevInfo = JSON.parse(msg.text)
if (prevInfo.cost != null) {
prevCompletedApiReqIndex = i
break
}
} catch {
/* continue searching */
}
}
}
// If no previous completed api_req, there's no "current activities" range.
// ChatRow's currentActivities returns empty in this case, so don't hide the tool group.
if (prevCompletedApiReqIndex === -1) {
return false
}
// Step 4: Check if any tool in this group falls in the "current activities" range
const lastTool = [...toolGroupMessages].reverse().find((m) => isLowStakesTool(m))
if (!lastTool) {
return false
@@ -433,40 +455,10 @@ export function isToolGroupInFlight(toolGroupMessages: ClineMessage[], allMessag
return false
}
// Step 3: Determine if tool group is in-flight
if (!mostRecentHasCost) {
// CASE A: Most recent api_req is INCOMPLETE (loading state active)
// Tool group is in-flight if it's between prev completed and current incomplete
// Tool is in the "current activities" range if it's AFTER prevCompleted and BEFORE current
const isInCurrentActivitiesRange = toolIndex > prevCompletedApiReqIndex && toolIndex < mostRecentApiReqIndex
// Find the previous COMPLETED api_req
let prevCompletedApiReqIndex = -1
for (let i = mostRecentApiReqIndex - 1; i >= 0; i--) {
const msg = allMessages[i]
if (msg.say === "api_req_started" && msg.text) {
try {
const prevInfo = JSON.parse(msg.text)
if (prevInfo.cost != null) {
prevCompletedApiReqIndex = i
break
}
} catch {
/* continue searching */
}
}
}
// If no previous completed api_req, there's no "current activities" range
if (prevCompletedApiReqIndex === -1) {
return false
}
// Tool group is in-flight if AFTER prevCompleted AND BEFORE current
return toolIndex > prevCompletedApiReqIndex && toolIndex < mostRecentApiReqIndex
} else {
// CASE B: Most recent api_req is COMPLETE (has cost)
// Tool group is in-flight if it appears AFTER this completed api_req (just arrived)
return toolIndex > mostRecentApiReqIndex
}
return isInCurrentActivitiesRange
}
/**
@@ -475,125 +467,80 @@ export function isToolGroupInFlight(toolGroupMessages: ClineMessage[], allMessag
*
* This is used so ToolGroupRenderer shows PAST tools (what's already in context),
* while the loading state shows ACTIVE tools (what's being "read" now).
*
* "Current activities" includes:
* - (Case A) Tools between a previous completed api_req and the current incomplete api_req
* - (Case B) Tools after the most recent api_req overall (either because it's complete, or no loading state is active yet)
*/
export function getToolsNotInCurrentActivities(toolGroupMessages: ClineMessage[], allMessages: ClineMessage[]): ClineMessage[] {
// Build a Map of timestamp -> index for O(1) lookups instead of O(n) findIndex calls
const tsToIndex = new Map<number, number>()
for (let i = 0; i < allMessages.length; i++) {
tsToIndex.set(allMessages[i].ts, i)
}
// Step 1: Find the MOST RECENT api_req_started overall (search backwards)
let mostRecentApiReqIndex = -1
let mostRecentApiReq: ClineMessage | null = null
for (let i = allMessages.length - 1; i >= 0; i--) {
if (allMessages[i].say === "api_req_started") {
mostRecentApiReqIndex = i
mostRecentApiReq = allMessages[i]
break
}
}
if (mostRecentApiReqIndex === -1) {
// No api_req at all - show all tools
return toolGroupMessages
}
// Step 2: Check if it's in "pre" state (no cost = loading state active)
const mostRecentApiReq = allMessages[mostRecentApiReqIndex]
if (!mostRecentApiReq?.text) {
return toolGroupMessages
}
// Step 2: Determine if most recent api_req is complete (has cost) or incomplete (no cost)
let mostRecentHasCost = false
let isLoadingStateActive = false
try {
const info = JSON.parse(mostRecentApiReq.text)
mostRecentHasCost = info.cost != null
isLoadingStateActive = info.cost == null
} catch {
return toolGroupMessages
}
// Step 3: Determine which tools are "in current activities"
if (!mostRecentHasCost) {
// CASE A: Most recent api_req is INCOMPLETE (loading state active)
// Tools are in-flight if they're between prev completed api_req and current incomplete one
// Find the previous COMPLETED api_req
let prevCompletedApiReqIndex = -1
for (let i = mostRecentApiReqIndex - 1; i >= 0; i--) {
const msg = allMessages[i]
if (msg.say === "api_req_started" && msg.text) {
try {
const prevInfo = JSON.parse(msg.text)
if (prevInfo.cost != null) {
prevCompletedApiReqIndex = i
break
}
} catch {
/* continue searching */
}
}
}
if (prevCompletedApiReqIndex === -1) {
// No previous completed api_req, so no tools are in the "current activities" range
return toolGroupMessages
}
// Filter out tools in the range (prevCompleted, current)
return toolGroupMessages.filter((msg) => {
// Keep non-low-stakes tools
if (!isLowStakesTool(msg)) {
return true
}
// Filter out only tools awaiting approval (ask === 'tool')
// Completed tools (say === 'tool') should still be shown
if (msg.ask === "tool") {
const toolIndex = tsToIndex.get(msg.ts)
if (toolIndex === undefined) {
return true
}
// Tool is in "current activities" range if AFTER prevCompleted AND BEFORE current
const isInCurrentActivitiesRange = toolIndex > prevCompletedApiReqIndex && toolIndex < mostRecentApiReqIndex
// Filter out if in current activities range
return !isInCurrentActivitiesRange
}
// Keep completed tools (say === 'tool')
return true
})
} else {
// CASE B: Most recent api_req is COMPLETE (has cost)
// Tools that appear AFTER this completed api_req are "in flight" (just arrived)
// Filter them out so they appear in currentActivities instead
return toolGroupMessages.filter((msg) => {
// Keep non-low-stakes tools
if (!isLowStakesTool(msg)) {
return true
}
// Filter out only tools awaiting approval (ask === 'tool')
// Completed tools (say === 'tool') should still be shown
if (msg.ask === "tool") {
const toolIndex = tsToIndex.get(msg.ts)
if (toolIndex === undefined) {
return true
}
// Tool is in "current activities" if it appears AFTER the most recent api_req
const isInCurrentActivitiesRange = toolIndex > mostRecentApiReqIndex
// Filter out if in current activities range
return !isInCurrentActivitiesRange
}
// Keep completed tools (say === 'tool')
return true
})
if (!isLoadingStateActive) {
// Loading state is NOT active - show all tools
return toolGroupMessages
}
// Step 3: Loading state IS active. Find the previous COMPLETED api_req.
let prevCompletedApiReqIndex = -1
for (let i = mostRecentApiReqIndex - 1; i >= 0; i--) {
const msg = allMessages[i]
if (msg.say === "api_req_started" && msg.text) {
try {
const prevInfo = JSON.parse(msg.text)
if (prevInfo.cost != null) {
prevCompletedApiReqIndex = i
break
}
} catch {
/* continue searching */
}
}
}
// If no previous completed api_req, there's no "current activities" range
if (prevCompletedApiReqIndex === -1) {
return toolGroupMessages
}
// Step 4: Filter out tools that are in the "current activities" range
return toolGroupMessages.filter((msg) => {
// Only filter tool messages
if (!isLowStakesTool(msg)) {
return true
}
const toolIndex = allMessages.findIndex((m) => m.ts === msg.ts)
if (toolIndex === -1) {
return true
}
// Tool is in "current activities" range if AFTER prevCompleted AND BEFORE current
const isInCurrentActivitiesRange = toolIndex > prevCompletedApiReqIndex && toolIndex < mostRecentApiReqIndex
// Keep only if NOT in current activities range
return !isInCurrentActivitiesRange
})
}
/**
@@ -88,11 +88,9 @@ export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ open, onClose, ver
<ul className="text-sm pl-3 list-disc" style={{ color: "var(--vscode-descriptionForeground)" }}>
<li className="mb-2">
<strong>OpenAI:</strong> Added gpt-5.2-codex model support
<div>
<AuthButton>
<ModelButton label="Try now!" modelId="openai/gpt-5.2-codex" />
</AuthButton>
</div>
<AuthButton>
<ModelButton label="Try now!" modelId="openai/gpt-5.2-codex" />
</AuthButton>
</li>
<li className="mb-2">
<strong>Skills:</strong> Extend Cline with instruction sets for specialized tasks.{" "}
@@ -1,4 +1,5 @@
import { StringRequest } from "@shared/proto/cline/common"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { memo } from "react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { TaskServiceClient } from "@/services/grpc-client"
@@ -81,25 +82,6 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
font-weight: 500;
white-space: nowrap;
}
.history-view-all-btn {
background: none;
border: none;
padding: 4px 0 4px 8px;
cursor: pointer;
font-size: 0.85em;
font-weight: 500;
color: var(--vscode-descriptionForeground);
white-space: nowrap;
display: flex;
align-items: center;
gap: 2px;
}
.history-view-all-btn .codicon {
font-size: 1.2em;
}
.history-view-all-btn:hover {
color: var(--vscode-foreground);
}
`}
</style>
@@ -110,65 +92,79 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
margin: "10px 16px 10px 16px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}>
<div style={{ display: "flex", alignItems: "center" }}>
<span
className="codicon codicon-comment-discussion"
style={{
marginRight: "4px",
transform: "scale(0.9)",
}}></span>
<span
style={{
fontWeight: 500,
fontSize: "0.85em",
textTransform: "uppercase",
}}>
Recent
</span>
</div>
{taskHistory.filter((item) => item.ts && item.task).length > 0 && (
<button
aria-label="View all history"
className="history-view-all-btn"
onClick={() => showHistoryView()}
type="button">
View All
<span className="codicon codicon-chevron-right" />
</button>
)}
<span
className="codicon codicon-comment-discussion"
style={{
marginRight: "4px",
transform: "scale(0.9)",
}}></span>
<span
style={{
fontWeight: 500,
fontSize: "0.85em",
textTransform: "uppercase",
}}>
Recent Tasks
</span>
</div>
{
<div className="px-4">
{taskHistory.filter((item) => item.ts && item.task).length > 0 ? (
taskHistory
.filter((item) => item.ts && item.task)
.slice(0, 3)
.map((item) => (
<div className="history-preview-item" key={item.id} onClick={() => handleHistorySelect(item.id)}>
<div className="history-task-content">
{item.isFavorited && (
<span
aria-label="Favorited"
className="codicon codicon-star-full"
style={{
color: "var(--vscode-button-background)",
flexShrink: 0,
}}
/>
)}
<div className="history-task-description ph-no-capture">{item.task}</div>
<>
{taskHistory
.filter((item) => item.ts && item.task)
.slice(0, 3)
.map((item) => (
<div
className="history-preview-item"
key={item.id}
onClick={() => handleHistorySelect(item.id)}>
<div className="history-task-content">
{item.isFavorited && (
<span
aria-label="Favorited"
className="codicon codicon-star-full"
style={{
color: "var(--vscode-button-background)",
flexShrink: 0,
}}
/>
)}
<div className="history-task-description ph-no-capture">{item.task}</div>
</div>
<div className="history-meta-stack">
<span className="history-date">{formatDate(item.ts)}</span>
{item.totalCost != null && (
<span className="history-cost-chip">${item.totalCost.toFixed(2)}</span>
)}
</div>
</div>
<div className="history-meta-stack">
<span className="history-date">{formatDate(item.ts)}</span>
{item.totalCost != null && (
<span className="history-cost-chip">${item.totalCost.toFixed(2)}</span>
)}
))}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
<VSCodeButton
appearance="icon"
aria-label="View all history"
onClick={() => showHistoryView()}
style={{
opacity: 0.9,
}}>
<div
style={{
fontSize: "var(--vscode-font-size)",
color: "var(--vscode-descriptionForeground)",
}}>
View All
</div>
</div>
))
</VSCodeButton>
</div>
</>
) : (
<div
style={{
@@ -71,10 +71,10 @@ const HistoryViewItem = ({
}, [])
return (
<div className="history-item cursor-pointer flex group mb-1 hover:bg-list-hover border-b border-accent/10" key={item.id}>
<div className="history-item cursor-pointer flex group mb-1 hover:bg-list-hover" key={item.id}>
<VSCodeCheckbox
checked={selectedItems.includes(item.id)}
className="pl-3 pr-1 py-auto self-start mt-3"
className="pl-3 pr-1 py-auto"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
@@ -140,7 +140,7 @@ const HistoryViewItem = ({
{expanded ? (
<ChevronsDownUpIcon className="text-description" />
) : (
<ChevronsUpDownIcon className="text-description hidden opacity-0 group-hover:opacity-100 transition-opacity group-hover:block" />
<ChevronsUpDownIcon className="text-description" />
)}
</div>
</div>
@@ -1,12 +0,0 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
export function RemotelyConfiguredInputWrapper({ hidden, children }: React.PropsWithChildren<{ hidden: boolean }>) {
return (
<Tooltip>
<TooltipContent hidden={hidden}>This setting is managed by your organization's remote configuration</TooltipContent>
<TooltipTrigger>{children}</TooltipTrigger>
</Tooltip>
)
}
export const LockIcon = () => <i className="codicon codicon-lock text-description text-sm" />
@@ -6,7 +6,6 @@ import { ModelsServiceClient } from "@/services/grpc-client"
import { DebouncedTextField } from "../common/DebouncedTextField"
import { ModelInfoView } from "../common/ModelInfoView"
import { ModelSelector } from "../common/ModelSelector"
import { LockIcon, RemotelyConfiguredInputWrapper } from "../common/RemotelyConfiguredInputWrapper"
import ThinkingBudgetSlider from "../ThinkingBudgetSlider"
import { normalizeApiConfiguration } from "../utils/providerUtils"
import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers"
@@ -21,7 +20,7 @@ interface LiteLlmProviderProps {
}
export const LiteLlmProvider = ({ showModelOptions, isPopup, currentMode }: LiteLlmProviderProps) => {
const { apiConfiguration, remoteConfigSettings, liteLlmModels } = useExtensionState()
const { apiConfiguration, liteLlmModels, refreshLiteLlmModels } = useExtensionState()
const { handleModeFieldsChange } = useApiConfigurationHandlers()
// Get the normalized configuration with model info
@@ -47,31 +46,25 @@ export const LiteLlmProvider = ({ showModelOptions, isPopup, currentMode }: Lite
return (
<div>
<RemotelyConfiguredInputWrapper hidden={remoteConfigSettings?.liteLlmBaseUrl === undefined}>
<DebouncedTextField
disabled={remoteConfigSettings?.liteLlmBaseUrl !== undefined}
initialValue={apiConfiguration?.liteLlmBaseUrl || ""}
onChange={async (value) => {
await ModelsServiceClient.updateApiConfiguration(
UpdateApiConfigurationRequestNew.create({
updates: {
options: {
liteLlmBaseUrl: value,
},
<DebouncedTextField
initialValue={apiConfiguration?.liteLlmBaseUrl || ""}
onChange={async (value) => {
await ModelsServiceClient.updateApiConfiguration(
UpdateApiConfigurationRequestNew.create({
updates: {
options: {
liteLlmBaseUrl: value,
},
updateMask: ["options.liteLlmBaseUrl"],
}),
)
}}
placeholder={"Default: http://localhost:4000"}
style={{ width: "100%" }}
type="text">
<div className="flex items-center gap-2 mb-1">
<span style={{ fontWeight: 500 }}>Base URL (optional)</span>
{remoteConfigSettings?.liteLlmBaseUrl !== undefined && <LockIcon />}
</div>
</DebouncedTextField>
</RemotelyConfiguredInputWrapper>
},
updateMask: ["options.liteLlmBaseUrl"],
}),
)
}}
placeholder={"Default: http://localhost:4000"}
style={{ width: "100%" }}
type="text">
<span style={{ fontWeight: 500 }}>Base URL (optional)</span>
</DebouncedTextField>
<DebouncedTextField
initialValue={apiConfiguration?.liteLlmApiKey || ""}
onChange={async (value) => {
@@ -82,11 +82,21 @@ export const VSCodeLmProvider = ({ currentMode }: VSCodeLmProviderProps) => {
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Use models from your GitHub Copilot subscription. Install the{" "}
<a href="https://marketplace.visualstudio.com/items?itemName=GitHub.copilot">Copilot extension</a> and
enable Claude models in Copilot settings to get started.
The VS Code Language Model API allows you to run models provided by other VS Code extensions (including
but not limited to GitHub Copilot). The easiest way to get started is to install the Copilot extension
from the VS Marketplace and enabling Claude 4 Sonnet.
</p>
)}
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-errorForeground)",
fontWeight: 500,
}}>
Note: This is a very experimental integration and may not work as expected.
</p>
</DropdownContainer>
</div>
)
@@ -7,7 +7,6 @@ import { DROPDOWN_Z_INDEX, DropdownContainer } from "../ApiOptions"
import { DebouncedTextField } from "../common/DebouncedTextField"
import { ModelInfoView } from "../common/ModelInfoView"
import { ModelSelector } from "../common/ModelSelector"
import { LockIcon, RemotelyConfiguredInputWrapper } from "../common/RemotelyConfiguredInputWrapper"
import ThinkingBudgetSlider from "../ThinkingBudgetSlider"
import { normalizeApiConfiguration } from "../utils/providerUtils"
import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers"
@@ -41,7 +40,7 @@ const REGIONS = VertexData.regions
* The GCP Vertex AI provider configuration component
*/
export const VertexProvider = ({ showModelOptions, isPopup, currentMode }: VertexProviderProps) => {
const { apiConfiguration, remoteConfigSettings } = useExtensionState()
const { apiConfiguration } = useExtensionState()
const { handleFieldChange, handleModeFieldChange } = useApiConfigurationHandlers()
// Get the normalized configuration
@@ -60,45 +59,31 @@ export const VertexProvider = ({ showModelOptions, isPopup, currentMode }: Verte
flexDirection: "column",
gap: 5,
}}>
<RemotelyConfiguredInputWrapper hidden={remoteConfigSettings?.vertexProjectId === undefined}>
<DebouncedTextField
disabled={remoteConfigSettings?.vertexProjectId !== undefined}
initialValue={apiConfiguration?.vertexProjectId || ""}
onChange={(value) => handleFieldChange("vertexProjectId", value)}
placeholder="Enter Project ID..."
style={{ width: "100%" }}>
<div className="flex items-center gap-2 mb-1">
<span style={{ fontWeight: 500 }}>Google Cloud Project ID</span>
{remoteConfigSettings?.vertexProjectId !== undefined && <LockIcon />}
</div>
</DebouncedTextField>
</RemotelyConfiguredInputWrapper>
<DebouncedTextField
initialValue={apiConfiguration?.vertexProjectId || ""}
onChange={(value) => handleFieldChange("vertexProjectId", value)}
placeholder="Enter Project ID..."
style={{ width: "100%" }}>
<span style={{ fontWeight: 500 }}>Google Cloud Project ID</span>
</DebouncedTextField>
<RemotelyConfiguredInputWrapper hidden={remoteConfigSettings?.vertexRegion === undefined}>
<DropdownContainer className="dropdown-container" zIndex={DROPDOWN_Z_INDEX - 1}>
<div
className="flex items-center gap-2 mb-1"
style={{ opacity: remoteConfigSettings?.vertexRegion !== undefined ? 0.4 : 1 }}>
<label htmlFor="vertex-region-dropdown">
<span className="font-medium">Google Cloud Region</span>
</label>
{remoteConfigSettings?.vertexRegion !== undefined && <LockIcon />}
</div>
<VSCodeDropdown
disabled={remoteConfigSettings?.vertexRegion !== undefined}
id="vertex-region-dropdown"
onChange={(e: any) => handleFieldChange("vertexRegion", e.target.value)}
style={{ width: "100%" }}
value={apiConfiguration?.vertexRegion || ""}>
<VSCodeOption value="">Select a region...</VSCodeOption>
{REGIONS.map((region) => (
<VSCodeOption key={region} value={region}>
{region}
</VSCodeOption>
))}
</VSCodeDropdown>
</DropdownContainer>
</RemotelyConfiguredInputWrapper>
<DropdownContainer className="dropdown-container" zIndex={DROPDOWN_Z_INDEX - 1}>
<label htmlFor="vertex-region-dropdown">
<span style={{ fontWeight: 500 }}>Google Cloud Region</span>
</label>
<VSCodeDropdown
id="vertex-region-dropdown"
onChange={(e: any) => handleFieldChange("vertexRegion", e.target.value)}
style={{ width: "100%" }}
value={apiConfiguration?.vertexRegion || ""}>
<VSCodeOption value="">Select a region...</VSCodeOption>
{REGIONS.map((region) => (
<VSCodeOption key={region} value={region}>
{region}
</VSCodeOption>
))}
</VSCodeDropdown>
</DropdownContainer>
<p
style={{
@@ -27,7 +27,6 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
dictationSettings,
useAutoCondense,
clineWebToolsEnabled,
worktreesEnabled,
focusChainSettings,
multiRootSetting,
hooksEnabled,
@@ -334,21 +333,6 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
</p>
</div>
)}
{worktreesEnabled?.featureFlag && (
<div style={{ marginTop: 10 }}>
<VSCodeCheckbox
checked={worktreesEnabled?.user}
onChange={(e: any) => {
const checked = e.target.checked === true
updateSetting("worktreesEnabled", checked)
}}>
Enable Worktrees
</VSCodeCheckbox>
<p className="text-xs text-(--vscode-descriptionForeground)">
Enables git worktree management for running parallel Cline tasks.
</p>
</div>
)}
<div className="mt-2.5">
<VSCodeCheckbox
checked={nativeToolCallSetting}
@@ -1,6 +1,8 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { InfoIcon } from "lucide-react"
import ClineLogoSanta from "@/assets/ClineLogoSanta"
import ClineLogoVariable from "@/assets/ClineLogoVariable"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { UiServiceClient } from "@/services/grpc-client"
@@ -25,11 +27,41 @@ const HomeHeader = ({ shouldShowQuickWins = false }: HomeHeaderProps) => {
return (
<div className="flex flex-col items-center mb-5">
<div className="my-7">
<style>
{`
@keyframes logo-pop-in {
0% {
opacity: 0;
transform: scale(0.95);
}
60% {
opacity: 1;
transform: scale(1.02);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.logo-animate {
animation: logo-pop-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
`}
</style>
<div className="my-7 logo-animate">
<LogoComponent className="size-20" environment={environment} />
</div>
<div className="text-center flex items-center justify-center px-4">
<h1 className="m-0 font-bold">What can I do for you?</h1>
<Tooltip>
<TooltipContent side="bottom">
I can develop software step-by-step by editing files, exploring projects, running commands, and using
browsers. I can even extend my capabilities with MCP tools to assist beyond basic code completion.
</TooltipContent>
<TooltipTrigger asChild>
<InfoIcon className="ml-2 cursor-pointer text-link text-sm size-2 shrink-0" />
</TooltipTrigger>
</Tooltip>
</div>
{shouldShowQuickWins && (
<div className="mt-4">
@@ -1,228 +0,0 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { CreateWorktreeRequest, SwitchWorktreeRequest } from "@shared/proto/cline/worktree"
import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { AlertCircle, AlertTriangle, Loader2, X } from "lucide-react"
import { memo, useCallback, useEffect, useState } from "react"
import { WorktreeServiceClient } from "@/services/grpc-client"
interface CreateWorktreeModalProps {
open: boolean
onClose: () => void
/** When true, opens the worktree in a new window after creation */
openAfterCreate?: boolean
/** Called after successful creation (and opening if openAfterCreate is true) */
onSuccess?: () => void
}
const CreateWorktreeModal = ({ open, onClose, openAfterCreate = false, onSuccess }: CreateWorktreeModalProps) => {
const [newWorktreePath, setNewWorktreePath] = useState("")
const [newBranchName, setNewBranchName] = useState("")
const [isCreating, setIsCreating] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [isLoadingDefaults, setIsLoadingDefaults] = useState(false)
const [hasWorktreeInclude, setHasWorktreeInclude] = useState<boolean | null>(null)
// Load defaults and check .worktreeinclude status when modal opens
const loadDefaults = useCallback(async () => {
setIsLoadingDefaults(true)
try {
const [defaults, includeStatus] = await Promise.all([
WorktreeServiceClient.getWorktreeDefaults(EmptyRequest.create({})),
WorktreeServiceClient.getWorktreeIncludeStatus(EmptyRequest.create({})),
])
setNewBranchName(defaults.suggestedBranch)
setNewWorktreePath(defaults.suggestedPath)
setHasWorktreeInclude(includeStatus.exists)
} catch (err) {
console.error("Failed to load worktree defaults:", err)
} finally {
setIsLoadingDefaults(false)
}
}, [])
useEffect(() => {
if (open) {
loadDefaults()
}
}, [open, loadDefaults])
// Reset form state when modal closes
useEffect(() => {
if (!open) {
setNewWorktreePath("")
setNewBranchName("")
setCreateError(null)
setHasWorktreeInclude(null)
}
}, [open])
const handleCreateWorktree = useCallback(async () => {
if (!newWorktreePath || !newBranchName) {
return
}
setIsCreating(true)
setCreateError(null)
try {
const result = await WorktreeServiceClient.createWorktree(
CreateWorktreeRequest.create({
path: newWorktreePath,
branch: newBranchName,
createNewBranch: true,
}),
)
if (!result.success) {
setCreateError(result.message)
} else {
// If openAfterCreate is true, open the worktree in a new window
if (openAfterCreate && result.worktree?.path) {
await WorktreeServiceClient.switchWorktree(
SwitchWorktreeRequest.create({
path: result.worktree.path,
newWindow: true,
}),
)
}
onSuccess?.()
onClose()
}
} catch (err) {
setCreateError(err instanceof Error ? err.message : "Failed to create worktree")
} finally {
setIsCreating(false)
}
}, [newWorktreePath, newBranchName, openAfterCreate, onSuccess, onClose])
if (!open) {
return null
}
const title = openAfterCreate ? "New Worktree" : "Create New Worktree"
const buttonText = openAfterCreate ? "Create & Open" : "Create Worktree"
const creatingText = openAfterCreate ? "Creating & Opening..." : "Creating..."
const description = openAfterCreate
? "This will create a copy of your project on a new branch and open in a separate window."
: "This will create a copy of your project on a new branch."
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose()
}
}}>
<div className="bg-[var(--vscode-editor-background)] border border-[var(--vscode-panel-border)] rounded-lg p-5 w-[450px] max-w-[90vw] relative">
{/* Close button */}
<button
className="absolute top-3 right-3 p-1 rounded hover:bg-[var(--vscode-toolbar-hoverBackground)] text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)] cursor-pointer"
onClick={onClose}
type="button">
<X className="w-4 h-4" />
</button>
<h4 className="mt-0 mb-2 pr-6">{title}</h4>
<p className="text-sm text-[var(--vscode-descriptionForeground)] mt-0 mb-4">{description}</p>
{hasWorktreeInclude === false && (
<div
className="flex items-start gap-2 p-2 rounded mb-3"
style={{ backgroundColor: "var(--vscode-inputValidation-warningBackground)" }}>
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5 text-[var(--vscode-editorWarning-foreground)]" />
<p className="text-xs text-[var(--vscode-foreground)] m-0">
No .worktreeinclude detected.{" "}
<a
className="text-[var(--vscode-textLink-foreground)] hover:text-[var(--vscode-textLink-activeForeground)]"
href="https://docs.cline.bot/features/worktrees#worktreeinclude"
rel="noopener noreferrer"
style={{ fontSize: "inherit" }}
target="_blank">
Learn more
</a>
</p>
</div>
)}
<div className="flex flex-col">
<div>
<label className="block text-sm font-medium mb-1">Branch Name *</label>
<VSCodeTextField
className="w-full"
onInput={(e) => setNewBranchName((e.target as HTMLInputElement).value)}
placeholder="feature/my-feature"
value={newBranchName}>
{newBranchName && (
<div
aria-label="Clear"
className="input-icon-button codicon codicon-close"
onClick={() => setNewBranchName("")}
slot="end"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
}}
/>
)}
</VSCodeTextField>
<p className="text-xs text-[var(--vscode-descriptionForeground)] mt-1">
Your new copy will be checked out to this branch.
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Folder Path *</label>
<VSCodeTextField
className="w-full"
onInput={(e) => setNewWorktreePath((e.target as HTMLInputElement).value)}
placeholder="../my-feature-worktree"
value={newWorktreePath}>
{newWorktreePath && (
<div
aria-label="Clear"
className="input-icon-button codicon codicon-close"
onClick={() => setNewWorktreePath("")}
slot="end"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
}}
/>
)}
</VSCodeTextField>
<p className="text-xs text-[var(--vscode-descriptionForeground)] mt-1">
Where the project will be copied for the worktree.
</p>
</div>
{createError && (
<div className="flex items-start gap-2 p-3 rounded bg-[var(--vscode-inputValidation-errorBackground)] border border-[var(--vscode-inputValidation-errorBorder)]">
<AlertCircle className="w-4 h-4 flex-shrink-0 text-[var(--vscode-errorForeground)] mt-0.5" />
<p className="text-sm text-[var(--vscode-errorForeground)] m-0">{createError}</p>
</div>
)}
<div className="flex justify-end gap-2">
<VSCodeButton
disabled={!newWorktreePath || !newBranchName || isCreating || isLoadingDefaults}
onClick={handleCreateWorktree}>
{isLoadingDefaults ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
Loading...
</>
) : isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{creatingText}
</>
) : (
buttonText
)}
</VSCodeButton>
</div>
</div>
</div>
</div>
)
}
export default memo(CreateWorktreeModal)
@@ -1,100 +0,0 @@
import { VSCodeButton, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { AlertTriangle, Loader2, X } from "lucide-react"
import { memo, useCallback, useState } from "react"
import { Button } from "@/components/ui/button"
interface DeleteWorktreeModalProps {
open: boolean
onClose: () => void
onConfirm: (deleteBranch: boolean) => Promise<void>
worktreePath: string
branchName: string
}
const DeleteWorktreeModal = ({ open, onClose, onConfirm, worktreePath, branchName }: DeleteWorktreeModalProps) => {
const [isDeleting, setIsDeleting] = useState(false)
const [deleteBranch, setDeleteBranch] = useState(false)
const handleDelete = useCallback(async () => {
setIsDeleting(true)
try {
await onConfirm(deleteBranch)
onClose()
} finally {
setIsDeleting(false)
setDeleteBranch(false)
}
}, [onConfirm, onClose, deleteBranch])
if (!open) {
return null
}
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={(e) => {
if (e.target === e.currentTarget && !isDeleting) {
onClose()
}
}}>
<div className="bg-[var(--vscode-editor-background)] border border-[var(--vscode-panel-border)] rounded-lg p-5 w-[400px] max-w-[90vw] relative">
{/* Close button */}
<button
className="absolute top-3 right-3 p-1 rounded hover:bg-[var(--vscode-toolbar-hoverBackground)] text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)] cursor-pointer disabled:opacity-50"
disabled={isDeleting}
onClick={onClose}
type="button">
<X className="w-4 h-4" />
</button>
{/* Title row with icon */}
<div className="flex items-center gap-2 mb-3 pr-6">
<AlertTriangle className="w-5 h-5 text-[var(--vscode-errorForeground)]" />
<h4 className="m-0">Delete Worktree</h4>
</div>
{/* Content */}
<p className="text-sm text-[var(--vscode-descriptionForeground)] mt-0 mb-3">
This will delete the worktree directory at{" "}
<span className="font-semibold text-[var(--vscode-foreground)] break-all">{worktreePath}</span>
</p>
<label className="flex items-center gap-2 cursor-pointer mb-3">
<VSCodeCheckbox
checked={deleteBranch}
onChange={(e) => setDeleteBranch((e.target as HTMLInputElement).checked)}
/>
<span className="text-sm">
Also delete branch <span className="font-semibold">{branchName}</span>
</span>
</label>
{deleteBranch && (
<p className="text-sm text-[var(--vscode-inputValidation-warningForeground)] mt-0 mb-3">
Warning: Unpushed commits on this branch will be lost.
</p>
)}
{/* Buttons */}
<div className="flex justify-end gap-2">
<VSCodeButton appearance="secondary" disabled={isDeleting} onClick={onClose}>
Cancel
</VSCodeButton>
<Button disabled={isDeleting} onClick={handleDelete} variant="danger">
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
Deleting...
</>
) : (
"Delete"
)}
</Button>
</div>
</div>
</div>
)
}
export default memo(DeleteWorktreeModal)
@@ -1,641 +0,0 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { NewTaskRequest } from "@shared/proto/cline/task"
import type { MergeWorktreeResult, Worktree as WorktreeProto } from "@shared/proto/cline/worktree"
import {
CreateWorktreeIncludeRequest,
DeleteWorktreeRequest,
MergeWorktreeRequest,
SwitchWorktreeRequest,
} from "@shared/proto/cline/worktree"
import { VSCodeButton, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { AlertCircle, Check, ExternalLink, FolderOpen, GitBranch, GitMerge, Loader2, Plus, Trash2, X } from "lucide-react"
import { memo, useCallback, useEffect, useState } from "react"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { FileServiceClient, TaskServiceClient, WorktreeServiceClient } from "@/services/grpc-client"
import { getEnvironmentColor } from "@/utils/environmentColors"
import CreateWorktreeModal from "./CreateWorktreeModal"
import DeleteWorktreeModal from "./DeleteWorktreeModal"
type WorktreesViewProps = {
onDone: () => void
}
const WorktreesView = ({ onDone }: WorktreesViewProps) => {
const { environment } = useExtensionState()
const [worktrees, setWorktrees] = useState<WorktreeProto[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isGitRepo, setIsGitRepo] = useState(true)
const [isMultiRoot, setIsMultiRoot] = useState(false)
const [isSubfolder, setIsSubfolder] = useState(false)
const [gitRootPath, setGitRootPath] = useState("")
const [showCreateForm, setShowCreateForm] = useState(false)
const [deleteWorktree, setDeleteWorktree] = useState<WorktreeProto | null>(null)
// Merge worktree state
const [mergeWorktree, setMergeWorktree] = useState<WorktreeProto | null>(null)
const [isMerging, setIsMerging] = useState(false)
const [mergeError, setMergeError] = useState<string | null>(null)
const [mergeResult, setMergeResult] = useState<MergeWorktreeResult | null>(null)
const [deleteAfterMerge, setDeleteAfterMerge] = useState(true)
// .worktreeinclude status
const [hasWorktreeInclude, setHasWorktreeInclude] = useState(false)
const [hasGitignore, setHasGitignore] = useState(false)
const [gitignoreContent, setGitignoreContent] = useState("")
const [isCreatingWorktreeInclude, setIsCreatingWorktreeInclude] = useState(false)
// Check if a worktree is the main/primary worktree (first one, typically the original clone)
const isMainWorktree = useCallback(
(worktree: WorktreeProto) => {
// The main worktree is typically the first one listed and is where .git directory lives
// It's also usually the one that's marked as "bare" or is the original clone location
if (worktrees.length === 0) return false
return worktree.path === worktrees[0]?.path || worktree.isBare
},
[worktrees],
)
// Load worktrees - only updates state if data changed to prevent flickering
const loadWorktrees = useCallback(async () => {
try {
const response = await WorktreeServiceClient.listWorktrees(EmptyRequest.create({}))
// Only update state if data actually changed (prevents flickering)
setWorktrees((prev) => {
const newData = JSON.stringify(response.worktrees)
const oldData = JSON.stringify(prev)
return newData === oldData ? prev : response.worktrees
})
setIsGitRepo((prev) => (prev === response.isGitRepo ? prev : response.isGitRepo))
setIsMultiRoot((prev) => (prev === response.isMultiRoot ? prev : response.isMultiRoot))
setIsSubfolder((prev) => (prev === response.isSubfolder ? prev : response.isSubfolder))
setGitRootPath((prev) => (prev === response.gitRootPath ? prev : response.gitRootPath))
setError((prev) => (response.error ? response.error : prev === null ? null : prev))
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load worktrees")
} finally {
setIsLoading(false)
}
}, [])
// Load .worktreeinclude status
const loadWorktreeIncludeStatus = useCallback(async () => {
try {
const status = await WorktreeServiceClient.getWorktreeIncludeStatus(EmptyRequest.create({}))
setHasWorktreeInclude(status.exists)
setHasGitignore(status.hasGitignore)
setGitignoreContent(status.gitignoreContent)
} catch (err) {
console.error("Failed to load worktree include status:", err)
}
}, [])
// Create .worktreeinclude file and open it in editor
const handleCreateWorktreeInclude = useCallback(async () => {
setIsCreatingWorktreeInclude(true)
try {
const result = await WorktreeServiceClient.createWorktreeInclude(
CreateWorktreeIncludeRequest.create({
content: gitignoreContent,
}),
)
if (result.success) {
setHasWorktreeInclude(true)
// Open the file in the editor
await FileServiceClient.openFileRelativePath({ value: ".worktreeinclude" })
} else {
setError(result.message)
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create .worktreeinclude")
} finally {
setIsCreatingWorktreeInclude(false)
}
}, [gitignoreContent])
// Initial load
useEffect(() => {
loadWorktrees()
loadWorktreeIncludeStatus()
}, [loadWorktrees, loadWorktreeIncludeStatus])
// Poll for updates every 3 seconds while the view is open
useEffect(() => {
const interval = setInterval(loadWorktrees, 3000)
return () => clearInterval(interval)
}, [loadWorktrees])
const handleDeleteWorktree = useCallback(
async (path: string, deleteBranch: boolean, branchName: string) => {
try {
const result = await WorktreeServiceClient.deleteWorktree(
DeleteWorktreeRequest.create({
path,
force: false,
deleteBranch,
branchName,
}),
)
if (!result.success) {
setError(result.message)
} else {
await loadWorktrees()
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete worktree")
}
},
[loadWorktrees],
)
const handleSwitchWorktree = useCallback(async (path: string, newWindow: boolean) => {
try {
await WorktreeServiceClient.switchWorktree(
SwitchWorktreeRequest.create({
path,
newWindow,
}),
)
} catch (err) {
console.error("Failed to switch worktree:", err)
}
}, [])
// Get the main branch name (first worktree's branch, usually main/master)
const getMainBranch = useCallback(() => {
if (worktrees.length === 0) return "main"
return worktrees[0]?.branch || "main"
}, [worktrees])
// Open merge modal for a worktree
const openMergeModal = useCallback((worktree: WorktreeProto) => {
setMergeWorktree(worktree)
setMergeError(null)
setMergeResult(null)
setDeleteAfterMerge(true)
}, [])
// Close merge modal
const closeMergeModal = useCallback(() => {
setMergeWorktree(null)
setMergeError(null)
setMergeResult(null)
}, [])
// Handle merge
const handleMergeWorktree = useCallback(async () => {
if (!mergeWorktree) return
setIsMerging(true)
setMergeError(null)
setMergeResult(null)
try {
const result = await WorktreeServiceClient.mergeWorktree(
MergeWorktreeRequest.create({
worktreePath: mergeWorktree.path,
targetBranch: getMainBranch(),
deleteAfterMerge,
}),
)
setMergeResult(result)
if (result.success) {
// Reload worktrees to reflect changes
await loadWorktrees()
} else if (!result.hasConflicts) {
setMergeError(result.message)
}
} catch (err) {
setMergeError(err instanceof Error ? err.message : "Failed to merge worktree")
} finally {
setIsMerging(false)
}
}, [mergeWorktree, getMainBranch, deleteAfterMerge, loadWorktrees])
// Ask Cline to resolve conflicts
const handleAskClineToResolve = useCallback(async () => {
if (!mergeResult || !mergeResult.hasConflicts) return
const conflictList = mergeResult.conflictingFiles.join(", ")
const prompt = `I tried to merge branch '${mergeResult.sourceBranch}' into '${mergeResult.targetBranch}' but there are merge conflicts in the following files: ${conflictList}
Please help me resolve these merge conflicts, then complete the merge, and delete the worktree at: ${mergeWorktree?.path}`
try {
// Create a new task with this prompt
await TaskServiceClient.newTask(NewTaskRequest.create({ text: prompt }))
closeMergeModal()
// Close worktrees view to show the chat with the new task
onDone()
} catch (err) {
setMergeError(err instanceof Error ? err.message : "Failed to create task for Cline")
}
}, [mergeResult, mergeWorktree, closeMergeModal, onDone])
return (
<div className="fixed inset-0 flex flex-col overflow-hidden">
{/* Sticky Header with title and Done button */}
<div className="flex-none flex justify-between items-center px-5 py-3 border-b border-[var(--vscode-panel-border)]">
<h3 className="m-0" style={{ color: getEnvironmentColor(environment) }}>
Worktrees
</h3>
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto p-5">
{/* Description */}
<p className="text-sm text-[var(--vscode-descriptionForeground)] m-0 mb-4">
Git worktrees let you work on multiple branches at the same time, each in its own folder. Open worktrees in
their own windows so Cline can work on multiple tasks in parallel.{" "}
<a
className="text-[var(--vscode-textLink-foreground)] hover:text-[var(--vscode-textLink-activeForeground)]"
href="https://docs.cline.bot/features/worktrees"
rel="noopener noreferrer"
style={{ fontSize: "inherit" }}
target="_blank">
Learn more
</a>
</p>
{/* .worktreeinclude status */}
{isGitRepo && !isMultiRoot && !isSubfolder && (
<div
className="p-3 rounded-md"
style={{
border: "1px solid var(--vscode-widget-border)",
backgroundColor: "var(--vscode-list-hoverBackground)",
}}>
{hasWorktreeInclude ? (
<p className="text-sm text-[var(--vscode-testing-iconPassed)] m-0">
<Check className="w-4 h-4 inline-block align-text-bottom mr-1" />
.worktreeinclude detected.{" "}
<a
className="text-[var(--vscode-textLink-foreground)] hover:text-[var(--vscode-textLink-activeForeground)]"
href="https://docs.cline.bot/features/worktrees#worktreeinclude"
rel="noopener noreferrer"
style={{ fontSize: "inherit" }}
target="_blank">
Learn more
</a>
</p>
) : (
<div className="flex flex-col gap-2">
<p className="text-sm text-[var(--vscode-descriptionForeground)] m-0">
<strong>Tip:</strong> Create a{" "}
<code className="bg-[var(--vscode-textCodeBlock-background)] px-1 rounded">
.worktreeinclude
</code>{" "}
file to automatically copy files like{" "}
<code className="bg-[var(--vscode-textCodeBlock-background)] px-1 rounded">
node_modules/
</code>{" "}
to new worktrees, so you don't have to reinstall dependencies.{" "}
<a
className="text-[var(--vscode-textLink-foreground)] hover:text-[var(--vscode-textLink-activeForeground)]"
href="https://docs.cline.bot/features/worktrees#worktreeinclude"
rel="noopener noreferrer"
style={{ fontSize: "inherit" }}
target="_blank">
Learn more
</a>
</p>
{hasGitignore && (
<VSCodeButton
appearance="secondary"
disabled={isCreatingWorktreeInclude}
onClick={handleCreateWorktreeInclude}>
{isCreatingWorktreeInclude ? (
<>
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
Creating...
</>
) : (
"Create from .gitignore"
)}
</VSCodeButton>
)}
</div>
)}
</div>
)}
{/* Loading/Error States */}
{isLoading ? (
<div className="flex items-center justify-center min-h-32 py-8">
<Loader2 className="w-6 h-6 animate-spin text-[var(--vscode-descriptionForeground)]" />
<span className="ml-2 text-[var(--vscode-descriptionForeground)]">Loading...</span>
</div>
) : isMultiRoot ? (
<div className="flex flex-col items-center justify-center min-h-32 py-8 text-center">
<AlertCircle className="w-8 h-8 text-[var(--vscode-inputValidation-warningForeground)] mb-2 shrink-0" />
<p className="text-[var(--vscode-foreground)] font-medium mb-1">Multi-folder workspace detected</p>
<p className="text-[var(--vscode-descriptionForeground)] text-sm">
Worktrees are not supported when multiple folders are open in the same workspace. Please open a single
repository folder to use this feature.
</p>
</div>
) : isSubfolder ? (
<div className="flex flex-col items-center justify-center min-h-32 py-8 text-center">
<AlertCircle className="w-8 h-8 text-[var(--vscode-inputValidation-warningForeground)] mb-2 shrink-0" />
<p className="text-[var(--vscode-foreground)] font-medium mb-1">Subfolder of a git repository</p>
<p className="text-[var(--vscode-descriptionForeground)] text-sm">
You have a subfolder open instead of the repository root. Please open the root folder to use
worktrees:
</p>
<code className="mt-2 px-2 py-1 bg-[var(--vscode-textCodeBlock-background)] rounded text-sm break-all">
{gitRootPath}
</code>
</div>
) : !isGitRepo ? (
<div className="flex flex-col items-center justify-center min-h-32 py-8 text-center">
<AlertCircle className="w-8 h-8 text-[var(--vscode-descriptionForeground)] mb-2 shrink-0" />
<p className="text-[var(--vscode-descriptionForeground)]">
Worktrees require a git repository. Please initialize git to use worktrees.
</p>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center min-h-32 py-8 text-center">
<AlertCircle className="w-8 h-8 text-[var(--vscode-errorForeground)] mb-2 shrink-0" />
<p className="text-[var(--vscode-errorForeground)]">{error}</p>
<VSCodeButton appearance="secondary" className="mt-3" onClick={loadWorktrees}>
Retry
</VSCodeButton>
</div>
) : worktrees.length === 0 ? (
<div className="flex flex-col items-center justify-center min-h-32 py-8 text-center">
<GitBranch className="w-8 h-8 text-[var(--vscode-descriptionForeground)] mb-2 shrink-0" />
<p className="text-[var(--vscode-descriptionForeground)]">No worktrees found.</p>
</div>
) : (
<>
{/* Worktrees List - current worktree first, then others */}
<div className="mt-4 flex flex-col gap-2">
{worktrees.map((worktree) => (
<div
className={`p-4 rounded border ${
worktree.isCurrent
? "border-[var(--vscode-focusBorder)] bg-[var(--vscode-list-activeSelectionBackground)]"
: "border-[var(--vscode-panel-border)]"
}`}
key={worktree.path}>
{/* Branch name, badges, and action buttons - wraps on small screens */}
<div className="flex flex-wrap items-center justify-between gap-2 mb-1">
{/* Left side: branch name and badges */}
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
<GitBranch className="w-4 h-4 flex-shrink-0 text-[var(--vscode-button-background)]" />
<span className="font-medium break-all">
{worktree.branch || (worktree.isDetached ? "HEAD (detached)" : "unknown")}
</span>
</div>
{isMainWorktree(worktree) && (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--vscode-badge-background)] text-[var(--vscode-badge-foreground)] cursor-help">
Primary
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
The original worktree where your .git directory lives.
</TooltipContent>
</Tooltip>
)}
{worktree.isCurrent && (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--vscode-button-background)] text-[var(--vscode-button-foreground)] cursor-help">
Current
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
This is the worktree currently open in this window.
</TooltipContent>
</Tooltip>
)}
{worktree.isLocked && (
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--vscode-inputValidation-warningBackground)] text-[var(--vscode-inputValidation-warningForeground)]">
Locked
</span>
)}
</div>
{/* Right side: action buttons */}
<div className="flex items-center gap-1">
{!worktree.isCurrent && (
<>
<Tooltip>
<TooltipTrigger asChild>
<VSCodeButton
appearance="icon"
onClick={() => handleSwitchWorktree(worktree.path, false)}>
<FolderOpen className="w-4 h-4" />
</VSCodeButton>
</TooltipTrigger>
<TooltipContent side="bottom">Open in current window</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<VSCodeButton
appearance="icon"
onClick={() => handleSwitchWorktree(worktree.path, true)}>
<ExternalLink className="w-4 h-4" />
</VSCodeButton>
</TooltipTrigger>
<TooltipContent side="bottom">Open in new window</TooltipContent>
</Tooltip>
</>
)}
{!worktree.isCurrent && !isMainWorktree(worktree) && (
<>
<Tooltip>
<TooltipTrigger asChild>
<VSCodeButton
appearance="icon"
onClick={() => openMergeModal(worktree)}>
<GitMerge className="w-4 h-4 text-[var(--vscode-testing-iconPassed)]" />
</VSCodeButton>
</TooltipTrigger>
<TooltipContent side="bottom">
Merge into {getMainBranch()}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<VSCodeButton
appearance="icon"
onClick={() => setDeleteWorktree(worktree)}>
<Trash2 className="w-4 h-4 text-[var(--vscode-errorForeground)]" />
</VSCodeButton>
</TooltipTrigger>
<TooltipContent side="bottom">Delete this worktree</TooltipContent>
</Tooltip>
</>
)}
</div>
</div>
{/* Path */}
<p className="text-sm text-[var(--vscode-descriptionForeground)] m-0 break-all">
{worktree.path}
</p>
</div>
))}
</div>
</>
)}
</div>
{/* Fixed Bottom - New Worktree Button */}
{isGitRepo && !isMultiRoot && !isSubfolder && (
<div
className="flex-none px-5 py-3"
style={{
borderTop: "1px solid var(--vscode-panel-border)",
}}>
<VSCodeButton disabled={isLoading} onClick={() => setShowCreateForm(true)} style={{ width: "100%" }}>
<Plus className="w-4 h-4 mr-1" />
New Worktree
</VSCodeButton>
</div>
)}
{/* Create Worktree Modal */}
<CreateWorktreeModal onClose={() => setShowCreateForm(false)} onSuccess={loadWorktrees} open={showCreateForm} />
{/* Delete Worktree Modal */}
<DeleteWorktreeModal
branchName={deleteWorktree?.branch || ""}
onClose={() => setDeleteWorktree(null)}
onConfirm={(deleteBranch) => handleDeleteWorktree(deleteWorktree!.path, deleteBranch, deleteWorktree!.branch)}
open={!!deleteWorktree}
worktreePath={deleteWorktree?.path || ""}
/>
{/* Merge Worktree Modal */}
{mergeWorktree && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={(e) => {
if (e.target === e.currentTarget && !isMerging) {
closeMergeModal()
}
}}>
<div className="bg-[var(--vscode-editor-background)] border border-[var(--vscode-panel-border)] rounded-lg p-5 w-[450px] max-w-[90vw] relative">
{/* Close button */}
<button
className="absolute top-3 right-3 p-1 rounded hover:bg-[var(--vscode-toolbar-hoverBackground)] text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)] cursor-pointer"
disabled={isMerging}
onClick={closeMergeModal}
type="button">
<X className="w-4 h-4" />
</button>
<div className="flex items-center gap-2 mb-2">
<GitMerge className="w-5 h-5 text-[var(--vscode-testing-iconPassed)]" />
<h4 className="m-0 pr-6">Merge Worktree</h4>
</div>
{/* Success state */}
{mergeResult?.success ? (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 p-3 rounded bg-[var(--vscode-testing-iconPassed)]/10 border border-[var(--vscode-testing-iconPassed)]">
<Check className="w-5 h-5 text-[var(--vscode-testing-iconPassed)]" />
<p className="text-sm m-0">{mergeResult.message}</p>
</div>
<div className="flex justify-end">
<VSCodeButton onClick={closeMergeModal}>Done</VSCodeButton>
</div>
</div>
) : mergeResult?.hasConflicts ? (
/* Conflict state */
<div className="flex flex-col gap-4">
<div className="flex items-start gap-2 p-3 rounded bg-[var(--vscode-inputValidation-warningBackground)] border border-[var(--vscode-inputValidation-warningBorder)]">
<AlertCircle className="w-5 h-5 flex-shrink-0 text-[var(--vscode-inputValidation-warningForeground)] mt-0.5" />
<div>
<p className="text-sm font-medium m-0 mb-1">Merge conflicts detected</p>
<p className="text-sm text-[var(--vscode-descriptionForeground)] m-0 mb-2">
The following files have conflicts:
</p>
<ul className="m-0 pl-4 text-sm font-mono text-[var(--vscode-descriptionForeground)]">
{mergeResult.conflictingFiles.slice(0, 3).map((file) => (
<li key={file}>{file}</li>
))}
{mergeResult.conflictingFiles.length > 3 && (
<li className="text-[var(--vscode-descriptionForeground)]">
...and {mergeResult.conflictingFiles.length - 3} more
</li>
)}
</ul>
</div>
</div>
<div className="flex flex-col gap-2">
<VSCodeButton onClick={handleAskClineToResolve} style={{ width: "100%" }}>
Ask Cline to Resolve
</VSCodeButton>
<VSCodeButton appearance="secondary" onClick={closeMergeModal} style={{ width: "100%" }}>
I'll Resolve Manually
</VSCodeButton>
</div>
</div>
) : (
/* Default state - confirm merge */
<div className="flex flex-col gap-4">
<p className="text-sm text-[var(--vscode-descriptionForeground)] m-0">
This will merge branch{" "}
<code className="bg-[var(--vscode-textCodeBlock-background)] px-1 rounded">
{mergeWorktree.branch}
</code>{" "}
into{" "}
<code className="bg-[var(--vscode-textCodeBlock-background)] px-1 rounded">
{getMainBranch()}
</code>
.
</p>
<label className="flex items-center gap-2 cursor-pointer">
<VSCodeCheckbox
checked={deleteAfterMerge}
onChange={(e) => setDeleteAfterMerge((e.target as HTMLInputElement).checked)}
/>
<span className="text-sm">Delete worktree after successful merge</span>
</label>
{mergeError && (
<div className="flex items-start gap-2 p-3 rounded bg-[var(--vscode-inputValidation-errorBackground)] border border-[var(--vscode-inputValidation-errorBorder)]">
<AlertCircle className="w-4 h-4 flex-shrink-0 text-[var(--vscode-errorForeground)] mt-0.5" />
<p className="text-sm text-[var(--vscode-errorForeground)] m-0">{mergeError}</p>
</div>
)}
<div className="flex justify-end gap-2">
<VSCodeButton appearance="secondary" disabled={isMerging} onClick={closeMergeModal}>
Cancel
</VSCodeButton>
<VSCodeButton disabled={isMerging} onClick={handleMergeWorktree}>
{isMerging ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
Merging...
</>
) : (
<>
<GitMerge className="w-4 h-4 mr-1" />
Merge
</>
)}
</VSCodeButton>
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}
export default memo(WorktreesView)
@@ -56,7 +56,6 @@ export interface ExtensionStateContextType extends ExtensionState {
settingsTargetSection?: string
showHistory: boolean
showAccount: boolean
showWorktrees: boolean
showAnnouncement: boolean
showChatModelSelector: boolean
expandTaskHeader: boolean
@@ -104,14 +103,12 @@ export interface ExtensionStateContextType extends ExtensionState {
navigateToSettings: (targetSection?: string) => void
navigateToHistory: () => void
navigateToAccount: () => void
navigateToWorktrees: () => void
navigateToChat: () => void
// Hide functions
hideSettings: () => void
hideHistory: () => void
hideAccount: () => void
hideWorktrees: () => void
hideAnnouncement: () => void
hideChatModelSelector: () => void
closeMcpView: () => void
@@ -132,7 +129,6 @@ export const ExtensionStateContextProvider: React.FC<{
const [settingsTargetSection, setSettingsTargetSection] = useState<string | undefined>(undefined)
const [showHistory, setShowHistory] = useState(false)
const [showAccount, setShowAccount] = useState(false)
const [showWorktrees, setShowWorktrees] = useState(false)
const [showAnnouncement, setShowAnnouncement] = useState(false)
const [showChatModelSelector, setShowChatModelSelector] = useState(false)
@@ -149,7 +145,6 @@ export const ExtensionStateContextProvider: React.FC<{
}, [])
const hideHistory = useCallback(() => setShowHistory(false), [setShowHistory])
const hideAccount = useCallback(() => setShowAccount(false), [setShowAccount])
const hideWorktrees = useCallback(() => setShowWorktrees(false), [setShowWorktrees])
const hideAnnouncement = useCallback(() => setShowAnnouncement(false), [setShowAnnouncement])
const hideChatModelSelector = useCallback(() => setShowChatModelSelector(false), [setShowChatModelSelector])
@@ -159,13 +154,12 @@ export const ExtensionStateContextProvider: React.FC<{
setShowSettings(false)
setShowHistory(false)
setShowAccount(false)
setShowWorktrees(false)
if (tab) {
setMcpTab(tab)
}
setShowMcp(true)
},
[setShowMcp, setMcpTab, setShowSettings, setShowHistory, setShowAccount, setShowWorktrees],
[setShowMcp, setMcpTab, setShowSettings, setShowHistory, setShowAccount],
)
const navigateToSettings = useCallback(
@@ -173,7 +167,6 @@ export const ExtensionStateContextProvider: React.FC<{
setShowHistory(false)
closeMcpView()
setShowAccount(false)
setShowWorktrees(false)
setSettingsTargetSection(targetSection)
setShowSettings(true)
},
@@ -184,33 +177,22 @@ export const ExtensionStateContextProvider: React.FC<{
setShowSettings(false)
closeMcpView()
setShowAccount(false)
setShowWorktrees(false)
setShowHistory(true)
}, [setShowSettings, closeMcpView, setShowAccount, setShowWorktrees, setShowHistory])
}, [setShowSettings, closeMcpView, setShowAccount, setShowHistory])
const navigateToAccount = useCallback(() => {
setShowSettings(false)
closeMcpView()
setShowHistory(false)
setShowWorktrees(false)
setShowAccount(true)
}, [setShowSettings, closeMcpView, setShowHistory, setShowWorktrees, setShowAccount])
const navigateToWorktrees = useCallback(() => {
setShowSettings(false)
closeMcpView()
setShowHistory(false)
setShowAccount(false)
setShowWorktrees(true)
}, [setShowSettings, closeMcpView, setShowHistory, setShowAccount, setShowWorktrees])
}, [setShowSettings, closeMcpView, setShowHistory, setShowAccount])
const navigateToChat = useCallback(() => {
setShowSettings(false)
closeMcpView()
setShowHistory(false)
setShowAccount(false)
setShowWorktrees(false)
}, [setShowSettings, closeMcpView, setShowHistory, setShowAccount, setShowWorktrees])
}, [setShowSettings, closeMcpView, setShowHistory, setShowAccount])
const [state, setState] = useState<ExtensionState>({
version: "",
@@ -254,7 +236,6 @@ export const ExtensionStateContextProvider: React.FC<{
customPrompt: undefined,
useAutoCondense: false,
clineWebToolsEnabled: { user: true, featureFlag: false },
worktreesEnabled: { user: true, featureFlag: false },
autoCondenseThreshold: undefined,
favoritedModelIds: [],
lastDismissedInfoBannerVersion: 0,
@@ -317,7 +298,6 @@ export const ExtensionStateContextProvider: React.FC<{
const chatButtonUnsubscribeRef = useRef<(() => void) | null>(null)
const accountButtonClickedSubscriptionRef = useRef<(() => void) | null>(null)
const settingsButtonClickedSubscriptionRef = useRef<(() => void) | null>(null)
const worktreesButtonClickedSubscriptionRef = useRef<(() => void) | null>(null)
const partialMessageUnsubscribeRef = useRef<(() => void) | null>(null)
const mcpMarketplaceUnsubscribeRef = useRef<(() => void) | null>(null)
const openRouterModelsUnsubscribeRef = useRef<(() => void) | null>(null)
@@ -473,23 +453,6 @@ export const ExtensionStateContextProvider: React.FC<{
},
})
// Set up worktrees button clicked subscription
worktreesButtonClickedSubscriptionRef.current = UiServiceClient.subscribeToWorktreesButtonClicked(
EmptyRequest.create({}),
{
onResponse: () => {
// When worktrees button is clicked, navigate to worktrees
navigateToWorktrees()
},
onError: (error) => {
console.error("Error in worktrees button clicked subscription:", error)
},
onComplete: () => {
console.log("Worktrees button clicked subscription completed")
},
},
)
// Subscribe to partial message events
partialMessageUnsubscribeRef.current = UiServiceClient.subscribeToPartialMessage(EmptyRequest.create({}), {
onResponse: (protoMessage) => {
@@ -641,10 +604,6 @@ export const ExtensionStateContextProvider: React.FC<{
settingsButtonClickedSubscriptionRef.current()
settingsButtonClickedSubscriptionRef.current = null
}
if (worktreesButtonClickedSubscriptionRef.current) {
worktreesButtonClickedSubscriptionRef.current()
worktreesButtonClickedSubscriptionRef.current = null
}
if (partialMessageUnsubscribeRef.current) {
partialMessageUnsubscribeRef.current()
partialMessageUnsubscribeRef.current = null
@@ -775,7 +734,6 @@ export const ExtensionStateContextProvider: React.FC<{
settingsTargetSection,
showHistory,
showAccount,
showWorktrees,
showAnnouncement,
showChatModelSelector,
globalClineRulesToggles: state.globalClineRulesToggles || {},
@@ -795,14 +753,12 @@ export const ExtensionStateContextProvider: React.FC<{
navigateToSettings,
navigateToHistory,
navigateToAccount,
navigateToWorktrees,
navigateToChat,
// Hide functions
hideSettings,
hideHistory,
hideAccount,
hideWorktrees,
hideAnnouncement,
setShowAnnouncement,
hideChatModelSelector,
-1
View File
@@ -56,7 +56,6 @@
}
a {
@apply text-link hover:text-link-hover;
font-size: inherit;
}
ol {
@apply list-decimal pl-3;