mirror of
https://github.com/cline/cline.git
synced 2026-09-09 06:45:53 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74cbf31cc3 | ||
|
|
c2354c9832 | ||
|
|
df36557c04 | ||
|
|
dc9d3ae407 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Expose --version in cline cli command
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
OpenAI GPT-5 Codex models are now using Apply Patch tool for diff edits.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix LiteLLM thinking configuration not showing for models (#8342)
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: add chat output on skill use
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Adding telemetry for background exec terminal
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Support native tool calling for LM Studio and Ollama provider
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Limite Vertex and LiteLLM options when they're remote configured
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix crash when the Context Menu has a type but no options
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Add git worktree management UI for running parallel Cline sessions
|
||||
@@ -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.
|
||||
@@ -1,8 +1,6 @@
|
||||
# Default
|
||||
.vscode/**
|
||||
.vscode-test/**
|
||||
.worktrees/**
|
||||
CLAUDE.local.md
|
||||
out/
|
||||
dist-standalone/
|
||||
node_modules/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -177,7 +177,6 @@
|
||||
"features/tasks/task-management"
|
||||
]
|
||||
},
|
||||
"features/worktrees",
|
||||
"features/yolo-mode"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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!
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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({})
|
||||
}
|
||||
+40
-5
@@ -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 change’s [context_after] lines in the second change’s [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
|
||||
}
|
||||
|
||||
@@ -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 doesn’t 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: Don’t use to “confirm” writes; rely on returned tool results.
|
||||
|
||||
**ask_followup_question** — Get missing info. Params: question, options (2–5).
|
||||
*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 doesn’t 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: Don’t use to “confirm” writes; rely on returned tool results.
|
||||
|
||||
**ask_followup_question** — Get missing info. Params: question, options (2–5).
|
||||
*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}}}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
export { ClineTempManager } from "./ClineTempManager"
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "" }
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ export function isNextGenModelProvider(providerInfo: ApiProviderInfo): boolean {
|
||||
"openai",
|
||||
"minimax",
|
||||
"openai-native",
|
||||
"openai-compatible",
|
||||
"baseten",
|
||||
"vercel-ai-gateway",
|
||||
"oca",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
}
|
||||
a {
|
||||
@apply text-link hover:text-link-hover;
|
||||
font-size: inherit;
|
||||
}
|
||||
ol {
|
||||
@apply list-decimal pl-3;
|
||||
|
||||
Reference in New Issue
Block a user