Compare commits

..

30 Commits

Author SHA1 Message Date
CandiedUniverse 0eab54ab12 fix(release-eng): Fix nightly extension publish failure caused by workspace self-link mismatch (#9420) 2026-02-19 10:40:45 -08:00
github-actions[bot] 7fa0a4924b Changeset version bump (#9413)
* changeset version bump

* Updating CHANGELOG.md format

* Adding 1m

* Adding 1m

* Adding 1m

* Adding 1m

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Arafatkatze <arafat.da.khan@gmail.com>
2026-02-19 10:36:21 -08:00
Junjie Tang 8680218e0e Update sap-ai-sdk version (#9417) 2026-02-19 06:46:09 -08:00
Dominic Cooney 8787ab35b9 Update package-lock.json. (#9414) 2026-02-18 22:56:49 -08:00
Dominic Cooney 6ed3944f04 Export global, workspace and secrets from VSCode to share with CLI (#9227)
Caveat: Task history, tasks, etc. are not written to the same place by all clients yet. This is just about globalState, workspaceState and secrets.
2026-02-19 15:46:23 +09:00
Bee 1dd8e763d1 fix(chat): make Cmd/Ctrl+A select-all deterministic (#9408)
Handle plain Cmd/Ctrl+A directly in `ChatTextArea` keydown to force
textarea-wide selection via `setSelectionRange`, while preventing default
and propagation. This avoids intermittent failures caused by global shortcut
listener races, and keeps Cmd/Ctrl+Shift+A behavior unchanged.fix(chat): make Cmd/Ctrl+A select-all deterministic

Handle plain Cmd/Ctrl+A directly in `ChatTextArea` keydown to force
textarea-wide selection via `setSelectionRange`, while preventing default
and propagation. This avoids intermittent failures caused by global shortcut
listener races, and keeps Cmd/Ctrl+Shift+A behavior unchanged.
2026-02-18 20:55:57 -08:00
Bee 28e6297769 fix: flaky Cancel behavior by preventing duplicate cancel actions (CLINE-1380) (#9409)
* fix: flaky Cancel behavior by preventing duplicate cancel actions

This PR fixes chat cancel behavior where users sometimes had to click Cancel multiple times, and repeated clicks could accidentally transition into Resume/restart behavior.

* move to finally
2026-02-18 20:55:41 -08:00
Bee 5a2a5d1c0a refactor: replace non-null assertions with checks in PatchParser (#9402)
* refactor: replace non-null assertions with safe null checks in PatchParser

Replace all forbidden non-null assertions (`!`) in PatchParser.ts with
safe alternatives using optional chaining (`?.`) and nullish coalescing
(`?? ""`/`?? 0`). Also refactor the Levenshtein distance matrix from a
2D array to a flat array to eliminate index-based non-null assertions,
improving type safety and code robustness.

No feature behavior changes.

* simplify Levenshtein matrix indexing

Initialize the distance matrix with zeroes and add `at`/`set` helpers for flat-array access in `levenshteinDistance`.

This removes repeated index math and nullish fallbacks, making the algorithm easier to read while keeping bounds-safe access and identical behavior.refactor(patch-parser): simplify Levenshtein matrix indexing

Initialize the distance matrix with zeroes and add `at`/`set` helpers for flat-array access in `levenshteinDistance`.

This removes repeated index math and nullish fallbacks, making the algorithm easier to read while keeping bounds-safe access and identical behavior.
2026-02-18 19:35:48 -08:00
Max 113039a259 CLI 2.0: allow custom inference profile arn for bedrock provider (#9271) 2026-02-18 19:16:32 -08:00
Max 4023c18257 remove default timeout (#9401) 2026-02-18 17:28:50 -08:00
cryptoque 7c95b53892 Add support for DB backed Welcome Banner (#9315)
* feat: add welcome banner support from backend

* make DB banner format conform with existing banners

* add support for welcome banner actions

* remove debugging helper that bypass dismissal, dismissal should work again

* undo changes to make welcome banner always appear during debugging

* remove console logs for debugging

* clean up bannerservice

* clean up welcomesection.tsx

* add new tests for ide type filtering

* add welcome banner own feature flag and conditionally display between hard coded welcome banner and DB backed ones

* turn on welcome banner flag locally by default

* close welcome banners when clicking on actions

* apply bot review suggestion, fix memory leak

* address feedback: use p without span

* split welcome banners into a seperate component to keep whatsnewmodal clean

* get action through api schema instead of extractin it from rules_json

* use only bannerWaitTimeoutRef, remove waitingForBannersRef

* resolve new merge conflict

* linter

* cerebra
2026-02-18 14:52:02 -08:00
Bee 9fd2b99be4 chore: remove autoCondenseThreshold setting and related code (#9396)
- Remove `auto_condense_threshold` from `Settings` and `UpdateSettingsRequest` in `state.proto`.
- Remove `autoCondenseThreshold` from `ApiProviderInfo` interface.
- Update `generate-state-proto.mjs` to remove double field handling and improve integer parsing.
- Add error handling to `ContextManager` when parsing previous request JSON to prevent crashes on malformed data.
2026-02-18 12:43:30 -08:00
github-actions[bot] 7c782abaf4 Changeset version bump (#9364)
* changeset version bump

* Updating CHANGELOG.md format

* update package versions

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-02-18 12:22:53 -08:00
Ara 3a14a88f4f fix: include root workspace for changesets release (#9393) 2026-02-18 11:40:02 -08:00
Saoud Rizwan 28d60a83a4 fix(models): keep Sonnet 4.5 as default for now (#9389)
* fix(models): keep Sonnet 4.5 as default

* chore(changeset): add release note for Sonnet 4.5 default

* fix(models): remove Sonnet 4.6 from curated model lists

* fix(models): restore Sonnet 4.6 in web recommended list
2026-02-18 10:53:07 -08:00
Saoud Rizwan ae6468b161 fix(models): reinstate minimax m2.5 free promo surfaces (#9387) 2026-02-18 10:28:54 -08:00
Saoud Rizwan af93e31862 feat(models): make sonnet 4.6 default and remove free promo positioning (#9377) 2026-02-18 10:07:20 -08:00
Tomás Barreiro ca154eb8f5 Add MiniMax M2.5 to the MiniMax provider (#9381)
* Add MiniMax M2.5 to the MiniMax provider

* Add changeset

* Update default model
2026-02-18 09:16:23 -08:00
Tomás Barreiro 1d8497c6bf Prevent error messages when displaying featured models in the CLI (#9379)
* Fix the featured models key

* Add changeset
2026-02-18 16:51:29 +01:00
Seb Duerr 5871fd02b1 feat(cerebras): remove deprecated llama-3.3-70b and qwen-3-32b models (#9345)
* feat(cerebras): remove deprecated llama-3.3-70b and qwen-3-32b models

These models have been deprecated from the Cerebras inference platform.

- Remove llama-3.3-70b and qwen-3-32b from cerebrasModels in api.ts
- Update supported models documentation in cerebras.mdx
- Add changeset for the deprecation

* fix: remove stale llama-3.3-70b and qwen-3-32b references from rate limits

Remove dead switch cases in getRateLimits() that referenced deprecated models
no longer present in cerebrasModels.
2026-02-17 23:39:38 -08:00
alex-lum 2d81c310d2 Alex/inf 413 bug no telemetry for versions greater than 20 (#9372) 2026-02-17 18:55:07 -08:00
Robin Newhouse 0c691f72d2 feat(cli): add /skills slash command (#9089)
* feat(cli): add /skills slash command for managing skills

- Add /skills to CLI_ONLY_COMMANDS in slashCommands.ts
- Create SkillsPanelContent component with:
  - Display global and workspace skills with toggle indicators
  - Enter to use skill (inserts @path into input)
  - Space to toggle skill enabled/disabled
  - Selectable marketplace link to skills.sh
  - Keyboard navigation with arrow keys and vim keys
- Wire up panel in ChatView.tsx
- Add comprehensive tests for keyboard interactions

* refactor(cli): use static skill controller imports

* fix(cli): add React import to skills panel test

* fix(cli): suppress required React import lint in skills test

* fix(cli): harden /skills panel interactions

Revert optimistic skill toggle state when persistence fails, and surface a fallback URL when opening the marketplace fails. Also tighten and extend tests to verify exact marketplace URL handling and rollback behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 17:13:51 -08:00
Robin Newhouse a8409137b3 fix: disable click-to-set auto-condense threshold and hardcode default (#9348)
* fix: disable click-to-set auto-condense threshold and hardcode default

Clicking anywhere on the context window progress bar silently set
autoCondenseThreshold to a value based on click position (e.g. 0.05),
persisting in globalState. This caused compaction to fire at ~10K tokens
instead of the intended ~150K, resulting in ~20 context resets per task.

- Comment out click and keyboard handlers on progress bar (keep components
  for future release with proper UX)
- Hardcode threshold to 0.75 default, ignoring corrupted stored values

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: add shouldCompactContextWindow unit tests

Cover threshold math including the accidental low-threshold bug case,
undefined/zero fallbacks, cache token inclusion, and maxAllowedSize cap.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: hardcode autoCondenseThreshold in all remaining callsites

Address Greptile review: SubagentRunner.ts, task/index.ts display
logic, and controller/index.ts webview state all still read the
corrupted value from globalState. Hardcode 0.75 everywhere.

Co-authored-by: Cursor <cursoragent@cursor.com>

* style: remove unnecessary union type on hardcoded threshold

Drop `number | undefined` annotation from the hardcoded 0.75 literal
in SubagentRunner.ts per Greptile review feedback.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: use SETTINGS_DEFAULTS constant, remove commented-out code, clarify test

- Replace hardcoded 0.75 with SETTINGS_DEFAULTS.autoCondenseThreshold
  across all 4 callsites for a single source of truth
- Delete commented-out click/keyboard handlers in ContextWindow.tsx,
  replace with TODO referencing PR #9348
- Make bug-case test self-documenting by deriving token values from
  the threshold calculation

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 16:32:13 -08:00
Saoud Rizwan 34a21b8e26 docs: add security policy (#9365) 2026-02-17 16:00:01 -08:00
ClineXDiego eb9f53edf4 fix: smarter retry for write_to_file missing content parameter (#9276)
* fix: smarter retry for write_to_file missing content parameter (#7998)

Replace generic 'missing parameter' error with progressive guidance when
write_to_file fails due to empty content parameter. This breaks the
infinite retry loop where the model repeatedly attempts the same
write_to_file call that exceeds output token limits.

Changes:
- Add writeToFileMissingContentError() to formatResponse with 3 tiers:
  1st failure: suggestions (use skeleton + replace_in_file)
  2nd failure: strong directive (stop retrying write_to_file)
  3rd+ failure: CRITICAL stop, forces alternative strategies
- Add context window awareness: warns model when >50% context used
- Add getContextUsagePercent() helper to WriteToFileToolHandler
- Add 22 unit tests covering progressive escalation and context awareness

Fixes #7998

* add changeset for write_to_file retry fix

* refactor: simplify write_to_file error handling per review

- Simplify writeToFileMissingContentError to single-tier error following
  existing diffError pattern (no progressive escalation)
- Use shared getLastApiReqTotalTokens() for context window awareness
- Remove private getContextUsagePercent() method from handler
- Add proactive skeleton + replace_in_file guidance to write_to_file
  tool description for all variants
- Simplify tests to match new API (11 tests)

* test: update system prompt snapshots

* chore: revert write_to_file prompt guidance

* feat: restore progressive 3-tier guidance for write_to_file missing content

Restore the progressive escalation that was removed in dd3c12d4e:
- Tier 1 (1st failure): Gentle suggestions (skeleton + replace_in_file)
- Tier 2 (2nd failure): Strong directive, 'Do NOT attempt full write again'
- Tier 3 (3rd+ failure): CRITICAL stop, forces alternative strategies
- Context window warning when >50% full
- Dynamic UI message: 'Retrying...' vs 'multiple times — different approach'
- 21 tests covering all tiers and context awareness

* nit: extract context window warning threshold to named constant

Also replace emoji with plain text in warning message for consistency.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Robin Newhouse <robin@cline.bot>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 19:08:42 -03:00
github-actions[bot] c60f18d907 Adding 1m (#9346)
Co-authored-by: Arafatkatze <arafat.da.khan@gmail.com>
2026-02-17 11:11:54 -08:00
Saoud Rizwan 36c68a6ab9 fix: remove expired MiniMax free promo surfaces (#9361)
* fix: remove expired MiniMax free promo surfaces

* fix: remove MiniMax M2.5 from recommended models

* chore: update GLM 5 whats-new promo wording
2026-02-17 10:57:19 -08:00
Saoud Rizwan 80dfce0f60 docs: remove stale Claude 5 mention from Auto Compact docs (#9360)
* docs: remove stale Claude 5 wording from auto compact docs

* Remove stale Claude 5 mention from docs
2026-02-17 10:48:23 -08:00
Saoud Rizwan 955ae2f62f feat: add Claude Sonnet 4.6 support and surface it as free (#9356)
* feat: add Sonnet 5 support and make it default across surfaces

* feat: surface Sonnet 5 as free while keeping Sonnet 4.5 defaults

* fix: rename Sonnet 5 support to Sonnet 4.6 across providers and UI

* fix: allow duplicate onboarding model ids across free and frontier

* chore: update Sonnet 4.6 banner to limited-time free messaging

* fix: align Bedrock Sonnet 4.6 model ids with AWS format

* feat: update whats new promo to Sonnet 4.6 free offer

* chore: update Sonnet 4.6 promo copy and timing
2026-02-17 10:43:24 -08:00
Dominic Cooney 8cb0c6d236 Fix e2e tests. (#9350) 2026-02-17 15:06:40 +09:00
109 changed files with 3803 additions and 1192 deletions
+64
View File
@@ -0,0 +1,64 @@
# Storage Architecture
Global settings, secrets and workspace state are stored in **file-backed JSON stores** under `~/.cline/data/`. This is the shared storage layer used by VSCode, CLI, and JetBrains.
## Key Abstractions
### `StorageContext` (src/shared/storage/storage-context.ts)
The entry point. Created via `createStorageContext()` and passed to `StateManager.initialize()`. Contains three `ClineFileStorage` instances:
- `globalState``~/.cline/data/globalState.json`
- `secrets``~/.cline/data/secrets.json` (mode 0o600)
- `workspaceState``~/.cline/data/workspaces/<hash>/workspaceState.json`
### `ClineFileStorage` (src/shared/storage/ClineFileStorage.ts)
Synchronous JSON key-value store backed by a single file. Supports `get()`, `set()`, `setBatch()`, `delete()`. Writes are atomic (write-then-rename).
### `StateManager` (src/core/storage/StateManager.ts)
In-memory cache on top of `StorageContext`. All runtime reads hit the cache; writes update cache immediately and debounce-flush to disk.
## ⚠️ Do NOT Use VSCode's ExtensionContext for Storage
**Do not** read from or write to `context.globalState`, `context.workspaceState`, or `context.secrets` for persistent data. These are VSCode-specific and not available on CLI or JetBrains.
Instead, use:
```typescript
// Reading state
StateManager.get().getGlobalStateKey("myKey")
StateManager.get().getSecretKey("mySecretKey")
StateManager.get().getWorkspaceStateKey("myWsKey")
// Writing state
StateManager.get().setGlobalState("myKey", value)
StateManager.get().setSecret("mySecretKey", value)
StateManager.get().setWorkspaceState("myWsKey", value)
```
Remember that your data may be read by a different client than the one that wrote it. For example, a value written by Cline in JetBrains may be read by Cline CLI.
## VSCode Migration (src/hosts/vscode/vscode-to-file-migration.ts)
On VSCode startup, a migration copies data from VSCode's `ExtensionContext` storage into the file-backed stores. This runs in `src/common.ts` before `StateManager.initialize()`.
- **Sentinel**: `__vscodeMigrationVersion` key in global state and workspace state — prevents re-migration.
- **Merge strategy**: File store wins. Existing values are never overwritten.
- **Safe downgrade**: VSCode storage is NOT cleared, so older extension versions still work.
## Adding New Storage Keys
1. Add to `src/shared/storage/state-keys.ts` (see existing patterns)
2. Read/write via `StateManager` (NOT via `context.globalState`)
3. If adding a secret, add to `SecretKeys` array in `state-keys.ts`
## File Layout
```
~/.cline/
data/
globalState.json # Global settings & state
secrets.json # API keys (mode 0o600)
tasks/
taskHistory.json # Task history (separate file)
workspaces/
<hash>/
workspaceState.json # Per-workspace toggles
```
@@ -39,6 +39,7 @@ jobs:
!endsWith(github.actor, '[bot]')
)
uses: ./.github/workflows/npm-main.yaml
secrets: inherit
with:
confirm_publish: ${{ github.event.inputs.confirm_publish }}
@@ -49,5 +50,6 @@ jobs:
(github.event_name == 'workflow_dispatch' && github.event.inputs.publish_target == 'nightly')
)
uses: ./.github/workflows/npm-nightly.yaml
secrets: inherit
with:
force_publish: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_nightly_publish == 'true' }}
+42 -13
View File
@@ -1,5 +1,30 @@
# Changelog
## [3.66.0]
### Added
- Gemini-3.1 Pro Preview
## [3.65.0]
### Added
- Add /skills slash command to CLI for viewing and managing installed skills
### Fixed
- Fix aggressive context compaction caused by accidental clicks on the context window progress bar silently setting a very low auto-condense threshold
- Fix infinite retry loop when write_to_file fails with missing content parameter.
- Fixed default claude model
## [3.64.0]
### Added
- Added sonnet 4.6
## [3.63.0]
### Added
@@ -13,6 +38,7 @@
## [3.62.0]
### Fixed
- Banners now display immediately when opening the extension instead of requiring user interaction first
- Resolved 17 security vulnerabilities including high-severity DoS issues in dependencies (body-parser, axios, qs, tar, and others)
@@ -27,11 +53,12 @@
## [3.59.0]
- Added Minimax 2.5 Free Promo
- Fixed Response chaining for OpenAI's Responses API
- Fixed Response chaining for OpenAI's Responses API
## [3.58.0]
### Added
- Subagent: replace legacy subagents with the native `use_subagents` tool
- Bundle `endpoints.json` support so packaged distributions can ship required endpoints out-of-the-box
- Amazon Bedrock: support parallel tool calling
@@ -42,6 +69,7 @@
- ZAI/GLM: add GLM-5
### Fixed
- CLI: handle stdin redirection correctly in CI/headless environments
- CLI: preserve OAuth callback paths during auth redirects
- VS Code Web: generate auth callback URLs via `vscode.env.asExternalUri` (OAuth callback reliability)
@@ -53,6 +81,7 @@
- CI: increase Windows E2E test timeout to reduce flakiness
### Changed
- Settings/model UX: move "reasoning effort" into model configuration and expose it in settings
- CLI provider selection: limit provider list to those remotely configured
- UI: consolidate ViewHeader component/styling across views
@@ -70,7 +99,7 @@
### Added
- Cline CLI 2.0 now available. Install with `npm install -g cline`
- Anthopic Opus 4.6
- Anthopic Opus 4.6
- Minimax-2.1 and Kimi-k2.5 now available for free for a limited time promo
- Codex-5.3 through ChatGPT subscription
@@ -90,23 +119,23 @@
### Added
- __CLI authentication:__ Added Vercel AI Gateway and Cline API key provider support for headless CI/automation workflows
- __New model:__ Added Kimi-K2.5 model to Moonshot provider (262K context, image support, prompt caching)
- __Prompt variant:__ Added Trinity Large prompt variant for improved tool-calling support
- __OpenTelemetry:__ Added support for custom headers on metrics and logs endpoints
- __Social links:__ Added community icons (X, Discord, GitHub, Reddit, LinkedIn) to the What's New modal
- **CLI authentication:** Added Vercel AI Gateway and Cline API key provider support for headless CI/automation workflows
- **New model:** Added Kimi-K2.5 model to Moonshot provider (262K context, image support, prompt caching)
- **Prompt variant:** Added Trinity Large prompt variant for improved tool-calling support
- **OpenTelemetry:** Added support for custom headers on metrics and logs endpoints
- **Social links:** Added community icons (X, Discord, GitHub, Reddit, LinkedIn) to the What's New modal
### Fixed
- __LiteLLM:__ Fixed thinking configuration not appearing for reasoning-capable models
- __OpenTelemetry:__ Fixed endpoint path handling (no longer incorrectly appends `/v1/logs` or `/v1/metrics`) and ensured logs are sent regardless of VSCode telemetry settings
- __CLI auth:__ Fixed `cline auth` displaying incorrect provider information after configuration
- **LiteLLM:** Fixed thinking configuration not appearing for reasoning-capable models
- **OpenTelemetry:** Fixed endpoint path handling (no longer incorrectly appends `/v1/logs` or `/v1/metrics`) and ensured logs are sent regardless of VSCode telemetry settings
- **CLI auth:** Fixed `cline auth` displaying incorrect provider information after configuration
### Changed
- __Hooks:__ Hook scripts now run from the workspace repository root instead of filesystem root
- __Default settings:__ Enabled multi-root workspaces, parallel tool calling, and skills by default; disabled strict plan mode by default
- __Settings UI:__ Refreshed feature settings section with collapsible design
- **Hooks:** Hook scripts now run from the workspace repository root instead of filesystem root
- **Default settings:** Enabled multi-root workspaces, parallel tool calling, and skills by default; disabled strict plan mode by default
- **Settings UI:** Refreshed feature settings section with collapsible design
## [3.55.0]
+27
View File
@@ -0,0 +1,27 @@
# Security Policy
## Supported Versions
We actively patch only the most recent minor release of Cline. Older versions receive fixes at our discretion.
## Reporting a Vulnerability
We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your contributions.
To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/cline/cline/security/advisories/new) tab.
The team will send a response indicating the next steps in handling your report. After the initial reply, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
When reporting, please include:
- A short summary of the issue
- Steps to reproduce or a proof of concept
- Any logs, stack traces, or screenshots that might help us understand the problem
We acknowledge reports within 48 hours and aim to release a fix or mitigation within 30 days. While we work on a resolution, please keep the details private.
## Escalation
If you do not receive an acknowledgement of your report within 5 business days, you may send an email to security@cline.bot.
Thank you for helping us keep Cline users safe.
+22 -2
View File
@@ -1,13 +1,33 @@
# cline
## 2.2.3
## 2.4.2
### Added
- Gemini-3.1 Pro Preview
### Patch Changes
- c0020e1: Allows users to enter custom aws region when selecting bedrock as a provider in CLI
- VSCode uses shared files for global, workspace and secret state.
## [2.4.1]
### Fixed
- Fix infinite retry loop when write_to_file fails with missing content parameter. Provides progressive guidance to the model, escalating from suggestions to hard stops, with context window awareness to break the loop.
## [2.4.0]
### Added
- Adding Anthropic Sonnet 4.6
- Allows users to enter custom aws region when selecting bedrock as a provider in CLI
- Keep reasoning rows visible when low-stakes tool groups start immediately after reasoning.
- Restore reasoning trace visibility in chat and improve the thinking row UX so streamed reasoning is visible, then collapsible after completion.
### Fixed
- Banners now display immediately when opening the extension instead of requiring user interaction first
- Resolved 17 security vulnerabilities including high-severity DoS issues in dependencies (body-parser, axios, qs, tar, and others)
## [2.2.2]
+4
View File
@@ -56,6 +56,8 @@ Run a new task with a prompt.
**-y**, **\--yolo** : Enable yolo/yes mode (auto-approve all actions, output in plain mode, exit process automatically when task complete)
**-t**, **\--timeout** *seconds* : Optional timeout in seconds. Only applied when explicitly provided.
**-m**, **\--model** *model* : Model to use for the task
**-i**, **\--images** *paths...* : Image file paths to include with the task
@@ -144,6 +146,8 @@ When running **cline** with just a prompt (no subcommand), these options are ava
**-y**, **\--yolo** : Enable yolo mode (auto-approve all actions). Also forces plain text output mode.
**-t**, **\--timeout** *seconds* : Optional timeout in seconds. Only applied when explicitly provided.
**-m**, **\--model** *model* : Model to use for the task
**-v**, **\--verbose** : Show verbose output
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cline",
"version": "2.2.3",
"version": "2.4.2",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"main": "dist/cli.mjs",
"bin": {
+6 -18
View File
@@ -53,7 +53,6 @@ import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { StandaloneTerminalManager } from "@/integrations/terminal/index.js"
import { AuthService } from "@/services/auth/AuthService.js"
import { Logger } from "@/shared/services/Logger.js"
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
import type { Mode } from "@/shared/storage/types"
import { openExternal } from "@/utils/env"
import { ACPDiffViewProvider } from "../acp/ACPDiffViewProvider.js"
@@ -177,7 +176,7 @@ export class ClineAgent implements acp.Agent {
this.clientCapabilities = params.clientCapabilities
this.initializeHostProvider(this.clientCapabilities, connection)
await ClineEndpoint.initialize(this.ctx.EXTENSION_DIR)
await StateManager.initialize(this.ctx.extensionContext)
await StateManager.initialize(this.ctx.storageContext)
return {
protocolVersion: PROTOCOL_VERSION,
@@ -1007,13 +1006,14 @@ export class ClineAgent implements acp.Agent {
const startTime = Date.now()
while (Date.now() - startTime < AUTH_TIMEOUT_MS) {
const stateManager = StateManager.get()
// Check if auth data has been stored
const authData = await secretStorage.get("cline:clineAccountId")
const authData = stateManager.getSecretKey("cline:clineAccountId")
if (authData) {
Logger.debug("[ClineAgent] Authentication successful")
// Set up the provider configuration for cline
const stateManager = StateManager.get()
stateManager.setGlobalState("actModeApiProvider", "cline")
stateManager.setGlobalState("planModeApiProvider", "cline")
await stateManager.flushPendingState()
@@ -1161,13 +1161,11 @@ export class ClineAgent implements acp.Agent {
if (currentProvider === "cline") {
// For Cline provider, check if we have stored auth data
const values = await Promise.all(["clineApiKey", "clineAccountId"].map((key) => secretStorage.get(key)))
return values.some(Boolean)
return Boolean(stateManager.getSecretKey("clineApiKey") || stateManager.getSecretKey("clineAccountId"))
}
// For OpenAI Codex provider, check OAuth credentials
if (currentProvider === "openai-codex") {
openAiCodexOAuthManager.initialize(this.ctx.extensionContext)
return await openAiCodexOAuthManager.isAuthenticated()
}
@@ -1178,14 +1176,7 @@ export class ClineAgent implements acp.Agent {
}
const fields = Array.isArray(keyField) ? keyField : [keyField]
for (const field of fields) {
const value = await secretStorage.get(field)
if (value) {
return true
}
}
return false
return fields.some((key) => stateManager.getSecretKey(key))
}
/**
@@ -1201,9 +1192,6 @@ export class ClineAgent implements acp.Agent {
Logger.debug("[ClineAgent] Starting OpenAI Codex OAuth flow...")
try {
// Initialize the OAuth manager with extension context
openAiCodexOAuthManager.initialize(this.ctx.extensionContext)
// Get the authorization URL and start the callback server
const authUrl = openAiCodexOAuthManager.startAuthorizationFlow()
+60 -5
View File
@@ -23,6 +23,7 @@ import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-confi
import { useValidProviders } from "../utils/providers"
import { ApiKeyInput } from "./ApiKeyInput"
import { StaticRobotFrame } from "./AsciiMotionCli"
import { BedrockCustomModelFlow } from "./BedrockCustomModelFlow"
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
import {
FeaturedModelPicker,
@@ -31,7 +32,7 @@ import {
isBrowseAllSelected,
} from "./FeaturedModelPicker"
import { ImportView } from "./ImportView"
import { getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
import { CUSTOM_MODEL_ID, getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
import { OcaEmployeeCheck } from "./OcaEmployeeCheck"
import { getProviderLabel } from "./ProviderPicker"
@@ -51,6 +52,7 @@ type AuthStep =
| "openai_codex_auth"
| "bedrock"
| "import"
| "bedrock_custom"
interface AuthViewProps {
controller: any
@@ -76,7 +78,7 @@ const Select: React.FC<{
const [selectedIndex, setSelectedIndex] = useState(0)
useInput(
(input, key) => {
(_, key) => {
if (key.upArrow) {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
} else if (key.downArrow) {
@@ -142,7 +144,11 @@ const TextInput: React.FC<{
return (
<Box>
<Text color="white">{displayValue || placeholder || ""}</Text>
{!displayValue && placeholder ? (
<Text color="gray">e.g. {placeholder}</Text>
) : (
<Text color="white">{displayValue || ""}</Text>
)}
<Text inverse> </Text>
</Box>
)
@@ -248,6 +254,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
}, [])
// Reset provider index when search changes
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to reset here
useEffect(() => {
setProviderIndex(0)
}, [providerSearch])
@@ -273,7 +280,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
return
}
if (authState.user && authState.user.email) {
if (authState.user?.email) {
// Auth succeeded - save configuration and transition to model selection
await applyProviderConfig({ providerId: "cline", controller })
setSelectedProvider("cline")
@@ -389,6 +396,33 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
[selectedProvider],
)
// Save custom Bedrock ARN configuration with base model for capability detection
const saveCustomBedrockConfiguration = useCallback(
async (arn: string, baseModelId: string) => {
try {
if (!bedrockConfig) {
throw new Error("Bedrock configuration is missing")
}
await applyBedrockConfig({
bedrockConfig,
modelId: arn,
customModelBaseId: baseModelId,
controller,
})
const stateManager = StateManager.get()
stateManager.setGlobalState("welcomeViewCompleted", true)
await stateManager.flushPendingState()
setStep("success")
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : String(error))
setStep("error")
}
},
[bedrockConfig, controller],
)
const saveConfiguration = useCallback(
async (model: string, base: string) => {
try {
@@ -423,6 +457,12 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
const handleModelIdSubmit = useCallback(
(value: string) => {
// Intercept "Custom" selection for Bedrock — redirect to custom ARN input flow
if (value === CUSTOM_MODEL_ID && selectedProvider === "bedrock") {
setStep("bedrock_custom")
return
}
if (value.trim()) {
setModelId(value)
}
@@ -530,6 +570,9 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
// Go back to cline_model if we came from there (Cline provider)
if (selectedProvider === "cline") {
setStep("cline_model")
} else if (selectedProvider === "bedrock") {
// Bedrock skips the API key step — go back to Bedrock setup
setStep("bedrock")
} else {
setStep("apikey")
}
@@ -646,7 +689,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
<Box flexDirection="column">
<Text color="white">Model ID</Text>
<Text> </Text>
<Text color="gray">e.g., claude-sonnet-4-20250514, gpt-4o</Text>
<Text color="gray">e.g., claude-sonnet-4-6, gpt-4o</Text>
<Text> </Text>
<TextInput onChange={setModelId} onSubmit={handleModelIdSubmit} placeholder="model-id" value={modelId} />
<Text> </Text>
@@ -741,6 +784,18 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
/>
)
case "bedrock_custom":
return (
<BedrockCustomModelFlow
isActive={step === "bedrock_custom"}
onCancel={() => setStep("modelid")}
onComplete={(arn, baseModelId) => {
setStep("saving")
saveCustomBedrockConfiguration(arn, baseModelId)
}}
/>
)
case "import":
if (!importSource) {
return null
@@ -0,0 +1,111 @@
/**
* Bedrock Custom Model Flow component
* Two-step flow: ARN/custom model ID input → base model selection for capability detection.
* Used by both AuthView (onboarding) and SettingsPanelContent (/settings).
*/
import { Box, Text, useInput } from "ink"
// biome-ignore lint/correctness/noUnusedImports: React is needed for JSX at runtime
import React, { useCallback, useState } from "react"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
import { getModelList } from "./ModelPicker"
import { SearchableList } from "./SearchableList"
type FlowStep = "arn_input" | "base_model"
interface BedrockCustomModelFlowProps {
/** Whether this component should capture keyboard input */
isActive: boolean
/** Called when the user completes both steps (ARN + base model selection) */
onComplete: (arn: string, baseModelId: string) => void
/** Called when the user presses Escape on the first step (ARN input) */
onCancel: () => void
}
export const BedrockCustomModelFlow: React.FC<BedrockCustomModelFlowProps> = ({ isActive, onComplete, onCancel }) => {
const { isRawModeSupported } = useStdinContext()
const [step, setStep] = useState<FlowStep>("arn_input")
const [customArn, setCustomArn] = useState("")
const handleArnSubmit = useCallback(() => {
if (customArn.trim()) {
setStep("base_model")
}
}, [customArn])
const handleBaseModelCancel = useCallback(() => {
setStep("arn_input")
}, [])
useInput(
(input, key) => {
if (step === "arn_input") {
if (key.escape) {
onCancel()
} else if (key.return) {
handleArnSubmit()
} else if (key.backspace || key.delete) {
setCustomArn((prev) => prev.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
setCustomArn((prev) => prev + input)
}
return
}
if (step === "base_model") {
if (key.escape) {
handleBaseModelCancel()
}
// Other input is handled by SearchableList
}
},
{ isActive: isActive && isRawModeSupported },
)
if (step === "arn_input") {
return (
<Box flexDirection="column">
<Text bold color={COLORS.primaryBlue}>
Custom Model ID
</Text>
<Box marginTop={1}>
<Text color="gray">Enter your Application Inference Profile ARN or custom model ID</Text>
</Box>
<Box marginTop={1}>
{customArn ? (
<Text color="white">{customArn}</Text>
) : (
<Text color="gray">e.g. arn:aws:bedrock:region:account:application-inference-profile/...</Text>
)}
<Text inverse> </Text>
</Box>
<Box marginTop={1}>
<Text color="gray">Enter to continue, Esc to go back</Text>
</Box>
</Box>
)
}
// step === "base_model"
return (
<Box flexDirection="column">
<Text bold color={COLORS.primaryBlue}>
Base Inference Model
</Text>
<Text color="gray">Select the base model your inference profile uses (for capability detection)</Text>
<Box marginTop={1}>
<SearchableList
isActive={isActive && step === "base_model"}
items={getModelList("bedrock").map((id) => ({ id, label: id }))}
onSelect={(item) => {
onComplete(customArn, item.id)
}}
/>
</Box>
<Box marginTop={1}>
<Text color="gray">Type to search, arrows to navigate, Enter to select, Esc to go back</Text>
</Box>
</Box>
)
}
+23
View File
@@ -150,6 +150,7 @@ import { HighlightedInput } from "./HighlightedInput"
import { HistoryPanelContent } from "./HistoryPanelContent"
import { providerModels } from "./ModelPicker"
import { SettingsPanelContent } from "./SettingsPanelContent"
import { SkillsPanelContent } from "./SkillsPanelContent"
import { SlashCommandMenu } from "./SlashCommandMenu"
import { ThinkingIndicator } from "./ThinkingIndicator"
@@ -412,6 +413,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
| { type: "settings"; initialMode?: "model-picker" | "featured-models"; initialModelKey?: "actModelId" | "planModelId" }
| { type: "history" }
| { type: "help" }
| { type: "skills" }
| null
>(null)
@@ -1156,6 +1158,14 @@ export const ChatView: React.FC<ChatViewProps> = ({
setSlashMenuDismissed(true)
return
}
if (cmd.name === "skills") {
setActivePanel({ type: "skills" })
setTextInput("")
setCursorPos(0)
setSelectedSlashIndex(0)
setSlashMenuDismissed(true)
return
}
if (cmd.name === "clear") {
clearViewAndResetTask()
setSelectedSlashIndex(0)
@@ -1545,6 +1555,19 @@ export const ChatView: React.FC<ChatViewProps> = ({
{/* Help panel */}
{activePanel?.type === "help" && <HelpPanelContent onClose={() => setActivePanel(null)} />}
{/* Skills panel */}
{activePanel?.type === "skills" && ctrl && (
<SkillsPanelContent
controller={ctrl}
onClose={() => setActivePanel(null)}
onUseSkill={(skillPath) => {
setActivePanel(null)
setTextInput(`@${skillPath} `)
setCursorPos(skillPath.length + 2)
}}
/>
)}
{/* Slash command menu - below input (takes priority over file menu) */}
{showSlashMenu && !activePanel && (
<Box paddingLeft={1} paddingRight={1}>
+2 -2
View File
@@ -39,7 +39,7 @@ export const FeaturedModelPicker: React.FC<FeaturedModelPickerProps> = ({
const isSelected = i === selectedIndex
return (
<Box flexDirection="column" key={model.id} marginBottom={1}>
<Box flexDirection="column" key={`${model.id}-${model.labels[0] || "default"}`} marginBottom={1}>
<Box>
<Text color={isSelected ? COLORS.primaryBlue : undefined}>{isSelected ? " " : " "}</Text>
<Text bold color={isSelected ? COLORS.primaryBlue : "white"}>
@@ -81,7 +81,7 @@ export const FeaturedModelPicker: React.FC<FeaturedModelPickerProps> = ({
* Get the maximum valid index for the featured model picker
* (includes "Browse all" option if showBrowseAll is true)
*/
export function getFeaturedModelMaxIndex(showBrowseAll: boolean = true): number {
export function getFeaturedModelMaxIndex(showBrowseAll = true): number {
const featuredModels = getAllFeaturedModels()
return showBrowseAll ? featuredModels.length : featuredModels.length - 1
}
+16 -2
View File
@@ -71,6 +71,9 @@ import { COLORS } from "../constants/colors"
import { getOpenRouterDefaultModelId, usesOpenRouterModels } from "../utils/openrouter-models"
import { SearchableList, SearchableListItem } from "./SearchableList"
// Special ID used to indicate the user wants to enter a custom model ID / ARN
export const CUSTOM_MODEL_ID = "__custom__"
// Map providers to their static model lists and defaults
export const providerModels: Record<string, { models: Record<string, unknown>; defaultId: string }> = {
anthropic: { models: anthropicModels, defaultId: anthropicDefaultModelId },
@@ -169,12 +172,23 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, controller,
return getModelList(provider)
}, [provider, asyncModels])
// Providers that support custom model IDs (e.g., Bedrock Application Inference Profiles)
const supportsCustomModel = provider === "bedrock"
const items: SearchableListItem[] = useMemo(() => {
return modelList.map((modelId) => ({
const list = modelList.map((modelId) => ({
id: modelId,
label: modelId,
}))
}, [modelList])
// Add "Custom" option at the end for providers that support it
if (supportsCustomModel) {
list.push({
id: CUSTOM_MODEL_ID,
label: "Custom (ARN / Inference Profile)",
})
}
return list
}, [modelList, supportsCustomModel])
// For providers without a model picker, render nothing
if (!hasModelPicker(provider)) {
+72 -2
View File
@@ -29,6 +29,7 @@ import { useOcaAuth } from "../hooks/useOcaAuth"
import { isMouseEscapeSequence } from "../utils/input"
import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-config"
import { ApiKeyInput } from "./ApiKeyInput"
import { BedrockCustomModelFlow } from "./BedrockCustomModelFlow"
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
import { Checkbox } from "./Checkbox"
import {
@@ -38,7 +39,7 @@ import {
isBrowseAllSelected,
} from "./FeaturedModelPicker"
import { LanguagePicker } from "./LanguagePicker"
import { hasModelPicker, ModelPicker } from "./ModelPicker"
import { CUSTOM_MODEL_ID, hasModelPicker, ModelPicker } from "./ModelPicker"
import { OcaEmployeeCheck } from "./OcaEmployeeCheck"
import { OrganizationPicker } from "./OrganizationPicker"
import { Panel, PanelTab } from "./Panel"
@@ -171,6 +172,9 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
const [apiKeyValue, setApiKeyValue] = useState("")
const [editValue, setEditValue] = useState("")
// Bedrock custom ARN flow state
const [isBedrockCustomFlow, setIsBedrockCustomFlow] = useState(false)
// Settings state - single object for feature toggles
const [features, setFeatures] = useState<Record<FeatureKey, boolean>>(() => {
const initial: Record<string, boolean> = {}
@@ -944,10 +948,56 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
setReasoningEffortForMode,
])
// Handle completion of the Bedrock custom ARN flow (ARN + base model selected)
const handleBedrockCustomFlowComplete = useCallback(
async (arn: string, baseModelId: string) => {
if (!pickingModelKey) return
const apiConfig = stateManager.getApiConfiguration()
// Build a minimal BedrockConfig from current state for applyBedrockConfig
const bedrockConfig: BedrockConfig = {
awsRegion: apiConfig.awsRegion ?? "us-east-1",
awsAuthentication: apiConfig.awsUseProfile ? "profile" : "credentials",
awsUseCrossRegionInference: Boolean(apiConfig.awsUseCrossRegionInference),
}
await applyBedrockConfig({
bedrockConfig,
modelId: arn,
customModelBaseId: baseModelId,
controller,
})
// Flush pending state to ensure everything is persisted
await stateManager.flushPendingState()
// Rebuild API handler if there's an active task
rebuildTaskApi()
refreshModelIds()
setIsBedrockCustomFlow(false)
setPickingModelKey(null)
// If opened from /models command, close the entire settings panel
if (initialMode) {
onClose()
}
},
[pickingModelKey, stateManager, controller, rebuildTaskApi, refreshModelIds, initialMode, onClose],
)
// Handle model selection from picker
const handleModelSelect = useCallback(
async (modelId: string) => {
if (!pickingModelKey) return
// Intercept "Custom" selection for Bedrock — redirect to custom ARN input flow
if (modelId === CUSTOM_MODEL_ID && provider === "bedrock") {
setIsPickingModel(false)
setIsBedrockCustomFlow(true)
return
}
const apiConfig = stateManager.getApiConfiguration()
const actProvider = apiConfig.actModeApiProvider
const planProvider = apiConfig.planModeApiProvider || actProvider
@@ -1008,7 +1058,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
onClose()
}
},
[pickingModelKey, separateModels, stateManager, controller, refreshModelIds, initialMode, onClose],
[pickingModelKey, separateModels, stateManager, controller, provider, refreshModelIds, initialMode, onClose],
)
// Handle language selection from picker
@@ -1332,6 +1382,11 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
return
}
// Bedrock custom flow - input handled by BedrockCustomModelFlow component
if (isBedrockCustomFlow) {
return
}
if (isEditing) {
if (key.escape) {
setIsEditing(false)
@@ -1584,6 +1639,20 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
)
}
// Bedrock custom model flow (ARN input + base model selection)
if (isBedrockCustomFlow) {
return (
<BedrockCustomModelFlow
isActive={isBedrockCustomFlow}
onCancel={() => {
setIsBedrockCustomFlow(false)
setIsPickingModel(true)
}}
onComplete={handleBedrockCustomFlowComplete}
/>
)
}
// Account tab - loading state
if (currentTab === "account" && isAccountLoading) {
return (
@@ -1748,6 +1817,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
isWaitingForClineAuth ||
isShowingOcaEmployeeCheck ||
isWaitingForOcaAuth ||
isBedrockCustomFlow ||
isEditing
return (
@@ -0,0 +1,230 @@
/**
* Tests for SkillsPanelContent component
*
* Tests keyboard interactions and callbacks.
* Rendering tests are limited due to ink-testing-library constraints with nested components.
*/
import { render } from "ink-testing-library"
// biome-ignore lint/correctness/noUnusedImports: React must be in scope for JSX in this test file.
import React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
// Mock refreshSkills
const mockRefreshSkills = vi.fn()
vi.mock("@/core/controller/file/refreshSkills", () => ({
refreshSkills: () => mockRefreshSkills(),
}))
// Mock toggleSkill
const mockToggleSkill = vi.fn()
vi.mock("@/core/controller/file/toggleSkill", () => ({
toggleSkill: (...args: unknown[]) => mockToggleSkill(...args),
}))
// Mock child_process exec
const mockExec = vi.fn()
vi.mock("node:child_process", () => ({
exec: (...args: unknown[]) => mockExec(...args),
}))
// Mock StdinContext
vi.mock("../context/StdinContext", () => ({
useStdinContext: () => ({ isRawModeSupported: true }),
}))
import { SkillsPanelContent } from "./SkillsPanelContent"
// Helper to wait for async state updates
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
describe("SkillsPanelContent", () => {
const mockController = {} as any
const mockOnClose = vi.fn()
const mockOnUseSkill = vi.fn()
const defaultProps = {
controller: mockController,
onClose: mockOnClose,
onUseSkill: mockOnUseSkill,
}
beforeEach(() => {
vi.clearAllMocks()
mockRefreshSkills.mockResolvedValue({
globalSkills: [],
localSkills: [],
})
})
describe("keyboard interactions", () => {
it("should call onClose when Escape is pressed", async () => {
mockRefreshSkills.mockResolvedValue({
globalSkills: [],
localSkills: [],
})
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
await delay()
stdin.write("\x1B") // Escape
await delay()
expect(mockOnClose).toHaveBeenCalled()
})
it("should call onUseSkill with skill path when Enter is pressed on a skill", async () => {
mockRefreshSkills.mockResolvedValue({
globalSkills: [{ name: "test-skill", description: "Test", path: "/test/path/SKILL.md", enabled: true }],
localSkills: [],
})
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
await delay()
stdin.write("\r") // Enter
await delay()
expect(mockOnUseSkill).toHaveBeenCalledWith("/test/path/SKILL.md")
})
it("should call toggleSkill when Space is pressed on a skill", async () => {
mockRefreshSkills.mockResolvedValue({
globalSkills: [{ name: "test-skill", description: "Test", path: "/test/path/SKILL.md", enabled: true }],
localSkills: [],
})
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
await delay()
stdin.write(" ") // Space
await delay()
expect(mockToggleSkill).toHaveBeenCalledWith(
mockController,
expect.objectContaining({
skillPath: "/test/path/SKILL.md",
isGlobal: true,
enabled: false, // toggled from true to false
}),
)
})
it("should open marketplace URL when Enter is pressed on marketplace item", async () => {
mockRefreshSkills.mockResolvedValue({
globalSkills: [{ name: "skill", description: "desc", path: "/path", enabled: true }],
localSkills: [],
})
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
await delay()
// Navigate down to marketplace (past the one skill)
stdin.write("\x1B[B") // Down arrow
await delay()
stdin.write("\r") // Enter
await delay()
// Should have called exec with open command
expect(mockExec).toHaveBeenCalled()
const execCall = mockExec.mock.calls[0][0]
expect(execCall).toContain("https://skills.sh/")
})
it("should navigate through skills with arrow keys", async () => {
mockRefreshSkills.mockResolvedValue({
globalSkills: [
{ name: "skill-1", description: "First", path: "/path1", enabled: true },
{ name: "skill-2", description: "Second", path: "/path2", enabled: true },
],
localSkills: [],
})
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
await delay()
// Navigate down
stdin.write("\x1B[B") // Down arrow
await delay()
// Press Enter - should use second skill
stdin.write("\r")
await delay()
expect(mockOnUseSkill).toHaveBeenCalledWith("/path2")
})
it("should navigate with vim keys (j/k)", async () => {
mockRefreshSkills.mockResolvedValue({
globalSkills: [
{ name: "skill-1", description: "First", path: "/path1", enabled: true },
{ name: "skill-2", description: "Second", path: "/path2", enabled: true },
],
localSkills: [],
})
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
await delay()
// Navigate down with j
stdin.write("j")
await delay()
// Press Enter - should use second skill
stdin.write("\r")
await delay()
expect(mockOnUseSkill).toHaveBeenCalledWith("/path2")
})
it("should revert optimistic toggle on failure", async () => {
mockRefreshSkills.mockResolvedValue({
globalSkills: [{ name: "test-skill", description: "Test", path: "/test/path/SKILL.md", enabled: true }],
localSkills: [],
})
mockToggleSkill.mockRejectedValueOnce(new Error("toggle failed"))
const { stdin, lastFrame } = render(<SkillsPanelContent {...defaultProps} />)
await delay()
stdin.write(" ") // Space to toggle
await delay(100)
// toggleSkill was called with enabled: false (toggled from true)
expect(mockToggleSkill).toHaveBeenCalledWith(mockController, expect.objectContaining({ enabled: false }))
const frame = lastFrame() || ""
expect(frame).toContain("● test-skill")
expect(frame).not.toContain("○ test-skill")
})
it("should wrap navigation at list boundaries", async () => {
mockRefreshSkills.mockResolvedValue({
globalSkills: [{ name: "only-skill", description: "Only", path: "/only", enabled: true }],
localSkills: [],
})
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
await delay()
// Navigate up from first item (should wrap to last - marketplace)
stdin.write("\x1B[A") // Up arrow
await delay()
stdin.write("\r") // Enter
await delay()
// Should have opened marketplace (wrapped to last item)
expect(mockExec).toHaveBeenCalled()
})
})
describe("skill loading", () => {
it("should call refreshSkills on mount", async () => {
render(<SkillsPanelContent {...defaultProps} />)
await delay()
expect(mockRefreshSkills).toHaveBeenCalled()
})
})
})
+257
View File
@@ -0,0 +1,257 @@
/**
* Skills panel content for inline display in ChatView
* Shows installed skills with toggle and use functionality
*/
import { exec } from "node:child_process"
import os from "node:os"
import { Box, Text, useInput } from "ink"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import type { Controller } from "@/core/controller"
import { refreshSkills } from "@/core/controller/file/refreshSkills"
import { toggleSkill } from "@/core/controller/file/toggleSkill"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
import { isMouseEscapeSequence } from "../utils/input"
import { Panel } from "./Panel"
const SKILLS_MARKETPLACE_URL = "https://skills.sh/"
interface SkillInfo {
name: string
description: string
path: string
enabled: boolean
}
interface SkillsPanelContentProps {
controller: Controller
onClose: () => void
onUseSkill: (skillPath: string) => void
}
const MAX_VISIBLE = 8
export const SkillsPanelContent: React.FC<SkillsPanelContentProps> = ({ controller, onClose, onUseSkill }) => {
const { isRawModeSupported } = useStdinContext()
const [globalSkills, setGlobalSkills] = useState<SkillInfo[]>([])
const [localSkills, setLocalSkills] = useState<SkillInfo[]>([])
const [selectedIndex, setSelectedIndex] = useState(0)
const [isLoading, setIsLoading] = useState(true)
// Load skills on mount
useEffect(() => {
const loadSkills = async () => {
try {
const skillsData = await refreshSkills(controller)
setGlobalSkills(skillsData.globalSkills || [])
setLocalSkills(skillsData.localSkills || [])
} catch (_error) {
// Skills loading failed, show empty state
} finally {
setIsLoading(false)
}
}
loadSkills()
}, [controller])
// Build flat list of skills with source info (global first, then local, alphabetical within each)
const skillEntries = useMemo(() => {
const entries: { skill: SkillInfo; isGlobal: boolean }[] = []
globalSkills.forEach((skill) => entries.push({ skill, isGlobal: true }))
localSkills.forEach((skill) => entries.push({ skill, isGlobal: false }))
return entries.sort((a, b) => {
if (a.isGlobal !== b.isGlobal) return a.isGlobal ? -1 : 1
return a.skill.name.localeCompare(b.skill.name)
})
}, [globalSkills, localSkills])
// Handle toggle
const handleToggle = useCallback(async () => {
const entry = skillEntries[selectedIndex]
if (!entry) return
const newEnabled = !entry.skill.enabled
const setter = entry.isGlobal ? setGlobalSkills : setLocalSkills
const update = (enabled: boolean) =>
setter((prev) => prev.map((s) => (s.path === entry.skill.path ? { ...s, enabled } : s)))
// Optimistic update
update(newEnabled)
try {
await toggleSkill(controller, {
metadata: undefined,
skillPath: entry.skill.path,
isGlobal: entry.isGlobal,
enabled: newEnabled,
})
} catch {
// Revert on failure
update(!newEnabled)
}
}, [controller, skillEntries, selectedIndex])
// Handle use skill (insert @ mention)
const handleUse = useCallback(() => {
const entry = skillEntries[selectedIndex]
if (!entry) return
onUseSkill(entry.skill.path)
}, [skillEntries, selectedIndex, onUseSkill])
// Handle opening the marketplace URL
const openMarketplace = useCallback(() => {
const platform = os.platform()
let command: string
if (platform === "darwin") {
command = `open "${SKILLS_MARKETPLACE_URL}"`
} else if (platform === "win32") {
command = `start "${SKILLS_MARKETPLACE_URL}"`
} else {
command = `xdg-open "${SKILLS_MARKETPLACE_URL}"`
}
exec(command, (err) => {
if (err) {
// Fallback: show URL in terminal if browser open fails
console.error(`Visit: ${SKILLS_MARKETPLACE_URL}`)
}
})
}, [])
// Total items = skills + 1 for marketplace link
const totalItems = skillEntries.length + 1
const isMarketplaceSelected = selectedIndex === skillEntries.length
useInput(
(input, key) => {
if (isMouseEscapeSequence(input)) {
return
}
if (key.escape) {
onClose()
return
}
// Navigation
if (key.upArrow || input === "k") {
setSelectedIndex((i) => (i > 0 ? i - 1 : totalItems - 1))
return
}
if (key.downArrow || input === "j") {
setSelectedIndex((i) => (i < totalItems - 1 ? i + 1 : 0))
return
}
// Actions
if (key.return) {
if (isMarketplaceSelected) {
openMarketplace()
} else {
handleUse()
}
return
}
if (input === " " && !isMarketplaceSelected) {
handleToggle()
return
}
},
{ isActive: isRawModeSupported },
)
// Scrolling window (includes marketplace row)
const halfVisible = Math.floor(MAX_VISIBLE / 2)
const startIndex = Math.max(0, Math.min(selectedIndex - halfVisible, totalItems - MAX_VISIBLE))
if (isLoading) {
return (
<Panel label="Skills">
<Text color="gray">Loading skills...</Text>
</Panel>
)
}
// Check if marketplace row is in visible window
const marketplaceIndex = skillEntries.length
const showMarketplace = marketplaceIndex >= startIndex && marketplaceIndex < startIndex + MAX_VISIBLE
return (
<Panel label="Skills">
<Box flexDirection="column" gap={1}>
{skillEntries.length === 0 ? (
<Box flexDirection="column" gap={1}>
<Text color="gray">No skills installed.</Text>
<Text>
Install skills with: <Text color="white">npx skills add owner/repo</Text>
</Text>
</Box>
) : (
<Box flexDirection="column">
{skillEntries
.slice(startIndex, Math.min(startIndex + MAX_VISIBLE, skillEntries.length))
.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = skillEntries[actualIndex - 1]
const showHeader = actualIndex === 0 || (prevEntry && prevEntry.isGlobal !== entry.isGlobal)
return (
<React.Fragment key={entry.skill.path}>
{showHeader && (
<Box marginTop={actualIndex > 0 ? 1 : 0}>
<Text bold color="gray">
{entry.isGlobal ? "Global Skills:" : "Workspace Skills:"}
</Text>
</Box>
)}
<SkillRow isSelected={actualIndex === selectedIndex} skill={entry.skill} />
</React.Fragment>
)
})}
</Box>
)}
{/* Marketplace link - selectable */}
{showMarketplace && (
<Box marginTop={1}>
<Text color={isMarketplaceSelected ? "cyan" : undefined}>
{isMarketplaceSelected ? " " : " "}
<Text color={COLORS.primaryBlue}>Browse more skills at https://skills.sh/</Text>
</Text>
</Box>
)}
{/* Help text */}
<Box marginTop={1}>
<Text color="gray">
/ Navigate Enter {isMarketplaceSelected ? "Open" : "Use"}
{!isMarketplaceSelected && " • Space Toggle"}
</Text>
</Box>
</Box>
</Panel>
)
}
const SkillRow: React.FC<{ skill: SkillInfo; isSelected: boolean }> = ({ skill, isSelected }) => {
return (
<Box flexDirection="column">
<Box>
<Text color={isSelected ? "cyan" : undefined}>
{isSelected ? " " : " "}
<Text color={skill.enabled ? "green" : "red"}>{skill.enabled ? "●" : "○"}</Text>
<Text> </Text>
<Text bold color="white">
{skill.name}
</Text>
</Text>
</Box>
{skill.description && (
<Box marginLeft={4}>
<Text color="gray">
{skill.description.length > 60 ? skill.description.slice(0, 57) + "..." : skill.description}
</Text>
</Box>
)}
</Box>
)
}
+14 -8
View File
@@ -12,23 +12,29 @@ export interface FeaturedModel {
export const FEATURED_MODELS: { recommended: FeaturedModel[]; free: FeaturedModel[] } = {
recommended: [
{
id: "google/gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro Preview",
description: "Latest Gemini release with 1m ctx window and strong coding performance",
labels: ["NEW"],
},
{
id: "anthropic/claude-sonnet-4.6",
name: "Claude Sonnet 4.6",
description: "Latest Sonnet release with strong coding and agent performance",
labels: ["NEW"],
},
{
id: "anthropic/claude-opus-4.6",
name: "Claude Opus 4.6",
description: "State-of-the-art for complex coding",
description: "Most intelligent model for agents and coding",
labels: ["BEST"],
},
{
id: "openai/gpt-5.2-codex",
name: "GPT 5.2 Codex",
description: "OpenAI's latest with strong coding abilities",
labels: ["NEW"],
},
{
id: "google/gemini-3-pro-preview",
name: "Gemini 3 Pro",
description: "1M context window for large codebases",
labels: ["TRENDING"],
labels: ["HOT"],
},
],
free: [
+6 -11
View File
@@ -8,12 +8,11 @@ import { Command } from "commander"
import { render } from "ink"
import React from "react"
import { ClineEndpoint } from "@/config"
import { Controller } from "@/core/controller"
import type { Controller } from "@/core/controller"
import { StateManager } from "@/core/storage/StateManager"
import { AuthHandler } from "@/hosts/external/AuthHandler"
import { HostProvider } from "@/hosts/host-provider"
import { FileEditProvider } from "@/integrations/editor/FileEditProvider"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { StandaloneTerminalManager } from "@/integrations/terminal/standalone/StandaloneTerminalManager"
import { ErrorService } from "@/services/error/ErrorService"
import { telemetryService } from "@/services/telemetry"
@@ -425,7 +424,7 @@ interface InitOptions {
*/
async function initializeCli(options: InitOptions): Promise<CliContext> {
const workspacePath = options.cwd || process.cwd()
const { extensionContext, DATA_DIR, EXTENSION_DIR } = initializeCliContext({
const { extensionContext, storageContext, DATA_DIR, EXTENSION_DIR } = initializeCliContext({
clineDir: options.config,
workspaceDir: workspacePath,
})
@@ -466,13 +465,9 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
DATA_DIR,
)
await StateManager.initialize(extensionContext as any)
await StateManager.initialize(storageContext)
await ErrorService.initialize()
// Initialize OpenAI Codex OAuth manager with extension context for secrets storage
openAiCodexOAuthManager.initialize(extensionContext)
const webview = HostProvider.get().createWebviewProvider() as CliWebviewProvider
const controller = webview.controller
@@ -754,7 +749,7 @@ program
.option("-a, --act", "Run in act mode")
.option("-p, --plan", "Run in plan mode")
.option("-y, --yolo", "Enable yes/yolo mode (auto-approve actions)")
.option("-t, --timeout <seconds>", "Timeout in seconds for yes/yolo mode (default: 600)")
.option("-t, --timeout <seconds>", "Optional timeout in seconds (applies only when provided)")
.option("-m, --model <model>", "Model to use for the task")
.option("-v, --verbose", "Show verbose output")
.option("-c, --cwd <path>", "Working directory for the task")
@@ -792,7 +787,7 @@ program
.description("Authenticate a provider and configure what model is used")
.option("-p, --provider <id>", "Provider ID for quick setup (e.g., openai-native, anthropic, moonshot)")
.option("-k, --apikey <key>", "API key for the provider")
.option("-m, --modelid <id>", "Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929, kimi-k2.5)")
.option("-m, --modelid <id>", "Model ID to configure (e.g., gpt-4o, claude-sonnet-4-6, kimi-k2.5)")
.option("-b, --baseurl <url>", "Base URL (optional, only for openai provider)")
.option("-v, --verbose", "Show verbose output")
.option("-c, --cwd <path>", "Working directory for the task")
@@ -983,7 +978,7 @@ program
.option("-a, --act", "Run in act mode")
.option("-p, --plan", "Run in plan mode")
.option("-y, --yolo", "Enable yolo mode (auto-approve actions)")
.option("-t, --timeout <seconds>", "Timeout in seconds for yolo mode (default: 600)")
.option("-t, --timeout <seconds>", "Optional timeout in seconds (applies only when provided)")
.option("-m, --model <model>", "Model to use for the task")
.option("-v, --verbose", "Show verbose output")
.option("-c, --cwd <path>", "Working directory")
+9 -5
View File
@@ -26,7 +26,7 @@ export interface PlainTextTaskOptions {
imageDataUrls?: string[]
verbose?: boolean
jsonOutput?: boolean
/** Timeout in seconds (default: 600 = 10 minutes) */
/** Timeout in seconds (only applied when explicitly provided) */
timeoutSeconds?: number
/** Task ID to resume an existing task */
taskId?: string
@@ -153,10 +153,14 @@ export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<b
throw new Error("Either taskId or prompt must be provided")
}
// Normal mode: wait for task completion
const timeoutMs = (options.timeoutSeconds ?? 600) * 1000 // default 10 minutes
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeoutMs))
await Promise.race([completionPromise, timeoutPromise])
// Wait for task completion, with optional timeout only when explicitly configured
if (options.timeoutSeconds) {
const timeoutMs = options.timeoutSeconds * 1000
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeoutMs))
await Promise.race([completionPromise, timeoutPromise])
} else {
await completionPromise
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error)
if (jsonOutput) {
+16 -1
View File
@@ -80,15 +80,18 @@ export async function applyProviderConfig(options: ApplyProviderConfigOptions):
export interface ApplyBedrockConfigOptions {
bedrockConfig: BedrockConfig
modelId?: string
customModelBaseId?: string // Base model ID for custom ARN/Inference Profile (for capability detection)
controller?: Controller
}
/**
* Apply Bedrock provider configuration to state
* Handles AWS-specific fields (authentication, region, credentials)
* When customModelBaseId is provided, sets the custom model flags so the system
* knows to use the ARN as the model ID and the base model for capability detection.
*/
export async function applyBedrockConfig(options: ApplyBedrockConfigOptions): Promise<void> {
const { bedrockConfig, modelId, controller } = options
const { bedrockConfig, modelId, customModelBaseId, controller } = options
const stateManager = StateManager.get()
const config: Record<string, unknown> = {
@@ -108,6 +111,18 @@ export async function applyBedrockConfig(options: ApplyBedrockConfigOptions): Pr
if (planModelKey) config[planModelKey] = finalModelId
}
// Handle custom model (Application Inference Profile ARN)
if (customModelBaseId) {
config.actModeAwsBedrockCustomSelected = true
config.planModeAwsBedrockCustomSelected = true
config.actModeAwsBedrockCustomModelBaseId = customModelBaseId
config.planModeAwsBedrockCustomModelBaseId = customModelBaseId
} else {
// Ensure custom flags are cleared when using a standard model
config.actModeAwsBedrockCustomSelected = false
config.planModeAwsBedrockCustomSelected = false
}
// Add optional AWS credentials
if (bedrockConfig.awsProfile !== undefined) config.awsProfile = bedrockConfig.awsProfile
if (bedrockConfig.awsAccessKey) config.awsAccessKey = bedrockConfig.awsAccessKey
+56 -92
View File
@@ -1,23 +1,21 @@
/**
* VSCode context stub for CLI mode
* Provides mock implementations of VSCode extension context
* Provides mock implementations of VSCode extension context.
*/
import { mkdirSync } from "node:fs"
import { fileURLToPath } from "node:url"
import os from "os"
import path from "path"
import { ExtensionRegistryInfo } from "@/registry"
import { ClineExtensionContext } from "@/shared/cline"
import { ClineFileStorage } from "@/shared/storage"
import type { ClineMemento } from "@/shared/storage/ClineStorage"
import { createStorageContext, type StorageContext } from "@/shared/storage/storage-context"
import { EnvironmentVariableCollection, ExtensionKind, ExtensionMode, readJson, URI } from "./vscode-shim"
// ES module equivalent of __dirname
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const SETTINGS_SUBFOLDER = "data"
/**
* CLI-specific state overrides.
* These values are always returned regardless of what's stored,
@@ -35,33 +33,43 @@ const CLI_STATE_OVERRIDES: Record<string, any> = {
}
/**
* File-based Memento store with optional key overrides.
* Implements VSCode's Memento interface using SyncJsonFileStorage.
* Memento adapter that wraps a ClineFileStorage with optional key overrides.
* Used for globalState where CLI needs to inject hardcoded overrides.
*/
class MementoStore extends ClineFileStorage {
private overrides: Record<string, any>
class MementoAdapter implements ClineMemento {
constructor(
private readonly store: ClineMemento,
private readonly overrides: Record<string, any> = {},
) {}
constructor(filePath: string, overrides: Record<string, any> = {}) {
super(filePath, "MementoStore")
this.overrides = overrides
}
// VSCode Memento interface - override base class get() with overload support
override get<T>(key: string): T | undefined
override get<T>(key: string, defaultValue: T): T
override get<T>(key: string, defaultValue?: T): T | undefined {
get<T>(key: string): T | undefined
get<T>(key: string, defaultValue: T): T
get<T>(key: string, defaultValue?: T): T | undefined {
if (key in this.overrides) {
return this.overrides[key] as T
}
const value = super.get<T>(key)
const value = this.store.get<T>(key)
return value !== undefined ? value : defaultValue
}
override async update(key: string, value: any): Promise<void> {
if (key in this.overrides) {
return
update(key: string, value: any): Thenable<void> {
return this.setBatch({ [key]: value })
}
keys(): readonly string[] {
return this.store.keys()
}
setBatch(entries: Record<string, any>): Thenable<void> {
// Filter out overridden keys and delegate to underlying store
const filteredEntries: Record<string, any> = {}
for (const [key, value] of Object.entries(entries)) {
if (!(key in this.overrides)) {
filteredEntries[key] = value
}
}
this.set(key, value)
this.store.setBatch(filteredEntries)
return Promise.resolve()
}
setKeysForSync(_keys: readonly string[]): void {
@@ -69,81 +77,45 @@ class MementoStore extends ClineFileStorage {
}
}
/**
* File-based secret storage implementing VSCode's SecretStorage interface.
* Uses sync storage internally but exposes async API for VSCode compatibility.
*/
class SecretStore {
private storage: ClineFileStorage<string>
private onDidChangeEmitter = {
event: () => ({ dispose: () => {} }),
fire: (_e: any) => {},
dispose: () => {},
}
onDidChange = this.onDidChangeEmitter.event
constructor(filePath: string) {
this.storage = new ClineFileStorage<string>(filePath, "SecretStore")
}
get(key: string): Promise<string | undefined> {
return Promise.resolve(this.storage.get(key))
}
store(key: string, value: string): Promise<void> {
this.storage.set(key, value)
return Promise.resolve()
}
delete(key: string): Promise<void> {
this.storage.delete(key)
return Promise.resolve()
}
}
export interface CliContextConfig {
clineDir?: string
/** The workspace directory being worked in (for hashing into storage path) */
/** The workspace directory being worked in (used to compute workspace storage hash) */
workspaceDir?: string
}
/**
* Create a short hash of a string for use in directory names
*/
function hashString(str: string): string {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash // Convert to 32bit integer
}
return Math.abs(hash).toString(16).substring(0, 8)
}
export interface CliContextResult {
extensionContext: ClineExtensionContext
storageContext: StorageContext
DATA_DIR: string
EXTENSION_DIR: string
WORKSPACE_STORAGE_DIR: string
}
/**
* Initialize the VSCode-like context for CLI mode
* Initialize the VSCode-like context for CLI mode.
*
* Creates a shared StorageContext (the single source of truth for all storage)
* and wraps it in a ClineExtensionContext shell for legacy APIs that still
* expect the VSCode ExtensionContext shape.
*/
export function initializeCliContext(config: CliContextConfig = {}): CliContextResult {
const CLINE_DIR = config.clineDir || process.env.CLINE_DIR || path.join(os.homedir(), ".cline")
const DATA_DIR = path.join(CLINE_DIR, SETTINGS_SUBFOLDER)
// Workspace storage should always be under ~/.cline/data/workspaces/<hash>/
// where hash is derived from the workspace path to keep workspaces isolated
const workspacePath = config.workspaceDir || process.cwd()
const workspaceHash = hashString(workspacePath)
const WORKSPACE_STORAGE_DIR = process.env.WORKSPACE_STORAGE_DIR || path.join(DATA_DIR, "workspaces", workspaceHash)
// Create the shared StorageContext — this owns all ClineFileStorage instances.
// CLI, JetBrains, and VSCode all share this same file-backed implementation.
let storageContext = createStorageContext({
clineDir: CLINE_DIR,
workspacePath: config.workspaceDir || process.cwd(),
workspaceStorageDir: process.env.WORKSPACE_STORAGE_DIR || undefined,
})
storageContext = {
...storageContext,
// Storage — delegates to storageContext stores (with CLI overrides for globalState)
globalState: new MementoAdapter(storageContext.globalState, CLI_STATE_OVERRIDES),
}
// Ensure directories exist
mkdirSync(DATA_DIR, { recursive: true })
mkdirSync(WORKSPACE_STORAGE_DIR, { recursive: true })
const DATA_DIR = storageContext.dataDir
const WORKSPACE_STORAGE_DIR = storageContext.workspaceStoragePath
// For CLI, extension dir is the package root (one level up from dist/)
const EXTENSION_DIR = path.resolve(__dirname, "..")
@@ -160,38 +132,30 @@ export function initializeCliContext(config: CliContextConfig = {}): CliContextR
extensionKind: ExtensionKind.UI,
}
// Build the ClineExtensionContext shell. All storage delegates to storageContext —
// there are NO separate ClineFileStorage instances here.
const extensionContext: ClineExtensionContext = {
extension: extension,
extensionMode: EXTENSION_MODE,
// Set up KV stores (globalState has CLI-specific overrides)
globalState: new MementoStore(path.join(DATA_DIR, "globalState.json"), CLI_STATE_OVERRIDES),
secrets: new SecretStore(path.join(DATA_DIR, "secrets.json")),
// Set up URIs
// URIs / paths
storageUri: URI.file(WORKSPACE_STORAGE_DIR),
storagePath: WORKSPACE_STORAGE_DIR,
globalStorageUri: URI.file(DATA_DIR),
globalStoragePath: DATA_DIR,
// Logs
logUri: URI.file(DATA_DIR),
logPath: DATA_DIR,
extensionUri: URI.file(EXTENSION_DIR),
extensionPath: EXTENSION_DIR,
asAbsolutePath: (relPath: string) => path.join(EXTENSION_DIR, relPath),
subscriptions: [],
environmentVariableCollection: new EnvironmentVariableCollection() as any,
// Workspace state
workspaceState: new MementoStore(path.join(WORKSPACE_STORAGE_DIR, "workspaceState.json")),
}
return {
extensionContext,
storageContext,
DATA_DIR,
EXTENSION_DIR,
WORKSPACE_STORAGE_DIR,
+8 -3
View File
@@ -16,8 +16,13 @@ description: "Learn how to configure and use Anthropic Claude models with Cline.
Cline supports the following Anthropic Claude models:
#### Claude 4.6 Series
- `claude-sonnet-4-6` - Latest Sonnet with extended thinking support
- `claude-sonnet-4-6:1m` - 1M context window variant with tiered pricing
#### Claude 4.5 Series
- `claude-sonnet-4-5-20250929` (Recommended) - Latest Sonnet with extended thinking support
- `claude-sonnet-4-5-20250929` (Recommended) - Stable default Sonnet with reasoning support
- `claude-sonnet-4-5-20250929:1m` - 1M context window variant with tiered pricing
#### Claude 4 Series
@@ -56,8 +61,8 @@ Cline users can leverage this by checking the `Enable Extended Thinking` box bel
**Key Aspects of Extended Thinking:**
- **Supported Models:** This feature is available for select models, including Claude Opus 4, Claude Sonnet 4.5, and Claude Sonnet 3.7.
- **Summarized Thinking (Claude 4):** For Claude 4 and 4.5 models, the API returns a summary of the full thinking process to balance insight with efficiency and prevent misuse. You are billed for the full thinking tokens, not just the summary.
- **Supported Models:** This feature is available for select models, including Claude Opus 4, Claude Sonnet 3.7+.
- **Summarized Thinking (Claude 3.7+):** For Claude 3.7+ models, the API returns a summary of the full thinking process to balance insight with efficiency and prevent misuse. You are billed for the full thinking tokens, not just the summary.
- **Streaming:** Extended thinking responses, including the `thinking` blocks, can be streamed.
- **Tool Use & Prompt Caching:** Extended thinking interacts with tool use (requiring thinking blocks to be passed back) and prompt caching (with specific behaviors around cache invalidation and context).
+1
View File
@@ -20,6 +20,7 @@ Cline supports the following AskSage models (subscription-based pricing):
#### Claude Models
- `claude-4-sonnet` (Default) - Claude 4 Sonnet via AskSage
- `claude-4.6-sonnet` - Claude 4.6 Sonnet via AskSage
- `claude-4-opus` - Claude 4 Opus via AskSage
- `claude-37-sonnet` - Claude 3.7 Sonnet
- `claude-35-sonnet` - Claude 3.5 Sonnet
+4 -6
View File
@@ -16,13 +16,11 @@ Cerebras delivers the world's fastest AI inference through their revolutionary w
### Supported Models
Cline supports the following Cerebras models (all currently free):
Cline supports the following Cerebras models:
- `zai-glm-4.7` (Default) - Highly capable general-purpose model (up to 1,000 tokens/s), competitive with leading proprietary models on coding tasks (131K context)
- `gpt-oss-120b` - OpenAI's intelligent general purpose model with 3,000 tokens/s (128K context)
- `qwen-3-235b-a22b-instruct-2507` - Intelligent model with ~1,400 tokens/s (64K context)
- `llama-3.3-70b` - Powerful model with ~2,600 tokens/s (64K context)
- `qwen-3-32b` - SOTA coding performance with ~2,500 tokens/s (64K context)
- `zai-glm-4.7` - Highly capable general-purpose model on Cerebras (up to 1,000 tokens/s), competitive with leading proprietary models on coding tasks.
- `gpt-oss-120b` - Intelligent general purpose model with 3,000 tokens/s
- `qwen-3-235b-a22b-instruct-2507` - Advanced instruction-following model
### Configuration in Cline
+105 -66
View File
@@ -1,14 +1,15 @@
{
"name": "claude-dev",
"version": "3.63.0",
"version": "3.66.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.63.0",
"version": "3.66.0",
"license": "Apache-2.0",
"workspaces": [
".",
"cli"
],
"dependencies": {
@@ -45,8 +46,8 @@
"@opentelemetry/sdk-trace-node": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.37.0",
"@playwright/test": "^1.55.1",
"@sap-ai-sdk/ai-api": "^2.1.0",
"@sap-ai-sdk/orchestration": "^2.1.0",
"@sap-ai-sdk/ai-api": "^2.7.0",
"@sap-ai-sdk/orchestration": "^2.7.0",
"@sap-cloud-sdk/connectivity": "^4.2.0",
"@streamparser/json": "^0.0.22",
"@tailwindcss/vite": "^4.1.14",
@@ -161,7 +162,7 @@
},
"cli": {
"name": "cline",
"version": "2.2.1",
"version": "2.4.1",
"cpu": [
"x64",
"arm64"
@@ -1575,7 +1576,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -3129,7 +3129,6 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2"
@@ -4032,7 +4031,6 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
@@ -4108,7 +4106,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -5799,7 +5796,8 @@
"optional": true,
"os": [
"android"
]
],
"peer": true
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.57.1",
@@ -5812,7 +5810,8 @@
"optional": true,
"os": [
"android"
]
],
"peer": true
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.57.1",
@@ -5825,7 +5824,8 @@
"optional": true,
"os": [
"darwin"
]
],
"peer": true
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.57.1",
@@ -5838,7 +5838,8 @@
"optional": true,
"os": [
"darwin"
]
],
"peer": true
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.57.1",
@@ -5851,7 +5852,8 @@
"optional": true,
"os": [
"freebsd"
]
],
"peer": true
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.57.1",
@@ -5864,7 +5866,8 @@
"optional": true,
"os": [
"freebsd"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.57.1",
@@ -5877,7 +5880,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.57.1",
@@ -5890,7 +5894,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.57.1",
@@ -5903,7 +5908,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.57.1",
@@ -5916,7 +5922,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.57.1",
@@ -5929,7 +5936,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.57.1",
@@ -5942,7 +5950,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.57.1",
@@ -5955,7 +5964,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.57.1",
@@ -5968,7 +5978,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.57.1",
@@ -5981,7 +5992,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.57.1",
@@ -5994,7 +6006,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.57.1",
@@ -6007,7 +6020,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.1",
@@ -6020,7 +6034,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.1",
@@ -6033,7 +6048,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.57.1",
@@ -6046,7 +6062,8 @@
"optional": true,
"os": [
"openbsd"
]
],
"peer": true
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.57.1",
@@ -6059,7 +6076,8 @@
"optional": true,
"os": [
"openharmony"
]
],
"peer": true
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.57.1",
@@ -6072,7 +6090,8 @@
"optional": true,
"os": [
"win32"
]
],
"peer": true
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.57.1",
@@ -6085,7 +6104,8 @@
"optional": true,
"os": [
"win32"
]
],
"peer": true
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.57.1",
@@ -6098,7 +6118,8 @@
"optional": true,
"os": [
"win32"
]
],
"peer": true
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.57.1",
@@ -6111,23 +6132,24 @@
"optional": true,
"os": [
"win32"
]
],
"peer": true
},
"node_modules/@sap-ai-sdk/ai-api": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/ai-api/-/ai-api-2.6.0.tgz",
"integrity": "sha512-HdQ3T6FSD/jlPnsDKEGsNg+SQONpHBvxfu6OVwW/5nLdlvPmWpysirYvOc0eqj7e+X3zBHGIgoGWKSxIcYOjNg==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/ai-api/-/ai-api-2.7.0.tgz",
"integrity": "sha512-dDc2Si5Mu62dVZkJ/f55fBIlbIjVjCKmPi9tt2jWix59o/f9z2Zkw7l7Il+H0RbgANyyQmZsCkXDHRUmzAKKDw==",
"license": "Apache-2.0",
"dependencies": {
"@sap-ai-sdk/core": "^2.6.0",
"@sap-ai-sdk/core": "^2.7.0",
"@sap-cloud-sdk/connectivity": "^4.4.0",
"@sap-cloud-sdk/util": "^4.4.0"
}
},
"node_modules/@sap-ai-sdk/core": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/core/-/core-2.6.0.tgz",
"integrity": "sha512-HgB2xtjx6iFwAq+a5cEH6rss4sBXAkkW6Vas07OxoZihc7BijjNFm+H19rkEAhBYQmkA415zJG3imHd6/slooQ==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/core/-/core-2.7.0.tgz",
"integrity": "sha512-NSuCrEFinMR8/EYKJcACh71a1kj1H3pNfpnJH1R4Z+cxnlr0CtiFDzz9hKwsXFtf+ixO6WnIf7xMVmXkoeGNSQ==",
"license": "Apache-2.0",
"dependencies": {
"@sap-cloud-sdk/connectivity": "^4.4.0",
@@ -6137,25 +6159,25 @@
}
},
"node_modules/@sap-ai-sdk/orchestration": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/orchestration/-/orchestration-2.6.0.tgz",
"integrity": "sha512-FGBZ0oiRbbZdL9UUytsJ7QBiVUmrg4eD/i61aHVeBGvaCvsgSlx7nJM07CAT5JHhJC8ss97+EYPXXzVNPATcyQ==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/orchestration/-/orchestration-2.7.0.tgz",
"integrity": "sha512-/aCRwb3o5yJu5/8jCDVatMGBIxPO5rKtr0sT+zAl5w3hD/PqAqbFZXNztQ263xK1ooW8YAFite8PpXJgdjnFZg==",
"license": "Apache-2.0",
"dependencies": {
"@sap-ai-sdk/ai-api": "^2.6.0",
"@sap-ai-sdk/core": "^2.6.0",
"@sap-ai-sdk/prompt-registry": "^2.6.0",
"@sap-ai-sdk/ai-api": "^2.7.0",
"@sap-ai-sdk/core": "^2.7.0",
"@sap-ai-sdk/prompt-registry": "^2.7.0",
"@sap-cloud-sdk/util": "^4.4.0",
"yaml": "^2.8.2"
}
},
"node_modules/@sap-ai-sdk/prompt-registry": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/prompt-registry/-/prompt-registry-2.6.0.tgz",
"integrity": "sha512-vSyBCx437Cba3AsBHf1G0KCldv4dPi60kNgp2rpF/kDZjf3Rga3VwZ6OnETaKtbJJbM8G8itR0USHgviMU8UUg==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/prompt-registry/-/prompt-registry-2.7.0.tgz",
"integrity": "sha512-hbfb75CD67dgKl/hLLCXdzhsR80FHFqKCpVUjiULs3KW1Rauxc+i6ZKNRcc+uzdb8kgsdhKizFFCtsz90ay44g==",
"license": "Apache-2.0",
"dependencies": {
"@sap-ai-sdk/core": "^2.6.0",
"@sap-ai-sdk/core": "^2.7.0",
"zod": "^4.3.6"
}
},
@@ -7783,7 +7805,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.32.tgz",
"integrity": "sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -7846,7 +7867,6 @@
"integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -8781,7 +8801,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -9746,7 +9765,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -10288,6 +10306,10 @@
"integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==",
"license": "MIT"
},
"node_modules/claude-dev": {
"resolved": "",
"link": true
},
"node_modules/clean-stack": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
@@ -11145,8 +11167,7 @@
"version": "0.0.1367902",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz",
"integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==",
"license": "BSD-3-Clause",
"peer": true
"license": "BSD-3-Clause"
},
"node_modules/diff": {
"version": "5.2.2",
@@ -12163,7 +12184,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -13471,7 +13491,6 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz",
"integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -13750,7 +13769,6 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.7.tgz",
"integrity": "sha512-QHyxhNF5VonF5cRmdAJD/UPucB9nRx3FozWMjQrDGfBxfAL9lpyu72/MlFPgloS1TMTGsOt7YN6dTPPA6mh0Aw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -15004,7 +15022,6 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"license": "MIT",
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@@ -15354,7 +15371,6 @@
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
"license": "MPL-2.0",
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
@@ -19091,7 +19107,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -21799,7 +21814,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -22121,7 +22135,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -22203,6 +22216,7 @@
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22219,6 +22233,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22235,6 +22250,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22251,6 +22267,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22267,6 +22284,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22283,6 +22301,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22299,6 +22318,7 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22315,6 +22335,7 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22331,6 +22352,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22347,6 +22369,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22363,6 +22386,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22379,6 +22403,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22395,6 +22420,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22411,6 +22437,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22427,6 +22454,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22443,6 +22471,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22459,6 +22488,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22475,6 +22505,7 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22491,6 +22522,7 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22507,6 +22539,7 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22523,6 +22556,7 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22539,6 +22573,7 @@
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22555,6 +22590,7 @@
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22571,6 +22607,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22587,6 +22624,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22603,6 +22641,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22658,6 +22697,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
@@ -23592,7 +23632,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+4 -3
View File
@@ -2,9 +2,10 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.63.0",
"version": "3.66.0",
"icon": "assets/icons/icon.png",
"workspaces": [
".",
"cli"
],
"engines": {
@@ -535,8 +536,8 @@
"@opentelemetry/sdk-trace-node": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.37.0",
"@playwright/test": "^1.55.1",
"@sap-ai-sdk/ai-api": "^2.1.0",
"@sap-ai-sdk/orchestration": "^2.1.0",
"@sap-ai-sdk/ai-api": "^2.7.0",
"@sap-ai-sdk/orchestration": "^2.7.0",
"@sap-cloud-sdk/connectivity": "^4.2.0",
"@streamparser/json": "^0.0.22",
"@tailwindcss/vite": "^4.1.14",
+2 -7
View File
@@ -104,15 +104,13 @@ message Secrets {
optional string oca_refresh_token = 42;
optional string mcp_o_auth_secrets = 43;
optional string cline_api_key = 44;
optional string openai_codex_oauth_credentials = 46;
optional string openai_codex_oauth_credentials = 47;
}
// NOTE: Add new fields under API_HANDLER_SETTINGS_FIELDS or USER_SETTINGS_FIELDS
// in src/shared/storage/state-keys.ts and use the scripts/generate-state-proto.mjs
// script to regenerate this list.
message Settings {
reserved 146; // was openai_reasoning_effort (moved to mode-scoped reasoning effort)
optional string lite_llm_base_url = 1;
optional bool lite_llm_use_prompt_cache = 2;
optional string anthropic_base_url = 4;
@@ -251,7 +249,6 @@ message Settings {
optional string default_terminal_profile = 137;
optional int32 terminal_output_line_limit = 138;
optional int32 max_consecutive_mistakes = 139;
optional int32 subagent_terminal_output_line_limit = 140;
optional bool strict_plan_mode_enabled = 141;
optional bool yolo_mode_toggled = 142;
optional bool use_auto_condense = 143;
@@ -261,7 +258,6 @@ message Settings {
optional DictationSettings dictation_settings = 148;
optional FocusChainSettings focus_chain_settings = 149;
optional string custom_prompt = 150;
optional double auto_condense_threshold = 151;
optional bool subagents_enabled = 153;
optional bool enable_parallel_tool_calling = 154;
optional bool background_edit_enabled = 155;
@@ -282,8 +278,8 @@ message Settings {
optional int32 open_telemetry_log_max_queue_size = 171;
optional bool worktrees_enabled = 172;
optional bool auto_approve_all_toggled = 174;
map<string, string> open_ai_headers = 175;
optional bool double_check_completion_enabled = 176;
map<string, string> open_ai_headers = 177;
}
message DictationSettings {
@@ -415,7 +411,6 @@ message UpdateSettingsRequest {
optional string default_terminal_profile = 21;
optional bool yolo_mode_toggled = 22;
optional DictationSettings dictation_settings = 23;
optional double auto_condense_threshold = 24;
optional bool multi_root_enabled = 25;
optional string vscode_terminal_execution_mode = 27;
optional int32 max_consecutive_mistakes = 28;
+5 -6
View File
@@ -30,7 +30,7 @@ function toProtoFieldName(str) {
const INT64_FIELDS = new Set(["planModeThinkingBudgetTokens", "actModeThinkingBudgetTokens"])
// Fields that should use double instead of int32
const DOUBLE_FIELDS = new Set(["autoCondenseThreshold"])
const DOUBLE_FIELDS = new Set()
/**
* Infer proto type from TypeScript type expression
@@ -254,7 +254,7 @@ function parseProtoMessageFieldNumbers(protoContent, messageName) {
for (const fieldMatch of matches) {
const snakeName = fieldMatch[1]
const fieldNum = parseInt(fieldMatch[2], 10)
const fieldNum = Number.parseInt(fieldMatch[2], 10)
const camelName = snakeToCamel(snakeName)
fieldNumbers[camelName] = fieldNum
}
@@ -354,11 +354,10 @@ function replaceMessage(protoContent, messageName, newMessageContent) {
if (messageRegex.test(protoContent)) {
return protoContent.replace(messageRegex, newMessageContent)
} else {
// Message doesn't exist, append before the first message or at end
console.warn(`Warning: ${messageName} message not found in proto file, appending`)
return protoContent + "\n\n" + newMessageContent
}
// Message doesn't exist, append before the first message or at end
console.warn(`Warning: ${messageName} message not found in proto file, appending`)
return protoContent + "\n\n" + newMessageContent
}
async function main() {
+136
View File
@@ -56,6 +56,7 @@ const log = {
const config = {
// The name and display name for the nightly version
nightlyName: "cline-nightly",
originalName: "claude-dev",
nightlyDisplayName: "Cline (Nightly)",
projectRoot: path.join(__dirname, ".."),
get packageJsonPath() {
@@ -70,6 +71,15 @@ const config = {
get vsixPath() {
return path.join(this.distDir, "cline-nightly.vsix")
},
get nodeModulesPath() {
return path.join(this.projectRoot, "node_modules")
},
get originalWorkspaceLinkPath() {
return path.join(this.nodeModulesPath, this.originalName)
},
get nightlyWorkspaceLinkPath() {
return path.join(this.nodeModulesPath, this.nightlyName)
},
}
// Utility class for managing the publish process
@@ -77,6 +87,31 @@ class NightlyPublisher {
constructor() {
this.originalPackageJson = null
this.hasBackup = false
this.didRenameWorkspaceLink = false
this.didCreateNightlyWorkspaceLink = false
}
/**
* Resolve symlink target to an absolute path.
*/
resolveSymlinkTarget(linkPath) {
const target = fs.readlinkSync(linkPath)
return path.resolve(path.dirname(linkPath), target)
}
/**
* Validate that a path is the expected workspace self-link to project root.
*/
isExpectedWorkspaceSelfLink(linkPath) {
try {
if (!fs.lstatSync(linkPath).isSymbolicLink()) {
return false
}
return this.resolveSymlinkTarget(linkPath) === path.resolve(config.projectRoot)
} catch {
return false
}
}
/**
@@ -145,6 +180,98 @@ class NightlyPublisher {
}
}
/**
* Keep workspace self-link consistent with package name during nightly packaging.
*
* The repo root is a workspace package ("."). When npm installs dependencies,
* it creates a self-link at node_modules/<package-name>. Nightly packaging
* changes package.json name from "claude-dev" to "cline-nightly". If we don't
* align this link, vsce's dependency detection (`npm list --production`) fails
* with ELSPROBLEMS (missing cline-nightly + extraneous claude-dev).
*/
reconcileWorkspaceSelfLinkForNightly() {
const originalPath = config.originalWorkspaceLinkPath
const nightlyPath = config.nightlyWorkspaceLinkPath
if (!fs.existsSync(config.nodeModulesPath)) {
log.warn("node_modules not found, skipping workspace self-link reconciliation")
return
}
if (fs.existsSync(nightlyPath)) {
if (!this.isExpectedWorkspaceSelfLink(nightlyPath)) {
throw new Error(
`Refusing to continue: unexpected path at ${nightlyPath}. Expected a workspace symlink to ${config.projectRoot}`,
)
}
log.info("Nightly workspace self-link already exists")
return
}
if (fs.existsSync(originalPath)) {
if (!this.isExpectedWorkspaceSelfLink(originalPath)) {
throw new Error(
`Refusing to continue: unexpected path at ${originalPath}. Expected a workspace symlink to ${config.projectRoot}`,
)
}
log.info(`Renaming workspace self-link: ${config.originalName} -> ${config.nightlyName}`)
fs.renameSync(originalPath, nightlyPath)
this.didRenameWorkspaceLink = true
return
}
// In some environments npm may not have created the workspace self-link yet.
// Create it explicitly so `npm list --production` can resolve the renamed
// package name during vsce dependency detection.
log.warn("Original workspace self-link not found, creating nightly workspace self-link")
fs.symlinkSync(config.projectRoot, nightlyPath, "dir")
if (!this.isExpectedWorkspaceSelfLink(nightlyPath)) {
throw new Error(`Failed to create expected workspace symlink at ${nightlyPath}`)
}
this.didCreateNightlyWorkspaceLink = true
}
/**
* Restore workspace self-link after packaging.
*/
restoreWorkspaceSelfLink() {
if (!this.didRenameWorkspaceLink && !this.didCreateNightlyWorkspaceLink) {
return
}
const originalPath = config.originalWorkspaceLinkPath
const nightlyPath = config.nightlyWorkspaceLinkPath
if (fs.existsSync(nightlyPath) && !fs.existsSync(originalPath)) {
if (this.didRenameWorkspaceLink) {
if (!this.isExpectedWorkspaceSelfLink(nightlyPath)) {
throw new Error(
`Refusing to restore: unexpected path at ${nightlyPath}. Expected a workspace symlink to ${config.projectRoot}`,
)
}
log.info(`Restoring workspace self-link: ${config.nightlyName} -> ${config.originalName}`)
fs.renameSync(nightlyPath, originalPath)
} else if (this.didCreateNightlyWorkspaceLink) {
if (!this.isExpectedWorkspaceSelfLink(nightlyPath)) {
throw new Error(
`Refusing to remove: unexpected path at ${nightlyPath}. Expected a workspace symlink to ${config.projectRoot}`,
)
}
log.info(`Removing temporary workspace self-link: ${config.nightlyName}`)
fs.unlinkSync(nightlyPath)
}
}
this.didRenameWorkspaceLink = false
this.didCreateNightlyWorkspaceLink = false
}
/**
* Generate new version with timestamp
* Format: major.minor.timestamp
@@ -300,6 +427,9 @@ class NightlyPublisher {
// Step 3: Update package.json
const newVersion = this.updatePackageJson()
// Step 3.5: Keep npm workspace self-link aligned with nightly package name
this.reconcileWorkspaceSelfLinkForNightly()
// Step 4: Package extension
this.packageExtension()
@@ -326,6 +456,9 @@ class NightlyPublisher {
log.error(`Publish failed: ${error.message}`)
process.exit(1)
} finally {
// Always restore workspace link first
this.restoreWorkspaceSelfLink()
// Always restore package.json
this.restorePackageJson()
}
@@ -336,17 +469,20 @@ class NightlyPublisher {
const publisher = new NightlyPublisher()
process.on("exit", () => {
publisher.restoreWorkspaceSelfLink()
publisher.restorePackageJson()
})
process.on("SIGINT", () => {
log.info("\nInterrupted, cleaning up...")
publisher.restoreWorkspaceSelfLink()
publisher.restorePackageJson()
process.exit(130)
})
process.on("SIGTERM", () => {
log.info("\nTerminated, cleaning up...")
publisher.restoreWorkspaceSelfLink()
publisher.restorePackageJson()
process.exit(143)
})
+12 -12
View File
@@ -1,15 +1,15 @@
import * as vscode from "vscode"
import type * as vscode from "vscode"
import { WebviewProvider } from "./core/webview"
import "./utils/path" // necessary to have access to String.prototype.toPosix
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/shared/services/Logger"
import type { StorageContext } from "@/shared/storage/storage-context"
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
import { clearOnboardingModelsCache } from "./core/controller/models/getClineOnboardingModels"
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
import { HookProcessRegistry } from "./core/hooks/HookProcessRegistry"
import { StateManager } from "./core/storage/StateManager"
import { openAiCodexOAuthManager } from "./integrations/openai-codex/oauth"
import { ExtensionRegistryInfo } from "./registry"
import { audioRecordingService } from "./services/dictation/AudioRecordingService"
import { ErrorService } from "./services/error"
@@ -25,6 +25,8 @@ import { getBlobStoreSettingsFromEnv } from "./shared/services/worker/worker"
import { getLatestAnnouncementId } from "./utils/announcements"
import { arePathsEqual } from "./utils/path"
type SlimExtensionContext = Omit<vscode.ExtensionContext, "globalState" | "secrets" | "workspaceState">
/**
* Performs intialization for Cline that is common to all platforms.
*
@@ -32,7 +34,7 @@ import { arePathsEqual } from "./utils/path"
* @returns The webview provider
* @throws ClineConfigurationError if endpoints.json exists but is invalid
*/
export async function initialize(context: vscode.ExtensionContext): Promise<WebviewProvider> {
export async function initialize(storageContext: StorageContext): Promise<WebviewProvider> {
// Configure the shared Logging class to use HostProvider's output channels and debug logger
Logger.subscribe((msg: string) => HostProvider.get().logToChannel(msg)) // File system logging
Logger.subscribe((msg: string) => HostProvider.env.debugLog({ value: msg })) // Host debug logging
@@ -44,7 +46,7 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
await ClineEndpoint.initialize(HostProvider.get().extensionFsPath)
try {
await StateManager.initialize(context)
await StateManager.initialize(storageContext)
} catch (error) {
Logger.error("[Cline] CRITICAL: Failed to initialize StateManager:", error)
HostProvider.window.showMessage({
@@ -55,8 +57,6 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
// =============== External services ===============
await ErrorService.initialize()
// Initialize OpenAI Codex OAuth manager with extension context for secrets storage
openAiCodexOAuthManager.initialize(context)
// Initialize PostHog client provider (skip in self-hosted mode)
if (!ClineEndpoint.isSelfHosted()) {
PostHogClientProvider.getInstance()
@@ -67,7 +67,7 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
const stateManager = StateManager.get()
// Non-blocking announcement check and display
showVersionUpdateAnnouncement(context)
showVersionUpdateAnnouncement(stateManager)
// Check if this workspace was opened from worktree quick launch
await checkWorktreeAutoOpen(stateManager)
@@ -78,24 +78,24 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
// Clean up old temp files in background (non-blocking) and start periodic cleanup every 24 hours
ClineTempManager.startPeriodicCleanup()
// Clean up orphaned file context warnings (startup cleanup)
FileContextTracker.cleanupOrphanedWarnings(context)
FileContextTracker.cleanupOrphanedWarnings(stateManager)
telemetryService.captureExtensionActivated()
return webview
}
async function showVersionUpdateAnnouncement(context: vscode.ExtensionContext) {
async function showVersionUpdateAnnouncement(stateManager: StateManager) {
// Version checking for autoupdate notification
const currentVersion = ExtensionRegistryInfo.version
const previousVersion = context.globalState.get<string>("clineVersion")
const previousVersion = stateManager.getGlobalStateKey("clineVersion")
// Perform post-update actions if necessary
try {
if (!previousVersion || currentVersion !== previousVersion) {
Logger.log(`Cline version changed: ${previousVersion} -> ${currentVersion}. First run or update detected.`)
// Check if there's a new announcement to show
const lastShownAnnouncementId = context.globalState.get<string>("lastShownAnnouncementId")
const lastShownAnnouncementId = stateManager.getGlobalStateKey("lastShownAnnouncementId")
const latestAnnouncementId = getLatestAnnouncementId()
if (lastShownAnnouncementId !== latestAnnouncementId) {
@@ -109,7 +109,7 @@ async function showVersionUpdateAnnouncement(context: vscode.ExtensionContext) {
})
}
// Always update the main version tracker for the next launch.
await context.globalState.update("clineVersion", currentVersion)
await stateManager.setGlobalState("clineVersion", currentVersion)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
-1
View File
@@ -66,7 +66,6 @@ export interface ApiProviderInfo {
model: ApiHandlerModel
mode: Mode
customPrompt?: string // "compact"
autoCondenseThreshold?: number // 0-1 range
}
export interface SingleCompletionHandler {
@@ -938,6 +938,19 @@ describe("AwsBedrockHandler", () => {
modelId.should.equal("jp.anthropic.claude-sonnet-4-5-20250929-v1:0")
})
it("should apply JP cross-region prefix for sonnet 4.6", async () => {
const jpOptions: AwsBedrockHandlerOptions = {
...mockOptions,
awsUseCrossRegionInference: true,
apiModelId: "anthropic.claude-sonnet-4-6",
awsRegion: "ap-northeast-1",
}
const jpHandler = new AwsBedrockHandler(jpOptions)
const modelId = await jpHandler.getModelId()
modelId.should.equal("jp.anthropic.claude-sonnet-4-6")
})
it("should apply global cross-region prefix for supported models", async () => {
const globalOptions: AwsBedrockHandlerOptions = {
...mockOptions,
@@ -276,6 +276,16 @@ describe("ClaudeCodeHandler", () => {
model.info.contextWindow.should.equal(1_000_000)
})
it("should support Sonnet 4.6 1m model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-sonnet-4-6[1m]",
})
const model = handler.getModel()
model.id.should.equal("claude-sonnet-4-6[1m]")
model.info.contextWindow.should.equal(1_000_000)
})
it("should return default model when not specified", () => {
const handler = new ClaudeCodeHandler({})
@@ -69,6 +69,7 @@ describe("SapAiCoreHandler", () => {
it("should support different Claude model variants", () => {
const modelVariants = [
"anthropic--claude-4.6-sonnet",
"anthropic--claude-4-sonnet",
"anthropic--claude-4-opus",
"anthropic--claude-3.7-sonnet",
+3 -1
View File
@@ -127,9 +127,11 @@ interface ProviderChainOptions {
profile?: string
}
// a special jp inference profile was created for opus 4.6, sonnet 4.5 & haiku 4.5
// a special jp inference profile was created for sonnet 4.6, opus 4.6, sonnet 4.5 & haiku 4.5
// https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html
const JP_SUPPORTED_CRIS_MODELS = [
"anthropic.claude-sonnet-4-6",
"anthropic.claude-sonnet-4-6:1m",
"anthropic.claude-opus-4-6-v1",
"anthropic.claude-opus-4-6-v1:1m",
"anthropic.claude-sonnet-4-5-20250929-v1:0",
-2
View File
@@ -249,9 +249,7 @@ export class CerebrasHandler implements ApiHandler {
case "qwen-3-235b-a22b-instruct-2507":
case "qwen-3-235b-a22b-thinking-2507":
return { requestsPerMinute: 30, tokensPerMinute: 60_000 }
case "llama-3.3-70b":
case "gpt-oss-120b":
case "qwen-3-32b":
return { requestsPerMinute: 30, tokensPerMinute: 64_000 }
default:
// Default rate limits for unknown models
+4 -2
View File
@@ -30,6 +30,8 @@ interface ClineHandlerOptions extends CommonApiHandlerOptions {
clineApiKey?: string
}
const CLINE_FREE_MODELS = ["minimax/minimax-m2.5", "kwaipilot/kat-coder-pro", "z-ai/glm-5"]
export class ClineHandler implements ApiHandler {
private options: ClineHandlerOptions
private clineAccountService = ClineAccountService.getInstance()
@@ -198,7 +200,7 @@ export class ClineHandler implements ApiHandler {
// @ts-expect-error-next-line
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
const modelId = this.getModel().id
const isFreeModel = ["kwaipilot/kat-coder-pro", "minimax/minimax-m2.5", "z-ai/glm-5"].includes(modelId)
const isFreeModel = CLINE_FREE_MODELS.includes(modelId)
if (isFreeModel) {
totalCost = 0
@@ -252,7 +254,7 @@ export class ClineHandler implements ApiHandler {
const generation = response.data
let totalCost = generation?.total_cost || 0
const modelId = this.getModel().id
const isFreeModel = ["kwaipilot/kat-coder-pro", "minimax/minimax-m2.5", "z-ai/glm-5"].includes(modelId)
const isFreeModel = CLINE_FREE_MODELS.includes(modelId)
if (isFreeModel) {
totalCost = 0
+2
View File
@@ -78,6 +78,8 @@ export class RequestyHandler implements ApiHandler {
: { thinking: { type: "disabled" } }
const thinkingArgs =
model.id.includes("claude-opus-4-6") ||
model.id.includes("claude-sonnet-4-6") ||
model.id.includes("claude-4.6-sonnet") ||
model.id.includes("claude-3-7-sonnet") ||
model.id.includes("claude-sonnet-4") ||
model.id.includes("claude-opus-4") ||
+3
View File
@@ -610,6 +610,7 @@ export class SapAiCoreHandler implements ApiHandler {
const anthropicModels = [
"anthropic--claude-4.5-haiku",
"anthropic--claude-4.5-opus",
"anthropic--claude-4.6-sonnet",
"anthropic--claude-4.5-sonnet",
"anthropic--claude-4-sonnet",
"anthropic--claude-4-opus",
@@ -659,6 +660,7 @@ export class SapAiCoreHandler implements ApiHandler {
if (
model.id === "anthropic--claude-4.5-opus" ||
model.id === "anthropic--claude-4.6-sonnet" ||
model.id === "anthropic--claude-4.5-sonnet" ||
model.id === "anthropic--claude-4.5-haiku" ||
model.id === "anthropic--claude-4-sonnet" ||
@@ -791,6 +793,7 @@ export class SapAiCoreHandler implements ApiHandler {
yield* this.streamCompletionGPT(response.data, model)
} else if (
model.id === "anthropic--claude-4.5-opus" ||
model.id === "anthropic--claude-4.6-sonnet" ||
model.id === "anthropic--claude-4.5-sonnet" ||
model.id === "anthropic--claude-4.5-haiku" ||
model.id === "anthropic--claude-4-sonnet" ||
@@ -5,6 +5,7 @@ import {
OPENROUTER_PROVIDER_PREFERENCES,
openRouterClaudeOpus461mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet461mModelId,
openRouterClaudeSonnet451mModelId,
} from "@shared/api"
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
@@ -34,6 +35,7 @@ export async function createOpenRouterStream(
const isClaude1m =
model.id === openRouterClaudeSonnet41mModelId ||
model.id === openRouterClaudeSonnet451mModelId ||
model.id === openRouterClaudeSonnet461mModelId ||
model.id === openRouterClaudeOpus461mModelId
if (isClaude1m) {
// remove the custom :1m suffix, to create the model id openrouter API expects
@@ -50,6 +52,8 @@ export async function createOpenRouterStream(
case "anthropic/claude-opus-4.6":
case "anthropic/claude-haiku-4.5":
case "anthropic/claude-4.5-haiku":
case "anthropic/claude-sonnet-4.6":
case "anthropic/claude-4.6-sonnet":
case "anthropic/claude-sonnet-4.5":
case "anthropic/claude-4.5-sonnet": // OpenRouter accidentally included this in model list for a brief moment, and users may be using this model id. And to support prompt caching, we need to add it here.
case "anthropic/claude-sonnet-4":
@@ -118,6 +122,8 @@ export async function createOpenRouterStream(
case "anthropic/claude-opus-4.6":
case "anthropic/claude-haiku-4.5":
case "anthropic/claude-4.5-haiku":
case "anthropic/claude-sonnet-4.6":
case "anthropic/claude-4.6-sonnet":
case "anthropic/claude-sonnet-4.5":
case "anthropic/claude-4.5-sonnet":
case "anthropic/claude-sonnet-4":
@@ -166,6 +172,8 @@ export async function createOpenRouterStream(
case "anthropic/claude-opus-4.6":
case "anthropic/claude-haiku-4.5":
case "anthropic/claude-4.5-haiku":
case "anthropic/claude-sonnet-4.6":
case "anthropic/claude-4.6-sonnet":
case "anthropic/claude-sonnet-4.5":
case "anthropic/claude-4.5-sonnet":
case "anthropic/claude-sonnet-4":
@@ -4,6 +4,7 @@ import {
ModelInfo,
openRouterClaudeOpus461mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet461mModelId,
openRouterClaudeSonnet451mModelId,
} from "@shared/api"
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
@@ -32,6 +33,7 @@ export async function createVercelAIGatewayStream(
const isClaude1m =
model.id === openRouterClaudeSonnet41mModelId ||
model.id === openRouterClaudeSonnet451mModelId ||
model.id === openRouterClaudeSonnet461mModelId ||
model.id === openRouterClaudeOpus461mModelId
if (isClaude1m) {
// remove the custom :1m suffix, to create the model id the API expects
@@ -154,15 +154,21 @@ export class ContextManager {
thresholdPercentage?: number,
): boolean {
if (previousApiReqIndex >= 0) {
const previousRequest = clineMessages[previousApiReqIndex]
if (previousRequest && previousRequest.text) {
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
const previousRequestText = clineMessages[previousApiReqIndex]?.text
if (previousRequestText) {
try {
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequestText)
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
const roundedThreshold = thresholdPercentage ? Math.floor(contextWindow * thresholdPercentage) : maxAllowedSize
const thresholdTokens = Math.min(roundedThreshold, maxAllowedSize)
return totalTokens >= thresholdTokens
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
const roundedThreshold = thresholdPercentage
? Math.floor(contextWindow * thresholdPercentage)
: maxAllowedSize
const thresholdTokens = Math.min(roundedThreshold, maxAllowedSize)
return totalTokens >= thresholdTokens
} catch {
return false
}
}
}
return false
@@ -195,10 +201,10 @@ export class ContextManager {
}
if (targetIndex >= 0) {
const targetRequest = clineMessages[targetIndex]
if (targetRequest && targetRequest.text) {
const targetRequestText = clineMessages[targetIndex]?.text
if (targetRequestText) {
try {
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(targetRequest.text)
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(targetRequestText)
const tokensUsed = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
const { contextWindow } = getContextWindowInfo(api)
@@ -232,10 +238,10 @@ export class ContextManager {
if (!useAutoCondense) {
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
if (previousApiReqIndex >= 0) {
const previousRequest = clineMessages[previousApiReqIndex]
if (previousRequest && previousRequest.text) {
const timestamp = previousRequest.ts
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
const previousRequestText = clineMessages[previousApiReqIndex]?.text
if (previousRequestText) {
const timestamp = clineMessages[previousApiReqIndex].ts
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequestText)
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
const { maxAllowedSize } = getContextWindowInfo(api)
@@ -846,9 +852,8 @@ export class ContextManager {
}
// otherwise there are still file reads here we can overwrite, so still need to process this text chunk
// to do so we need to keep track of which files we've already replaced so we don't replace them again
else {
thisExistingFileReads = blockUpdates[blockUpdates.length - 1][3][0]
}
thisExistingFileReads = blockUpdates[blockUpdates.length - 1][3][0]
}
} else {
// for all other cases we can assume that we dont need to check this again
@@ -1,7 +1,29 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ClineMessage } from "@shared/ExtensionMessage"
import { expect } from "chai"
import { ContextManager } from "../ContextManager"
// Minimal mock for ApiHandler — only getModel().info.contextWindow is used by shouldCompactContextWindow
function createMockApi(contextWindow: number) {
return {
getModel: () => ({ id: "test-model", info: { contextWindow } }),
} as any
}
function createApiReqMessage(tokens: {
tokensIn?: number
tokensOut?: number
cacheWrites?: number
cacheReads?: number
}): ClineMessage {
return {
ts: Date.now(),
type: "say",
say: "api_req_started",
text: JSON.stringify(tokens),
}
}
describe("ContextManager", () => {
function createMessages(count: number): Anthropic.Messages.MessageParam[] {
const messages: Anthropic.Messages.MessageParam[] = []
@@ -381,4 +403,93 @@ describe("ContextManager", () => {
expect((content[0] as Anthropic.Messages.TextBlockParam).text).to.equal("Additional user text")
})
})
describe("shouldCompactContextWindow", () => {
let contextManager: ContextManager
beforeEach(() => {
contextManager = new ContextManager()
})
it("does not compact at 33K tokens with default 0.75 threshold on 200K context", () => {
const api = createMockApi(200_000)
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 30_000, tokensOut: 3_000 })]
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
expect(result).to.equal(false)
})
it("compacts when tokens exceed 0.75 threshold on 200K context", () => {
const api = createMockApi(200_000)
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 140_000, tokensOut: 15_000 })]
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
expect(result).to.equal(true)
})
it("compacts at only 10K tokens when threshold is accidentally set to 0.05", () => {
const contextWindow = 200_000
const accidentalThreshold = 0.05
// floor(200000 * 0.05) = 10000 — this is the bug case from PR #9348.
// Accidental clicks on the progress bar set threshold to ~5%, triggering
// compaction at 10K tokens instead of the intended 150K (0.75 * 200K).
const compactionTriggersAt = Math.floor(contextWindow * accidentalThreshold) // 10,000
const totalTokens = compactionTriggersAt + 500 // 10,500 — just above the trigger
const api = createMockApi(contextWindow)
const tokensIn = totalTokens - 1_500
const tokensOut = 1_500
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn, tokensOut })]
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, accidentalThreshold)
expect(result).to.equal(true)
})
it("falls back to maxAllowedSize when threshold is undefined", () => {
const api = createMockApi(200_000)
// 155K tokens — above 0.75 threshold (150K) but below maxAllowedSize (160K)
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 150_000, tokensOut: 5_000 })]
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, undefined)
// undefined → uses maxAllowedSize (160K), so 155K < 160K → false
expect(result).to.equal(false)
})
it("falls back to maxAllowedSize when threshold is 0", () => {
const api = createMockApi(200_000)
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 150_000, tokensOut: 5_000 })]
// 0 is falsy, so ternary falls back to maxAllowedSize (160K)
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0)
expect(result).to.equal(false)
})
it("includes cacheWrites and cacheReads in total token count", () => {
const api = createMockApi(200_000)
// Low direct tokens but high cache reads push total over threshold
const clineMessages: ClineMessage[] = [
createApiReqMessage({ tokensIn: 5_000, tokensOut: 500, cacheWrites: 0, cacheReads: 150_000 }),
]
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
expect(result).to.equal(true)
})
it("returns false when previousApiReqIndex is negative", () => {
const api = createMockApi(200_000)
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 200_000 })]
const result = contextManager.shouldCompactContextWindow(clineMessages, api, -1, 0.75)
expect(result).to.equal(false)
})
it("threshold is capped at maxAllowedSize even when percentage is very high", () => {
const api = createMockApi(200_000)
// threshold of 1.0 → floor(200000 * 1.0) = 200000, but min(200000, 160000) = 160000
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 165_000 })]
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 1.0)
expect(result).to.equal(true)
})
})
})
@@ -2,8 +2,8 @@ import { getTaskMetadata, readTaskHistoryFromState, saveTaskMetadata } from "@co
import type { ClineMessage } from "@shared/ExtensionMessage"
import chokidar, { FSWatcher } from "chokidar"
import * as path from "path"
import * as vscode from "vscode"
import { Controller } from "@/core/controller"
import { StateManager } from "@/core/storage/StateManager"
import { Logger } from "@/shared/services/Logger"
import { getCwd } from "@/utils/path"
import type { FileMetadataEntry } from "./ContextTrackerTypes"
@@ -250,6 +250,8 @@ export class FileContextTracker {
async retrievePendingFileContextWarning(): Promise<string[] | undefined> {
try {
const key = `pendingFileContextWarning_${this.taskId}`
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
const files = this.controller.stateManager.getWorkspaceStateKey(key as any) as string[]
return files
} catch (error) {
@@ -265,6 +267,8 @@ export class FileContextTracker {
try {
const files = await this.retrievePendingFileContextWarning()
if (files) {
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
this.controller.stateManager.setWorkspaceState(`pendingFileContextWarning_${this.taskId}` as any, undefined)
return files
}
@@ -278,12 +282,12 @@ export class FileContextTracker {
* Static method to clean up orphaned pending file context warnings at startup
* This removes warnings for tasks that may no longer exist
*/
static async cleanupOrphanedWarnings(context: vscode.ExtensionContext): Promise<void> {
static async cleanupOrphanedWarnings(stateManager: StateManager): Promise<void> {
const startTime = Date.now()
try {
const taskHistory = await readTaskHistoryFromState()
const existingTaskIds = new Set(taskHistory.map((task) => task.id))
const allStateKeys = context.workspaceState.keys()
const allStateKeys = Object.keys(stateManager.getAllWorkspaceStateEntries())
const pendingWarningKeys = allStateKeys.filter((key) => key.startsWith("pendingFileContextWarning_"))
const orphanedPendingContextTasks: string[] = []
@@ -296,8 +300,9 @@ export class FileContextTracker {
if (orphanedPendingContextTasks.length > 0) {
for (const key of orphanedPendingContextTasks) {
// eslint-disable-next-line eslint-rules/no-direct-vscode-state-api
await context.workspaceState.update(key, undefined)
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
await stateManager.setWorkspaceState(key as any, undefined)
}
}
+5 -5
View File
@@ -13,7 +13,7 @@ import type { ChatContent } from "@shared/ChatContent"
import type { ExtensionState, Platform } from "@shared/ExtensionMessage"
import type { HistoryItem } from "@shared/HistoryItem"
import type { McpMarketplaceCatalog, McpMarketplaceItem } from "@shared/mcp"
import type { Settings } from "@shared/storage/state-keys"
import { type Settings } from "@shared/storage/state-keys"
import type { Mode } from "@shared/storage/types"
import type { TelemetrySetting } from "@shared/TelemetrySetting"
import type { UserInfo } from "@shared/UserInfo"
@@ -23,7 +23,6 @@ import fs from "fs/promises"
import open from "open"
import pWaitFor from "p-wait-for"
import * as path from "path"
import type * as vscode from "vscode"
import { ClineEnv } from "@/config"
import type { FolderLockWithRetryResult } from "@/core/locks/types"
import { HostProvider } from "@/hosts/host-provider"
@@ -35,6 +34,7 @@ import { BannerService } from "@/services/banner/BannerService"
import { featureFlagsService } from "@/services/feature-flags"
import { getDistinctId } from "@/services/logging/distinctId"
import { telemetryService } from "@/services/telemetry"
import { ClineExtensionContext } from "@/shared/cline"
import { getAxiosSettings } from "@/shared/net"
import { ShowMessageType } from "@/shared/proto/host/window"
import { Logger } from "@/shared/services/Logger"
@@ -117,7 +117,7 @@ export class Controller {
this.remoteConfigTimer = setInterval(() => fetchRemoteConfig(this), 3600000) // 1 hour
}
constructor(readonly context: vscode.ExtensionContext) {
constructor(readonly context: ClineExtensionContext) {
Session.reset() // Reset session on controller initialization
PromptRegistry.getInstance() // Ensure prompts and tools are registered
this.stateManager = StateManager.get()
@@ -894,7 +894,6 @@ export class Controller {
const localCursorRulesToggles = this.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
const localAgentsRulesToggles = this.stateManager.getWorkspaceStateKey("localAgentsRulesToggles")
const workflowToggles = this.stateManager.getWorkspaceStateKey("workflowToggles")
const autoCondenseThreshold = this.stateManager.getGlobalSettingsKey("autoCondenseThreshold")
const currentTaskItem = this.task?.taskId ? (taskHistory || []).find((item) => item.id === this.task?.taskId) : undefined
// Spread to create new array reference - React needs this to detect changes in useEffect dependencies
@@ -914,6 +913,7 @@ export class Controller {
const clineConfig = ClineEnv.config()
const environment = clineConfig.environment
const banners = BannerService.get().getActiveBanners() ?? []
const welcomeBanners = BannerService.get().getWelcomeBanners() ?? []
// Check OpenAI Codex authentication status
const { openAiCodexOAuthManager } = await import("@/integrations/openai-codex/oauth")
@@ -976,7 +976,6 @@ export class Controller {
taskHistory: processedTaskHistory,
shouldShowAnnouncement,
favoritedModelIds,
autoCondenseThreshold,
backgroundCommandRunning: this.backgroundCommandRunning,
backgroundCommandTaskId: this.backgroundCommandTaskId,
// NEW: Add workspace information
@@ -1007,6 +1006,7 @@ export class Controller {
optOutOfRemoteConfig: this.stateManager.getGlobalSettingsKey("optOutOfRemoteConfig"),
doubleCheckCompletionEnabled,
banners,
welcomeBanners,
openAiCodexIsAuthenticated,
}
}
@@ -12,21 +12,34 @@ export function getClineOnboardingModels(): OnboardingModelGroup {
}
const remoteOverrides = featureFlagsService.getOnboardingOverrides()
const models = new Map<string, OnboardingModel>(CLINE_ONBOARDING_MODELS.map((model) => [model.id, model]))
const models = [...CLINE_ONBOARDING_MODELS]
// Apply remote overrides if available
if (remoteOverrides) {
for (const [id, override] of Object.entries(remoteOverrides) as [string, OnboardingModelOverride][]) {
if (override.hidden) {
models.delete(id)
for (let i = models.length - 1; i >= 0; i--) {
if (models[i].id === id) {
models.splice(i, 1)
}
}
} else {
const baseModel = models.get(id)
models.set(id, mergeModelWithOverride(baseModel, override))
let found = false
for (let i = 0; i < models.length; i++) {
if (models[i].id === id) {
models[i] = mergeModelWithOverride(models[i], override)
found = true
}
}
if (!found) {
models.push(mergeModelWithOverride(undefined, override))
}
}
}
}
cached = { models: Array.from(models.values()) }
cached = { models }
return cached
}
@@ -11,6 +11,7 @@ import {
CLAUDE_SONNET_1M_TIERS,
openRouterClaudeOpus461mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet461mModelId,
openRouterClaudeSonnet451mModelId,
} from "@/shared/api"
import { getAxiosSettings } from "@/shared/net"
@@ -144,6 +145,8 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
}
switch (rawModel.id) {
case "anthropic/claude-sonnet-4.6":
case "anthropic/claude-4.6-sonnet":
case "anthropic/claude-sonnet-4.5":
case "anthropic/claude-4.5-sonnet":
case "anthropic/claude-sonnet-4":
@@ -264,14 +267,28 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
models[rawModel.id] = modelInfo
// add custom :1m model variant for sonnet
if (rawModel.id === "anthropic/claude-sonnet-4" || rawModel.id === "anthropic/claude-sonnet-4.5") {
if (
rawModel.id === "anthropic/claude-sonnet-4" ||
rawModel.id === "anthropic/claude-sonnet-4.5" ||
rawModel.id === "anthropic/claude-4.5-sonnet" ||
rawModel.id === "anthropic/claude-sonnet-4.6" ||
rawModel.id === "anthropic/claude-4.6-sonnet"
) {
const claudeSonnet1mModelInfo = cloneDeep(modelInfo)
claudeSonnet1mModelInfo.contextWindow = 1_000_000 // limiting providers to those that support 1m context window
claudeSonnet1mModelInfo.tiers = CLAUDE_SONNET_1M_TIERS
// sonnet 4
models[openRouterClaudeSonnet41mModelId] = claudeSonnet1mModelInfo
if (rawModel.id === "anthropic/claude-sonnet-4") {
models[openRouterClaudeSonnet41mModelId] = claudeSonnet1mModelInfo
}
// sonnet 4.5
models[openRouterClaudeSonnet451mModelId] = claudeSonnet1mModelInfo
if (rawModel.id === "anthropic/claude-sonnet-4.5" || rawModel.id === "anthropic/claude-4.5-sonnet") {
models[openRouterClaudeSonnet451mModelId] = claudeSonnet1mModelInfo
}
// sonnet 4.6
if (rawModel.id === "anthropic/claude-sonnet-4.6" || rawModel.id === "anthropic/claude-4.6-sonnet") {
models[openRouterClaudeSonnet461mModelId] = claudeSonnet1mModelInfo
}
}
// add custom :1m model variant for opus 4.6
+2 -2
View File
@@ -20,13 +20,13 @@ export async function resetState(controller: Controller, request: ResetStateRequ
type: ShowMessageType.INFORMATION,
message: "Resetting global state...",
})
await resetGlobalState(controller)
await resetGlobalState()
} else {
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: "Resetting workspace state...",
})
await resetWorkspaceState(controller)
await resetWorkspaceState()
}
if (controller.task) {
@@ -301,11 +301,6 @@ export async function updateSettings(controller: Controller, request: UpdateSett
controller.stateManager.setGlobalState("backgroundEditEnabled", !!request.backgroundEditEnabled)
}
if (request.autoCondenseThreshold !== undefined) {
const threshold = Math.min(1, Math.max(0, request.autoCondenseThreshold)) // Clamp to 0-1 range
controller.stateManager.setGlobalState("autoCondenseThreshold", threshold)
}
if (request.multiRootEnabled !== undefined) {
controller.stateManager.setGlobalState("multiRootEnabled", !!request.multiRootEnabled)
}
@@ -0,0 +1,127 @@
import { describe, it } from "mocha"
import "should"
import { formatResponse } from "../responses"
describe("formatResponse.writeToFileMissingContentError", () => {
describe("first failure (tier 1)", () => {
it("should include the file path in the error message", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
result.should.containEql("src/index.ts")
})
it("should include the base error explanation", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
result.should.containEql("'content' parameter was empty")
result.should.containEql("output token limits")
})
it("should include helpful suggestions", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
result.should.containEql("Suggestions")
result.should.containEql("replace_in_file")
result.should.containEql("skeleton")
})
it("should include the tool use instructions reminder", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
result.should.containEql("Reminder: Instructions for Tool Use")
})
it("should mention breaking down the task into smaller steps", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
result.should.containEql("breaking down the task into smaller steps")
})
it("should suggest using replace_in_file for existing files", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
result.should.containEql("prefer replace_in_file to make targeted edits")
})
it("should not include CRITICAL language on first failure", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
result.should.not.containEql("CRITICAL")
})
it("should work with different file paths", () => {
const result = formatResponse.writeToFileMissingContentError("components/MyComponent.tsx", 1)
result.should.containEql("components/MyComponent.tsx")
})
})
describe("second failure (tier 2)", () => {
it("should indicate this is the 2nd failed attempt", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 2)
result.should.containEql("2nd failed attempt")
})
it("should strongly suggest alternative approaches", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 2)
result.should.containEql("must use a different strategy")
result.should.containEql("Recommended approaches")
})
it("should tell model not to retry full write again", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 2)
result.should.containEql("Do NOT attempt to write the full file content")
})
it("should not include CRITICAL language on second failure", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 2)
result.should.not.containEql("CRITICAL")
})
})
describe("third+ failure (tier 3)", () => {
it("should include CRITICAL language on third failure", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 3)
result.should.containEql("CRITICAL")
result.should.containEql("3 times in a row")
})
it("should tell model to NOT retry write_to_file", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 3)
result.should.containEql("do NOT retry write_to_file")
})
it("should include required action strategies", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 3)
result.should.containEql("Required action")
result.should.containEql("replace_in_file")
result.should.containEql("50-100 lines")
})
it("should show correct count for higher failure counts", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 5)
result.should.containEql("5 times in a row")
})
})
describe("context window awareness", () => {
it("should include context warning when usage exceeds 50%", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1, 60)
result.should.containEql("60% full")
result.should.containEql("MUST use a strategy that produces smaller outputs")
})
it("should not include context warning when usage is 50% or below", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1, 50)
result.should.not.containEql("% full")
})
it("should not include context warning when percent is undefined", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1, undefined)
result.should.not.containEql("% full")
})
it("should show high context window usage correctly", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1, 85)
result.should.containEql("85% full")
})
it("should include context warning in tier 3 messages", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 3, 75)
result.should.containEql("75% full")
result.should.containEql("CRITICAL")
})
})
})
+50
View File
@@ -4,6 +4,8 @@ import * as path from "path"
import { Mode } from "@/shared/storage/types"
import { ClineIgnoreController, LOCK_TEXT_SYMBOL } from "../ignore/ClineIgnoreController"
const CONTEXT_WINDOW_WARNING_THRESHOLD_PERCENT = 50
export const formatResponse = {
duplicateFileReadNotice: () =>
`[[NOTE] This file read has been removed to save space in the context window. Refer to the latest file read for the most up to date version of this file.]`,
@@ -46,6 +48,54 @@ Otherwise, if you have not completed the task and do not need additional informa
missingToolParameterError: (paramName: string) =>
`Missing value for required parameter '${paramName}'. Please retry with complete response.\n\n${toolUseInstructionsReminder}`,
/**
* Specialized error for write_to_file when the 'content' parameter is missing.
* Provides progressive guidance based on how many times this has happened consecutively,
* and includes token budget awareness to help the model understand output constraints.
*/
writeToFileMissingContentError: (relPath: string, consecutiveFailures: number, contextUsagePercent?: number): string => {
const baseError = `Failed to write to '${relPath}': The 'content' parameter was empty. This typically happens when the file content is too large to generate in a single response, or when output token limits are reached before the content parameter is fully written.`
const contextWarning =
contextUsagePercent !== undefined && contextUsagePercent > CONTEXT_WINDOW_WARNING_THRESHOLD_PERCENT
? `\n\nWarning: Context window is ${contextUsagePercent}% full. The remaining output budget may be insufficient for large file writes. You MUST use a strategy that produces smaller outputs.`
: ""
if (consecutiveFailures >= 3) {
// After 3+ failures, be very directive — stop trying write_to_file entirely
return (
`${baseError}${contextWarning}\n\n` +
`CRITICAL: You have failed to write this file ${consecutiveFailures} times in a row. You MUST change your approach — do NOT retry write_to_file for this file again.\n\n` +
`Required action — choose ONE of these strategies:\n` +
`1. **Create an empty file first, then use replace_in_file** to add content in small sections (recommended)\n` +
`2. **Break the file into multiple smaller files** if architecturally appropriate\n` +
`3. **Write a minimal skeleton** using write_to_file (just imports, class/function signatures, no implementations), then use replace_in_file to fill in each section one at a time\n\n` +
`Each replace_in_file call should add no more than 50-100 lines of content at a time.`
)
}
if (consecutiveFailures >= 2) {
// After 2 failures, strongly suggest alternative approaches
return (
`${baseError}${contextWarning}\n\n` +
`This is your ${consecutiveFailures}${consecutiveFailures === 2 ? "nd" : "rd"} failed attempt. The file content is likely too large to generate in one response. You must use a different strategy:\n\n` +
`Recommended approaches:\n` +
`1. **Use write_to_file with a minimal skeleton** (just the structure — imports, class/function signatures, no implementations), then use replace_in_file to fill in each section incrementally\n` +
`2. **Use replace_in_file with smaller chunks** — if the file already exists, make targeted edits instead of rewriting the entire file\n` +
`3. **Break the task into smaller steps** — write one function or section at a time\n\n` +
`Do NOT attempt to write the full file content in a single write_to_file call again.`
)
}
// First failure — provide helpful guidance
return (
`${baseError}${contextWarning}\n\n` +
`Suggestions:\n` +
`- If the file is large, try breaking down the task into smaller steps. Write a skeleton first, then fill in sections using replace_in_file.\n` +
`- If the file already exists, prefer replace_in_file to make targeted edits instead of rewriting the entire file.\n` +
`- Ensure the 'content' parameter contains the complete file content before closing the tool tag.\n\n` +
toolUseInstructionsReminder
)
},
invalidMcpToolArgumentError: (serverName: string, toolName: string) =>
`Invalid JSON argument used with ${serverName} for ${toolName}. Please retry with a properly formatted JSON argument.`,
+83 -88
View File
@@ -1,25 +1,24 @@
import { ApiConfiguration, ModelInfo } from "@shared/api"
import type { ApiConfiguration, ModelInfo } from "@shared/api"
import {
ApiHandlerSettingsKeys,
GlobalState,
GlobalStateAndSettings,
GlobalStateAndSettingsKey,
type GlobalState,
type GlobalStateAndSettings,
type GlobalStateAndSettingsKey,
isSecretKey,
isSettingsKey,
LocalState,
LocalStateKey,
RemoteConfigFields,
SecretKey,
type LocalState,
type LocalStateKey,
type RemoteConfigFields,
type SecretKey,
SecretKeys,
Secrets,
Settings,
SettingsKey,
type Secrets,
type Settings,
type SettingsKey,
} from "@shared/storage/state-keys"
import type { StorageContext } from "@shared/storage/storage-context"
import chokidar, { FSWatcher } from "chokidar"
import type { ExtensionContext } from "vscode"
import { initializeDistinctId } from "@/services/logging/distinctId"
import { Logger } from "@/shared/services/Logger"
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
import {
getTaskHistoryStateFilePath,
readTaskHistoryFromState,
@@ -29,7 +28,7 @@ import {
} from "./disk"
import { STATE_MANAGER_NOT_INITIALIZED } from "./error-messages"
import { filterAllowedRemoteConfigFields } from "./remote-config/utils"
import { readGlobalStateFromDisk, readSecretsFromDisk, readWorkspaceStateFromDisk } from "./utils/state-helpers"
import { readGlobalStateFromStorage, readSecretsFromStorage, readWorkspaceStateFromStorage } from "./utils/state-helpers"
export interface PersistenceErrorEvent {
error: Error
}
@@ -38,6 +37,9 @@ export interface PersistenceErrorEvent {
* In-memory state manager for fast state access.
* Provides immediate reads/writes with async disk persistence.
*
* All persistent storage is backed by file-based stores via StorageContext.
* This is shared across all platforms (VSCode, CLI, JetBrains).
*
* MULTI-INSTANCE BEHAVIOR:
* StateManager reads from disk ONLY during initialize(). After that, all reads come from
* the in-memory cache. Writes update both the cache and disk, but other running instances
@@ -60,7 +62,12 @@ export class StateManager {
private remoteConfigCache: Partial<RemoteConfigFields> = {} as RemoteConfigFields
private secretsCache: Secrets = {} as Secrets
private workspaceStateCache: LocalState = {} as LocalState
private context: ExtensionContext
/**
* File-backed storage context. All reads/writes to persistent state go through here.
* Do NOT access VSCode's ExtensionContext for storage use this instead.
*/
private storage: StorageContext
private isInitialized = false
// Cache TTL: 1 hour - long enough to prevent duplicate fetches, short enough to see new models
@@ -107,17 +114,16 @@ export class StateManager {
// Callback to sync external state changes with the UI client
onSyncExternalChange?: () => void | Promise<void>
private constructor(context: ExtensionContext) {
this.context = context
secretStorage.init(context.secrets)
private constructor(storage: StorageContext) {
this.storage = storage
}
/**
* Initialize the cache by loading data from disk
* Initialize the cache by loading data from the file-backed StorageContext.
*/
public static async initialize(context: ExtensionContext): Promise<StateManager> {
public static async initialize(storage: StorageContext): Promise<StateManager> {
if (!StateManager.instance) {
StateManager.instance = new StateManager(context)
StateManager.instance = new StateManager(storage)
}
if (StateManager.instance.isInitialized) {
@@ -125,11 +131,12 @@ export class StateManager {
}
try {
await initializeDistinctId(context)
// Load all extension state from disk
const globalState = await readGlobalStateFromDisk(context)
const secrets = await readSecretsFromDisk()
const workspaceState = await readWorkspaceStateFromDisk(context)
await initializeDistinctId(storage)
// Load all extension state from file-backed stores
const globalState = await readGlobalStateFromStorage(storage.globalState)
const secrets = readSecretsFromStorage(storage.secrets)
const workspaceState = readWorkspaceStateFromStorage(storage.workspaceState)
// Populate the cache with all extension state and secrets fields
// Use populate method to avoid triggering persistence during initialization
@@ -291,8 +298,6 @@ export class StateManager {
this.pendingTaskState.clear()
} catch (error) {
Logger.error("[StateManager] Failed to persist task settings before clearing:", error)
// If persistence fails, we just move on with clearing the in-memory state.
// clearTaskSettings realistically probably won't be called in the small window of time between task settings being set and their persistence anyways
}
}
@@ -606,8 +611,6 @@ export class StateManager {
throw new Error(STATE_MANAGER_NOT_INITIALIZED)
}
if (this.remoteConfigCache[key] !== undefined) {
// type casting here, TS cannot infer that the key will ONLY be one of Settings
return this.remoteConfigCache[key] as Settings[K]
}
if (this.taskStateCache[key] !== undefined) {
@@ -624,7 +627,6 @@ export class StateManager {
throw new Error(STATE_MANAGER_NOT_INITIALIZED)
}
if (this.remoteConfigCache[key] !== undefined) {
// type casting here, TS cannot infer that the key will ONLY be one of GlobalState
return this.remoteConfigCache[key] as GlobalState[K]
}
return this.globalStateCache[key]
@@ -655,11 +657,14 @@ export class StateManager {
* Used for error recovery when write operations fail
*/
async reInitialize(currentTaskId?: string): Promise<void> {
if (this.persistenceTimeout) {
await this.persistPendingState()
}
// Clear all cached data and pending state
this.dispose()
// Reinitialize from disk
await StateManager.initialize(this.context)
// Reinitialize from the same storage context
await StateManager.initialize(this.storage)
// If there's an active task, reload its settings
if (currentTaskId) {
@@ -765,21 +770,25 @@ export class StateManager {
}
/**
* Private method to batch persist global state keys with Promise.all
* Persist global state keys to the file-backed store.
* Uses setBatch for efficiency (single disk write).
*/
private async persistGlobalStateBatch(keys: Set<GlobalStateAndSettingsKey>): Promise<void> {
try {
await Promise.all(
Array.from(keys).map((key) => {
if (key === "taskHistory") {
// Route task history persistence to file, not VS Code globalState
return writeTaskHistoryToState(this.globalStateCache[key])
}
return this.context.globalState.update(key, this.globalStateCache[key])
}),
)
} catch (error) {
throw error
// Separate taskHistory (goes to its own file) from regular global state
const regularEntries: Record<string, any> = {}
for (const key of keys) {
if (key === "taskHistory") {
// Route task history persistence to its own file
await writeTaskHistoryToState(this.globalStateCache[key])
} else {
regularEntries[key] = this.globalStateCache[key]
}
}
// Batch write all regular keys in a single disk operation
if (Object.keys(regularEntries).length > 0) {
this.storage.globalStateBackingStore.setBatch(regularEntries)
}
}
@@ -790,61 +799,47 @@ export class StateManager {
if (pendingTaskStates.size === 0) {
return
}
try {
// Persist each task's settings
await Promise.all(
Array.from(pendingTaskStates.entries()).map(([taskId, keys]) => {
if (keys.size === 0) {
return Promise.resolve()
// Persist each task's settings
await Promise.all(
Array.from(pendingTaskStates.entries()).map(([taskId, keys]) => {
if (keys.size === 0) {
return Promise.resolve()
}
const settingsToWrite: Record<string, any> = {}
for (const key of keys) {
const value = this.taskStateCache[key]
if (value !== undefined) {
settingsToWrite[key] = value
}
const settingsToWrite: Record<string, any> = {}
for (const key of keys) {
const value = this.taskStateCache[key]
if (value !== undefined) {
settingsToWrite[key] = value
}
}
return writeTaskSettingsToStorage(taskId, settingsToWrite)
}),
)
} catch (error) {
throw error
}
}
return writeTaskSettingsToStorage(taskId, settingsToWrite)
}),
)
}
/**
* Private method to batch persist secrets with Promise.all
* Persist secrets to the file-backed store.
* Uses setBatch for efficiency (single disk write).
*/
private async persistSecretsBatch(keys: Set<SecretKey>): Promise<void> {
try {
await Promise.all(
Array.from(keys).map((key) => {
const value = this.secretsCache[key]
if (value) {
return this.context.secrets.store(key, value)
}
return this.context.secrets.delete(key)
}),
)
} catch (error) {
throw error
const entries: Record<string, string | undefined> = {}
for (const key of keys) {
const value = this.secretsCache[key]
entries[key] = value || undefined // Convert empty strings to undefined (delete)
}
this.storage.secrets.setBatch(entries)
}
/**
* Private method to batch persist workspace state keys with Promise.all
* Persist workspace state to the file-backed store.
* Uses setBatch for efficiency (single disk write).
*/
private async persistWorkspaceStateBatch(keys: Set<LocalStateKey>): Promise<void> {
try {
await Promise.all(
Array.from(keys).map((key) => {
const value = this.workspaceStateCache[key]
return this.context.workspaceState.update(key, value)
}),
)
} catch (error) {
throw error
const entries: Record<string, any> = {}
for (const key of keys) {
entries[key] = this.workspaceStateCache[key]
}
this.storage.workspaceState.setBatch(entries)
}
/**
+7 -8
View File
@@ -261,14 +261,13 @@ export async function getSavedClineMessages(taskId: string): Promise<ClineMessag
const filePath = path.join(await ensureTaskDirectoryExists(taskId), GlobalFileNames.uiMessages)
if (await fileExistsAtPath(filePath)) {
return JSON.parse(await fs.readFile(filePath, "utf8"))
} else {
// check old location
const oldPath = path.join(await ensureTaskDirectoryExists(taskId), "claude_messages.json")
if (await fileExistsAtPath(oldPath)) {
const data = JSON.parse(await fs.readFile(oldPath, "utf8"))
await fs.unlink(oldPath) // remove old file
return data
}
}
// check old location
const oldPath = path.join(await ensureTaskDirectoryExists(taskId), "claude_messages.json")
if (await fileExistsAtPath(oldPath)) {
const data = JSON.parse(await fs.readFile(oldPath, "utf8"))
await fs.unlink(oldPath) // remove old file
return data
}
return []
}
+2 -3
View File
@@ -4,7 +4,6 @@ import * as vscode from "vscode"
import { HistoryItem } from "@/shared/HistoryItem"
import { Logger } from "@/shared/services/Logger"
import { ensureRulesDirectoryExists, readTaskHistoryFromState, writeTaskHistoryToState } from "./disk"
import { StateManager } from "./StateManager"
export async function migrateWorkspaceToGlobalStorage(context: vscode.ExtensionContext) {
// Keys to migrate from workspace storage back to global storage
@@ -665,12 +664,12 @@ export async function cleanupMcpMarketplaceCatalogFromGlobalState(context: vscod
}
}
export function cleanupOldApiKey() {
export async function cleanupOldApiKey(context: vscode.ExtensionContext) {
try {
// Old API Keys were introduced in March 2025 and later replaced with tokens
// Now that we have new API keys that are prefixed with `sk_`,
// we need to clean up the old ones to free the secret storage
StateManager.get().setSecret("clineApiKey", undefined)
await context.secrets.delete("clineApiKey")
} catch (error) {
Logger.error("Failed to cleanup old clineApiKey", error)
}
+34 -43
View File
@@ -1,4 +1,5 @@
import { ApiProvider } from "@shared/api"
import type { ClineFileStorage } from "@shared/storage/ClineFileStorage"
import {
applyTransform,
GlobalStateAndSettingKeys,
@@ -11,88 +12,81 @@ import {
SecretKeys,
Secrets,
} from "@shared/storage/state-keys"
import { ExtensionContext } from "vscode"
import { Controller } from "@/core/controller"
import { ClineRulesToggles } from "@/shared/cline-rules"
import { Logger } from "@/shared/services/Logger"
import { secretStorage } from "@/shared/storage"
import { ClineMemento } from "@/shared/storage"
import { readTaskHistoryFromState } from "../disk"
import { StateManager } from "../StateManager"
export async function readSecretsFromDisk(): Promise<Secrets> {
const secrets = await Promise.all(SecretKeys.map((key) => secretStorage.get(key)))
// ─── File-backed storage readers (used by StateManager) ────────────────────
return SecretKeys.reduce((acc, key, index) => {
acc[key] = secrets[index]
/**
* Read secrets from a ClineFileStorage instance.
*/
export function readSecretsFromStorage(store: ClineFileStorage<string>): Secrets {
return SecretKeys.reduce((acc, key) => {
acc[key] = store.get(key)
return acc
}, {} as Secrets)
}
export async function readWorkspaceStateFromDisk(context: ExtensionContext): Promise<LocalState> {
const states = LocalStateKeys.map((key) => context.workspaceState.get<ClineRulesToggles | undefined>(key))
return LocalStateKeys.reduce((acc, key, index) => {
acc[key] = states[index] || {}
/**
* Read workspace state from a ClineFileStorage instance.
*/
export function readWorkspaceStateFromStorage(store: ClineFileStorage): LocalState {
return LocalStateKeys.reduce((acc, key) => {
acc[key] = store.get(key) || {}
return acc
}, {} as LocalState)
}
export async function readGlobalStateFromDisk(context: ExtensionContext): Promise<GlobalStateAndSettings> {
/**
* Read global state from a ClineFileStorage instance.
*/
export async function readGlobalStateFromStorage(store: ClineMemento): Promise<GlobalStateAndSettings> {
try {
// Batch read all state values in a single optimized pass
const stateValues = new Map<string, any>()
// Read all values at once for better performance
for (const key of GlobalStateAndSettingKeys) {
const value = context.globalState.get(key as string)
const value = store.get(key as string)
stateValues.set(key, value)
}
// Build result object with proper typing
const result = {} as any // Use any for assignment, but return proper type
const result = {} as any
// Process each state property using optimized approach
for (const key of GlobalStateAndSettingKeys) {
const stateKey = key as keyof GlobalStateAndSettings
let value = stateValues.get(stateKey)
// Skip async properties - they need special handling
if (isAsyncProperty(stateKey)) {
continue
}
// Skip computed properties - they need special handling
if (isComputedProperty(stateKey)) {
continue
}
// Apply default value if needed
if (value === undefined) {
const defaultValue = getDefaultValue(stateKey)
if (defaultValue !== undefined) {
value = defaultValue
}
}
// Apply transformation if provided
if (value !== undefined) {
value = applyTransform(stateKey, value)
}
// Set the processed value
result[stateKey] = value
}
// Handle computed properties with special logic
await handleComputedProperties(result, stateValues)
// Handle async properties
await handleAsyncProperties(result)
return result as GlobalStateAndSettings
} catch (error) {
Logger.error("[StateHelpers] Failed to read global state:", error)
Logger.error("[StateHelpers] Failed to read global state from storage:", error)
throw error
}
}
// ─── Legacy readers (for VSCode migration — reads from ExtensionContext) ────
/**
* Handle properties that require computed logic
*/
@@ -120,19 +114,16 @@ async function handleAsyncProperties(result: any): Promise<void> {
result.taskHistory = await readTaskHistoryFromState()
}
export async function resetWorkspaceState(controller: Controller) {
await Promise.all(LocalStateKeys.map((key) => controller.context.workspaceState.update(key, undefined)))
await controller.stateManager.reInitialize()
export async function resetWorkspaceState() {
const stateManager = StateManager.get()
LocalStateKeys.map((key) => stateManager.setWorkspaceState(key, {}))
await stateManager.reInitialize()
}
export async function resetGlobalState(controller: Controller) {
export async function resetGlobalState() {
// TODO: Reset all workspace states?
const context = controller.context
await Promise.all(GlobalStateAndSettingKeys.map((key) => context.globalState.update(key, undefined)))
await Promise.all(SecretKeys.map((key) => secretStorage.delete(key)))
await controller.stateManager.reInitialize()
const stateManager = StateManager.get()
GlobalStateAndSettingKeys.map((key) => stateManager.setGlobalState(key, undefined))
SecretKeys.map((key) => stateManager.setSecret(key, undefined))
await stateManager.reInitialize()
}
-3
View File
@@ -12,7 +12,6 @@ import { ClineAsk, ClineSay } from "@shared/ExtensionMessage"
import { ClineContent } from "@shared/messages/content"
import { ClineDefaultTool } from "@shared/tools"
import { ClineAskResponse } from "@shared/WebviewMessage"
import * as vscode from "vscode"
import { isParallelToolCallingEnabled, modelDoesntSupportWebp } from "@/utils/model-utils"
import { ToolUse } from "../assistant-message"
import { ContextManager } from "../context/context-management/ContextManager"
@@ -81,7 +80,6 @@ export class ToolExecutor {
constructor(
// Core Services & Managers
private context: vscode.ExtensionContext,
private taskState: TaskState,
private messageStateHandler: MessageStateHandler,
private api: ApiHandler,
@@ -160,7 +158,6 @@ export class ToolExecutor {
const config: TaskConfig = {
taskId: this.taskId,
ulid: this.ulid,
context: this.context,
mode: this.stateManager.getGlobalSettingsKey("mode"),
strictPlanModeEnabled: this.stateManager.getGlobalSettingsKey("strictPlanModeEnabled"),
yoloModeToggled: this.stateManager.getGlobalSettingsKey("yoloModeToggled"),
+5 -11
View File
@@ -309,7 +309,7 @@ export class Task {
this.terminalManager.setTerminalOutputLineLimit(terminalOutputLineLimit)
this.terminalManager.setDefaultTerminalProfile(defaultTerminalProfile)
this.urlContentFetcher = new UrlContentFetcher(controller.context)
this.urlContentFetcher = new UrlContentFetcher()
this.browserSession = new BrowserSession(stateManager)
this.contextManager = new ContextManager()
this.streamHandler = new StreamResponseHandler()
@@ -530,7 +530,6 @@ export class Task {
this.commandExecutor = new CommandExecutor(commandExecutorConfig, commandExecutorCallbacks)
this.toolExecutor = new ToolExecutor(
this.controller.context,
this.taskState,
this.messageStateHandler,
this.api,
@@ -2262,7 +2261,7 @@ export class Task {
"mistake_limit_reached",
this.api.getModel().id.includes("claude")
? `This may indicate a failure in Cline's thought process or inability to use a tool properly, which can be mitigated with some user guidance (e.g. "Try breaking down the task into smaller steps").`
: "Cline uses complex prompts and iterative task execution that may be challenging for less capable models. For best results, it's recommended to use Claude 4 Sonnet for its advanced agentic coding capabilities.",
: "Cline uses complex prompts and iterative task execution that may be challenging for less capable models. For best results, it's recommended to use Claude 4.5 Sonnet for its advanced agentic coding capabilities.",
)
if (response === "messageResponse") {
// Display the user's message in the chat UI
@@ -2389,14 +2388,10 @@ export class Task {
}
}
} else {
const autoCondenseThreshold = this.stateManager.getGlobalSettingsKey("autoCondenseThreshold") as
| number
| undefined
shouldCompact = this.contextManager.shouldCompactContextWindow(
this.messageStateHandler.getClineMessages(),
this.api,
previousApiReqIndex,
autoCondenseThreshold,
)
// Edge case: summarize_task tool call completes but user cancels next request before it finishes.
@@ -2529,7 +2524,7 @@ export class Task {
// if last message is a partial we need to update and save it
const lastMessage = this.messageStateHandler.getClineMessages().at(-1)
if (lastMessage && lastMessage.partial) {
if (lastMessage?.partial) {
// lastMessage.ts = Date.now() DO NOT update ts since it is used as a key for virtuoso list
lastMessage.partial = false
// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
@@ -3517,8 +3512,7 @@ export class Task {
let shouldShowContextWindow = true
// For next-gen models, only show context window usage if it exceeds a certain threshold
if (isNextGenModel) {
const autoCondenseThreshold =
(this.stateManager.getGlobalSettingsKey("autoCondenseThreshold") as number | undefined) ?? 0.75
const autoCondenseThreshold = 0.75
const displayThreshold = autoCondenseThreshold - 0.15
const currentUsageRatio = lastApiReqTotalTokens / contextWindow
shouldShowContextWindow = currentUsageRatio >= displayThreshold
@@ -3532,7 +3526,7 @@ export class Task {
details += "\n\n# Current Mode"
const mode = this.stateManager.getGlobalSettingsKey("mode")
if (mode === "plan") {
details += "\nPLAN MODE\n" + formatResponse.planModeInstructions()
details += `\nPLAN MODE\n${formatResponse.planModeInstructions()}`
} else {
details += "\nACT MODE"
}
@@ -6,6 +6,7 @@ import { formatResponse } from "@core/prompts/responses"
import { getWorkspaceBasename, resolveWorkspacePath } from "@core/workspace"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { ClineSayTool } from "@shared/ExtensionMessage"
import { getLastApiReqTotalTokens } from "@shared/getApiMetrics"
import { fileExistsAtPath } from "@utils/fs"
import { arePathsEqual, getReadablePath, isLocatedInWorkspace } from "@utils/path"
import { telemetryService } from "@/services/telemetry"
@@ -117,7 +118,27 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
if (block.name === "write_to_file" && !rawContent) {
config.taskState.consecutiveMistakeCount++
await config.services.diffViewProvider.reset()
return await config.callbacks.sayAndCreateMissingParamError(block.name, "content")
// Use progressive error with token budget awareness
const relPath = rawRelPath || "unknown"
const contextWindow = config.api.getModel().info.contextWindow ?? 128_000
const lastApiReqTotalTokens = getLastApiReqTotalTokens(config.messageState.getClineMessages())
const contextUsagePercent = contextWindow > 0 ? Math.round((lastApiReqTotalTokens / contextWindow) * 100) : undefined
const errorMessage = formatResponse.writeToFileMissingContentError(
relPath,
config.taskState.consecutiveMistakeCount,
contextUsagePercent,
)
await config.callbacks.say(
"error",
`Cline tried to use write_to_file for '${relPath}' without value for required parameter 'content'. ${
config.taskState.consecutiveMistakeCount >= 2
? "This has happened multiple times — Cline will try a different approach."
: "Retrying..."
}`,
)
return formatResponse.toolError(errorMessage)
}
if (block.name === "new_rule" && !rawContent) {
@@ -246,35 +267,34 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
await config.services.diffViewProvider.revertChanges()
return `The user denied this operation. ${fileDeniedNote}`
} else {
// User hit the approve button, and may have provided feedback
if (text || (images && images.length > 0) || (files && files.length > 0)) {
let fileContentString = ""
if (files && files.length > 0) {
fileContentString = await processFilesIntoText(files)
}
// Push additional tool feedback using existing utilities
ToolResultUtils.pushAdditionalToolFeedback(
config.taskState.userMessageContent,
text,
images,
fileContentString,
)
await config.callbacks.say("user_feedback", text, images, files)
}
// User hit the approve button, and may have provided feedback
if (text || (images && images.length > 0) || (files && files.length > 0)) {
let fileContentString = ""
if (files && files.length > 0) {
fileContentString = await processFilesIntoText(files)
}
telemetryService.captureToolUsage(
config.ulid,
block.name,
modelId,
providerId,
false,
true,
workspaceContext,
block.isNativeToolCall,
// Push additional tool feedback using existing utilities
ToolResultUtils.pushAdditionalToolFeedback(
config.taskState.userMessageContent,
text,
images,
fileContentString,
)
await config.callbacks.say("user_feedback", text, images, files)
}
telemetryService.captureToolUsage(
config.ulid,
block.name,
modelId,
providerId,
false,
true,
workspaceContext,
block.isNativeToolCall,
)
}
// Run PreToolUse hook after approval but before execution
@@ -327,9 +347,8 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
finalContent,
newProblemsMessage,
)
} else {
return formatResponse.fileEditWithoutUserChanges(relPath, autoFormattingEdits, finalContent, newProblemsMessage)
}
return formatResponse.fileEditWithoutUserChanges(relPath, autoFormattingEdits, finalContent, newProblemsMessage)
} catch (error) {
// Reset diff view on error
await config.services.diffViewProvider.revertChanges()
@@ -737,9 +737,7 @@ export class SubagentRunner {
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
const useAutoCondense = this.baseConfig.services.stateManager.getGlobalSettingsKey("useAutoCondense")
if (useAutoCondense && isNextGenModelFamily(modelId)) {
const autoCondenseThreshold = this.baseConfig.services.stateManager.getGlobalSettingsKey("autoCondenseThreshold") as
| number
| undefined
const autoCondenseThreshold = 0.75
const roundedThreshold = autoCondenseThreshold ? Math.floor(contextWindow * autoCondenseThreshold) : maxAllowedSize
const thresholdTokens = Math.min(roundedThreshold, maxAllowedSize)
return previousRequestTotalTokens >= thresholdTokens
-2
View File
@@ -15,7 +15,6 @@ import type { ClineContent } from "@shared/messages/content"
import type { Mode } from "@shared/storage/types"
import type { ClineDefaultTool } from "@shared/tools"
import type { ClineAskResponse } from "@shared/WebviewMessage"
import * as vscode from "vscode"
import { WorkspaceRootManager } from "@/core/workspace"
import type { ContextManager } from "../../../context/context-management/ContextManager"
import type { StateManager } from "../../../storage/StateManager"
@@ -41,7 +40,6 @@ export interface TaskConfig {
vscodeTerminalExecutionMode: "vscodeTerminal" | "backgroundExec"
enableParallelToolCalling: boolean
isSubagentExecution: boolean
context: vscode.ExtensionContext
// Multi-workspace support (optional for backward compatibility)
workspaceManager?: WorkspaceRootManager
+28 -25
View File
@@ -62,11 +62,11 @@ export class PatchParser {
private parseNextAction(): void {
const line = this.lines[this.index]
if (line.startsWith(PATCH_MARKERS.UPDATE)) {
if (line?.startsWith(PATCH_MARKERS.UPDATE)) {
this.parseUpdate(line.substring(PATCH_MARKERS.UPDATE.length).trim())
} else if (line.startsWith(PATCH_MARKERS.DELETE)) {
} else if (line?.startsWith(PATCH_MARKERS.DELETE)) {
this.parseDelete(line.substring(PATCH_MARKERS.DELETE.length).trim())
} else if (line.startsWith(PATCH_MARKERS.ADD)) {
} else if (line?.startsWith(PATCH_MARKERS.ADD)) {
this.parseAdd(line.substring(PATCH_MARKERS.ADD.length).trim())
} else {
throw new DiffError(`Unknown line while parsing: ${line}`)
@@ -85,14 +85,14 @@ export class PatchParser {
this.index++
const movePath = this.lines[this.index]?.startsWith(PATCH_MARKERS.MOVE)
? this.lines[this.index++].substring(PATCH_MARKERS.MOVE.length).trim()
? (this.lines[this.index++] ?? "").substring(PATCH_MARKERS.MOVE.length).trim()
: undefined
if (!(path in this.currentFiles)) {
throw new DiffError(`Update File Error: Missing File: ${path}`)
}
const text = this.currentFiles[path]!
const text = this.currentFiles[path] ?? ""
const action = this.parseUpdateFile(text, path)
action.movePath = movePath
@@ -114,8 +114,9 @@ export class PatchParser {
]
while (!stopMarkers.some((m) => this.lines[this.index]?.startsWith(m.trim()))) {
const defStr = this.lines[this.index]?.startsWith("@@ ") ? this.lines[this.index]!.substring(3) : undefined
const sectionStr = this.lines[this.index] === "@@" ? this.lines[this.index] : undefined
const currentLine = this.lines[this.index]
const defStr = currentLine?.startsWith("@@ ") ? currentLine.substring(3) : undefined
const sectionStr = currentLine === "@@" ? currentLine : undefined
if (defStr !== undefined || sectionStr !== undefined) {
this.index++
@@ -127,9 +128,10 @@ export class PatchParser {
if (defStr?.trim()) {
const canonDefStr = canonicalize(defStr.trim())
for (let i = index; i < fileLines.length; i++) {
if (canonicalize(fileLines[i]!) === canonDefStr || canonicalize(fileLines[i]!.trim()) === canonDefStr) {
const fileLine = fileLines[i]
if (fileLine && (canonicalize(fileLine) === canonDefStr || canonicalize(fileLine.trim()) === canonDefStr)) {
index = i + 1
if (canonicalize(fileLines[i]!.trim()) === canonDefStr && canonicalize(fileLines[i]!) !== canonDefStr) {
if (canonicalize(fileLine.trim()) === canonDefStr && canonicalize(fileLine) !== canonDefStr) {
this.fuzz++
}
break
@@ -192,8 +194,11 @@ export class PatchParser {
const stopMarkers = [PATCH_MARKERS.END, PATCH_MARKERS.UPDATE, PATCH_MARKERS.DELETE, PATCH_MARKERS.ADD]
while (this.hasMoreLines() && !stopMarkers.some((m) => this.lines[this.index].startsWith(m.trim()))) {
while (this.hasMoreLines() && !stopMarkers.some((m) => this.lines[this.index]?.startsWith(m.trim()))) {
const line = this.lines[this.index++]
if (line === undefined) {
break
}
if (!line.startsWith("+")) {
throw new DiffError(`Invalid Add File line (missing '+'): ${line}`)
}
@@ -222,31 +227,29 @@ function calculateSimilarity(str1: string, str2: string): number {
* Calculate Levenshtein distance between two strings
*/
function levenshteinDistance(str1: string, str2: string): number {
const matrix: number[][] = []
const rows = str2.length + 1
const cols = str1.length + 1
const matrix = new Array<number>(rows * cols).fill(0) // avoid undefined access with flat array
for (let i = 0; i <= str2.length; i++) {
matrix[i] = [i]
const at = (r: number, c: number): number => matrix[r * cols + c] ?? 0
const set = (r: number, c: number, v: number) => {
matrix[r * cols + c] = v
}
for (let j = 0; j <= str1.length; j++) {
matrix[0]![j] = j
}
for (let i = 0; i <= str2.length; i++) set(i, 0, i)
for (let j = 0; j <= str1.length; j++) set(0, j, j)
for (let i = 1; i <= str2.length; i++) {
for (let j = 1; j <= str1.length; j++) {
if (str2[i - 1] === str1[j - 1]) {
matrix[i]![j] = matrix[i - 1]![j - 1]!
set(i, j, at(i - 1, j - 1))
} else {
matrix[i]![j] = Math.min(
matrix[i - 1]![j - 1]! + 1, // substitution
matrix[i]![j - 1]! + 1, // insertion
matrix[i - 1]![j]! + 1, // deletion
)
set(i, j, 1 + Math.min(at(i - 1, j - 1), at(i, j - 1), at(i - 1, j)))
}
}
}
return matrix[str2.length]![str1.length]!
return at(str2.length, str1.length)
}
/**
@@ -356,8 +359,8 @@ function peek(lines: string[], initialIndex: number): PeekResult {
]
while (index < lines.length) {
const s = lines[index]!
if (stopMarkers.some((m) => s.startsWith(m.trim()))) {
const s = lines[index]
if (!s || stopMarkers.some((m) => s.startsWith(m.trim()))) {
break
}
if (s === "***") {
@@ -20,7 +20,6 @@ export const TASK_CONFIG_KEYS = [
"vscodeTerminalExecutionMode",
"enableParallelToolCalling",
"isSubagentExecution",
"context",
"taskState",
"messageState",
"api",
@@ -375,6 +375,45 @@ describe("PatchParser", () => {
expectedError: /Unknown line while parsing/,
},
// Duplicate delete error
{
name: "error: duplicate delete",
patchLines: ["*** Begin Patch", "*** Delete File: old.ts", "*** Delete File: old.ts", "*** End Patch"],
currentFiles: {
"old.ts": "content",
},
expectedError: /Duplicate delete/,
},
// Duplicate add error
{
name: "error: duplicate add",
patchLines: [
"*** Begin Patch",
"*** Add File: new.ts",
"+content",
"*** Add File: new.ts",
"+content2",
"*** End Patch",
],
currentFiles: {},
expectedError: /Duplicate add/,
},
// Invalid add line (missing '+')
{
name: "error: add file line missing '+'",
patchLines: [
"*** Begin Patch",
"*** Add File: new.ts",
"+valid line",
"invalid line without plus",
"*** End Patch",
],
currentFiles: {},
expectedError: /Invalid Add File line/,
},
// Move operation
{
name: "update with move",
@@ -782,34 +821,34 @@ describe("PatchParser", () => {
expect(actualAction, `Action for ${filePath} should exist`).to.exist
// Check action type
expect(actualAction!.type).to.equal(expectedAction.type)
expect(actualAction.type).to.equal(expectedAction.type)
// Check newFile for ADD operations
if (expectedAction.newFile !== undefined) {
expect(actualAction!.newFile).to.equal(expectedAction.newFile)
expect(actualAction?.newFile).to.equal(expectedAction.newFile)
}
// Check movePath for MOVE operations
if (expectedAction.movePath !== undefined) {
expect(actualAction!.movePath).to.equal(expectedAction.movePath)
expect(actualAction?.movePath).to.equal(expectedAction.movePath)
}
// Check chunks if provided
if (expectedAction.chunks !== undefined) {
expect(actualAction!.chunks).to.have.lengthOf(expectedAction.chunks.length)
expect(actualAction?.chunks).to.have.lengthOf(expectedAction.chunks.length)
for (let i = 0; i < expectedAction.chunks.length; i++) {
const expectedChunk = expectedAction.chunks[i]
const actualChunk = actualAction!.chunks[i]
const actualChunk = actualAction?.chunks[i]
if (expectedChunk.origIndex !== undefined) {
expect(actualChunk!.origIndex).to.equal(expectedChunk.origIndex)
expect(actualChunk?.origIndex).to.equal(expectedChunk.origIndex)
}
if (expectedChunk.delLines !== undefined) {
expect(actualChunk!.delLines).to.deep.equal(expectedChunk.delLines)
expect(actualChunk?.delLines).to.deep.equal(expectedChunk.delLines)
}
if (expectedChunk.insLines !== undefined) {
expect(actualChunk!.insLines).to.deep.equal(expectedChunk.insLines)
expect(actualChunk?.insLines).to.deep.equal(expectedChunk.insLines)
}
}
}
@@ -857,8 +896,8 @@ describe("PatchParser", () => {
const result = parser.parse()
expect(result.patch.actions["large.ts"]).to.exist
expect(result.patch.actions["large.ts"]!.chunks).to.have.lengthOf(1)
expect(result.patch.actions["large.ts"]!.chunks[0]!.origIndex).to.equal(501)
expect(result.patch.actions["large.ts"].chunks).to.have.lengthOf(1)
expect(result.patch.actions["large.ts"].chunks[0].origIndex).to.equal(501)
})
it("should handle unicode characters in content", () => {
@@ -878,7 +917,7 @@ describe("PatchParser", () => {
const result = parser.parse()
expect(result.patch.actions["unicode.ts"]).to.exist
expect(result.patch.actions["unicode.ts"]!.chunks[0]!.insLines[0]).to.equal("const text = '你好'")
expect(result.patch.actions["unicode.ts"]?.chunks[0]?.insLines[0]).to.equal("const text = '你好'")
})
it("should handle empty lines in context", () => {
@@ -891,6 +930,183 @@ describe("PatchParser", () => {
expect(result.patch.actions["test.ts"]).to.exist
})
it("should handle updating an empty file", () => {
// A file with only a single line (no context needed)
const patchLines = [
"*** Begin Patch",
"*** Update File: empty.ts",
"@@",
"-old content",
"+new content",
"*** End Patch",
]
const parser = new PatchParser(patchLines, {
"empty.ts": "old content",
})
const result = parser.parse()
expect(result.patch.actions["empty.ts"]).to.exist
expect(result.patch.actions["empty.ts"].chunks).to.have.lengthOf(1)
expect(result.patch.actions["empty.ts"].chunks[0].origIndex).to.equal(0)
expect(result.patch.actions["empty.ts"].chunks[0].delLines).to.deep.equal(["old content"])
expect(result.patch.actions["empty.ts"].chunks[0].insLines).to.deep.equal(["new content"])
})
it("should not include warnings property when there are no warnings", () => {
const patchLines = ["*** Begin Patch", "*** Update File: test.ts", "@@", " context", "-old", "+new", "*** End Patch"]
const parser = new PatchParser(patchLines, {
"test.ts": "context\nold",
})
const result = parser.parse()
// warnings should be absent (deleted) when empty
expect(result.patch.warnings).to.be.undefined
})
it("should handle add file with empty content (no lines)", () => {
const patchLines = ["*** Begin Patch", "*** Add File: empty.ts", "*** End Patch"]
const parser = new PatchParser(patchLines, {})
const result = parser.parse()
expect(result.patch.actions["empty.ts"]).to.exist
expect(result.patch.actions["empty.ts"].type).to.equal(PatchActionType.ADD)
expect(result.patch.actions["empty.ts"].newFile).to.equal("")
})
it("should handle move without content changes", () => {
// Move-only: no @@ section, just rename
const patchLines = [
"*** Begin Patch",
"*** Update File: old.ts",
"*** Move to: new.ts",
"@@",
" unchanged line",
"*** End Patch",
]
const parser = new PatchParser(patchLines, {
"old.ts": "unchanged line",
})
const result = parser.parse()
expect(result.patch.actions["old.ts"]).to.exist
expect(result.patch.actions["old.ts"].movePath).to.equal("new.ts")
expect(result.patch.actions["old.ts"].chunks).to.have.lengthOf(0)
})
it("should handle unicode punctuation normalization (curly quotes match straight quotes)", () => {
// File uses straight quotes, patch uses Unicode curly quotes — canonicalize should match them
const patchLines = [
"*** Begin Patch",
"*** Update File: test.ts",
"@@",
" const x = \u201Chello\u201D", // LEFT/RIGHT DOUBLE QUOTATION MARK
"-const y = \u2018old\u2019", // LEFT/RIGHT SINGLE QUOTATION MARK
"+const y = 'new'",
"*** End Patch",
]
const parser = new PatchParser(patchLines, {
"test.ts": "const x = \"hello\"\nconst y = 'old'",
})
const result = parser.parse()
expect(result.patch.actions["test.ts"]).to.exist
expect(result.patch.actions["test.ts"].chunks).to.have.lengthOf(1)
})
it("should handle unicode dash normalization (em-dash matches hyphen)", () => {
// File uses a regular hyphen, patch uses an em-dash — canonicalize should match them
const patchLines = [
"*** Begin Patch",
"*** Update File: test.ts",
"@@",
" // section \u2014 header", // EM DASH
"-old",
"+new",
"*** End Patch",
]
const parser = new PatchParser(patchLines, {
"test.ts": "// section - header\nold",
})
const result = parser.parse()
expect(result.patch.actions["test.ts"]).to.exist
expect(result.patch.actions["test.ts"].chunks).to.have.lengthOf(1)
})
it("should handle @@ context string not found in file (silently continues from current position)", () => {
// When the @@ context string doesn't exist in the file, the parser should
// continue searching from the current index rather than throwing
const patchLines = [
"*** Begin Patch",
"*** Update File: test.ts",
"@@ nonexistent function",
" actual context",
"-old",
"+new",
"*** End Patch",
]
const parser = new PatchParser(patchLines, {
"test.ts": "actual context\nold",
})
const result = parser.parse()
// Should still find the chunk via context lines even though @@ string wasn't found
expect(result.patch.actions["test.ts"]).to.exist
})
it("should handle patch with only insertions (no deletions)", () => {
const patchLines = [
"*** Begin Patch",
"*** Update File: test.ts",
"@@",
" line1",
"+inserted line",
" line2",
"*** End Patch",
]
const parser = new PatchParser(patchLines, {
"test.ts": "line1\nline2",
})
const result = parser.parse()
expect(result.patch.actions["test.ts"]).to.exist
const chunks = result.patch.actions["test.ts"].chunks
expect(chunks).to.have.lengthOf(1)
expect(chunks[0].delLines).to.deep.equal([])
expect(chunks[0].insLines).to.deep.equal(["inserted line"])
})
it("should handle patch with only deletions (no insertions)", () => {
const patchLines = [
"*** Begin Patch",
"*** Update File: test.ts",
"@@",
" line1",
"-line to delete",
" line2",
"*** End Patch",
]
const parser = new PatchParser(patchLines, {
"test.ts": "line1\nline to delete\nline2",
})
const result = parser.parse()
expect(result.patch.actions["test.ts"]).to.exist
const chunks = result.patch.actions["test.ts"].chunks
expect(chunks).to.have.lengthOf(1)
expect(chunks[0].delLines).to.deep.equal(["line to delete"])
expect(chunks[0].insLines).to.deep.equal([])
})
})
describe("Fuzz Scoring", () => {
@@ -968,14 +1184,14 @@ describe("PatchParser", () => {
// Should have one valid chunk applied
expect(result.patch.actions["test.ts"]).to.exist
expect(result.patch.actions["test.ts"]!.chunks).to.have.lengthOf(1)
expect(result.patch.actions["test.ts"]!.chunks[0]!.origIndex).to.equal(1)
expect(result.patch.actions["test.ts"].chunks).to.have.lengthOf(1)
expect(result.patch.actions["test.ts"].chunks[0].origIndex).to.equal(1)
// Should have warnings for skipped chunk
expect(result.patch.warnings).to.exist
expect(result.patch.warnings).to.have.lengthOf(1)
expect(result.patch.warnings![0]!.path).to.equal("test.ts")
expect(result.patch.warnings![0]!.message).to.match(/Could not find matching context/)
expect(result.patch.warnings?.[0]?.path).to.equal("test.ts")
expect(result.patch.warnings?.[0]?.message).to.match(/Could not find matching context/)
})
it("should handle mixed valid and invalid chunks", () => {
@@ -1003,7 +1219,7 @@ describe("PatchParser", () => {
const result = parser.parse()
// Should have 2 valid chunks (1st and 3rd)
expect(result.patch.actions["test.ts"]!.chunks).to.have.lengthOf(2)
expect(result.patch.actions["test.ts"].chunks).to.have.lengthOf(2)
// Should have 1 warning for skipped chunk
expect(result.patch.warnings).to.have.lengthOf(1)
+3 -3
View File
@@ -2,8 +2,8 @@ import path from "node:path"
import { Controller } from "@core/controller/index"
import axios from "axios"
import { readFile } from "fs/promises"
import * as vscode from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import { ClineExtensionContext } from "@/shared/cline"
import { ShowMessageType } from "@/shared/proto/host/window"
import { Logger } from "@/shared/services/Logger"
import { getNonce } from "./getNonce"
@@ -12,7 +12,7 @@ export abstract class WebviewProvider {
private static instance: WebviewProvider | null = null
controller: Controller
constructor(readonly context: vscode.ExtensionContext) {
constructor(readonly context: ClineExtensionContext) {
WebviewProvider.instance = this
// Create controller with cache service
@@ -140,7 +140,7 @@ export abstract class WebviewProvider {
return readFile(portFilePath, "utf8")
.then((portFile) => {
const port = parseInt(portFile.trim()) || DEFAULT_PORT
const port = Number.parseInt(portFile.trim()) || DEFAULT_PORT
Logger.info(`[getDevServerPort] Using dev server port ${port} from .vite-port file`)
return port
+36 -28
View File
@@ -19,6 +19,7 @@ import path from "node:path"
import type { ExtensionContext } from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import { vscodeHostBridgeClient } from "@/hosts/vscode/hostbridge/client/host-grpc-client"
import { createStorageContext } from "@/shared/storage/storage-context"
import { readTextFromClipboard, writeTextToClipboard } from "@/utils/env"
import { initialize, tearDown } from "./common"
import { addToCline } from "./core/controller/commands/addToCline"
@@ -28,7 +29,6 @@ import { improveWithCline } from "./core/controller/commands/improveWithCline"
import { sendAddToInputEvent } from "./core/controller/ui/subscribeToAddToInput"
import { sendShowWebviewEvent } from "./core/controller/ui/subscribeToShowWebview"
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
import { StateManager } from "./core/storage/StateManager"
import {
cleanupMcpMarketplaceCatalogFromGlobalState,
cleanupOldApiKey,
@@ -48,6 +48,7 @@ import {
import { VscodeTerminalManager } from "./hosts/vscode/terminal/VscodeTerminalManager"
import { VscodeDiffViewProvider } from "./hosts/vscode/VscodeDiffViewProvider"
import { VscodeWebviewProvider } from "./hosts/vscode/VscodeWebviewProvider"
import { exportVSCodeStorageToSharedFiles } from "./hosts/vscode/vscode-to-file-migration"
import { ExtensionRegistryInfo } from "./registry"
import { AuthService } from "./services/auth/AuthService"
import { LogoutReason } from "./services/auth/types"
@@ -66,18 +67,26 @@ export async function activate(context: vscode.ExtensionContext) {
// IMPORTANT: This must be done before any service can be registered
setupHostProvider(context)
// 2. Register services and perform common initialization
// IMPORTANT: Must be done after host provider is setup
const webview = (await initialize(context)) as VscodeWebviewProvider
// 2. Clean up legacy data patterns within VSCode's native storage.
// Moves workspace→global keys, task history→file, custom instructions→rules, etc.
// Must run BEFORE the file export so we copy clean state.
await cleanupLegacyVSCodeStorage(context)
// 3. Register services and commands specific to VS Code
// 3. One-time export of VSCode's native storage to shared file-backed stores.
// After this, all platforms (VSCode, CLI, JetBrains) read from ~/.cline/data/.
const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
const storageContext = createStorageContext({ workspacePath })
await exportVSCodeStorageToSharedFiles(context, storageContext)
// 4. Register services and perform common initialization
// IMPORTANT: Must be done after host provider is setup and migrations are complete
const webview = (await initialize(storageContext)) as VscodeWebviewProvider
// 5. Register services and commands specific to VS Code
// Initialize test mode and add disposables to context
const testModeWatchers = await initializeTestMode(webview)
context.subscriptions.push(...testModeWatchers)
// Perform storage migrations that does not block extension activation
performStorageMigrations(context)
// Initialize hook discovery cache for performance optimization
HookDiscoveryCache.getInstance().initialize(
context as any, // Adapt VSCode ExtensionContext to generic interface
@@ -514,25 +523,24 @@ ${ctx.cellJson || "{}"}
}),
)
context.subscriptions.push(
context.secrets.onDidChange(async (event) => {
if (event.key === "cline:clineAccountId") {
// Check if the secret was removed (logout) or added/updated (login)
const secretValue = await context.secrets.get(event.key)
const activeWebview = WebviewProvider.getVisibleInstance()
const controller = activeWebview?.controller
// Listen for secrets changes (e.g., cross-window login/logout sync)
const unsubSecrets = storageContext.secrets.onDidChange((event) => {
if (event.key === "cline:clineAccountId") {
const secretValue = storageContext.secrets.get<string>(event.key)
const activeWebview = WebviewProvider.getVisibleInstance()
const controller = activeWebview?.controller
const authService = AuthService.getInstance(controller)
if (secretValue) {
// Secret was added or updated - restore auth info (login from another window)
authService?.restoreRefreshTokenAndRetrieveAuthInfo()
} else {
// Secret was removed - handle logout for all windows
authService?.handleDeauth(LogoutReason.CROSS_WINDOW_SYNC)
}
const authService = AuthService.getInstance(controller)
if (secretValue) {
// Secret was added or updated - restore auth info (login from another window)
authService?.restoreRefreshTokenAndRetrieveAuthInfo()
} else {
// Secret was removed - handle logout for all windows
authService?.handleDeauth(LogoutReason.CROSS_WINDOW_SYNC)
}
}),
)
}
})
context.subscriptions.push({ dispose: unsubSecrets })
Logger.log(`[Cline] extension activated in ${performance.now() - activationStartTime} ms`)
@@ -706,11 +714,11 @@ if (IS_DEV) {
}
// VSCode-specific storage migrations
async function performStorageMigrations(context: ExtensionContext): Promise<void> {
async function cleanupLegacyVSCodeStorage(context: ExtensionContext): Promise<void> {
try {
cleanupOldApiKey()
await cleanupOldApiKey(context)
// Migrate is not done if the new storage does not have the lastShownAnnouncementId flag
const hasMigrated = StateManager.get().getGlobalStateKey("lastShownAnnouncementId")
const hasMigrated = context.globalState.get("lastShownAnnouncementId")
if (hasMigrated !== undefined) {
return
}
@@ -0,0 +1,457 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import "should"
import { ClineFileStorage } from "@shared/storage/ClineFileStorage"
import { createStorageContext, type StorageContext } from "@shared/storage/storage-context"
import fs from "fs"
import os from "os"
import path from "path"
import sinon from "sinon"
import { exportVSCodeStorageToSharedFiles } from "../vscode-to-file-migration"
/**
* Create a minimal mock of VSCode's ExtensionContext for migration testing.
* Provides in-memory implementations of globalState, secrets, and workspaceState.
*/
function createMockVSCodeContext() {
const globalStateStore = new Map<string, any>()
const secretsStore = new Map<string, string>()
const workspaceStateStore = new Map<string, any>()
return {
globalState: {
get<T>(key: string): T | undefined {
return globalStateStore.get(key) as T | undefined
},
async update(key: string, value: any): Promise<void> {
if (value === undefined) {
globalStateStore.delete(key)
} else {
globalStateStore.set(key, value)
}
},
keys(): readonly string[] {
return Array.from(globalStateStore.keys())
},
setKeysForSync() {},
},
secrets: {
async get(key: string): Promise<string | undefined> {
return secretsStore.get(key)
},
async store(key: string, value: string): Promise<void> {
secretsStore.set(key, value)
},
async delete(key: string): Promise<void> {
secretsStore.delete(key)
},
onDidChange: () => ({ dispose: () => {} }),
},
workspaceState: {
get<T>(key: string): T | undefined {
return workspaceStateStore.get(key) as T | undefined
},
async update(key: string, value: any): Promise<void> {
if (value === undefined) {
workspaceStateStore.delete(key)
} else {
workspaceStateStore.set(key, value)
}
},
keys(): readonly string[] {
return Array.from(workspaceStateStore.keys())
},
setKeysForSync() {},
},
// Expose internal stores for test setup
_globalStateStore: globalStateStore,
_secretsStore: secretsStore,
_workspaceStateStore: workspaceStateStore,
}
}
describe("vscode-to-file-migration", () => {
let sandbox: sinon.SinonSandbox
let tempDir: string
let storageContext: StorageContext
beforeEach(() => {
sandbox = sinon.createSandbox()
tempDir = path.join(os.tmpdir(), `migration-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
fs.mkdirSync(tempDir, { recursive: true })
storageContext = createStorageContext({
clineDir: tempDir,
workspacePath: tempDir,
})
})
afterEach(() => {
sandbox.restore()
try {
fs.rmSync(tempDir, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
})
describe("sentinel behavior", () => {
it("should migrate on first run (no sentinel)", async () => {
const mockCtx = createMockVSCodeContext()
mockCtx._globalStateStore.set("mode", "act")
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
result.globalStateCount.should.be.greaterThan(0)
storageContext.globalState.get("mode")!.should.equal("act")
// Both sentinels should be written
storageContext.globalState.get("__vscodeMigrationVersion")!.should.equal(1)
storageContext.workspaceState.get("__vscodeMigrationVersion")!.should.equal(1)
})
it("should skip everything when both sentinels are current version", async () => {
// Pre-set BOTH sentinels
storageContext.globalState.update("__vscodeMigrationVersion", 1)
storageContext.workspaceState.set("__vscodeMigrationVersion", 1)
const mockCtx = createMockVSCodeContext()
mockCtx._globalStateStore.set("mode", "plan")
mockCtx._workspaceStateStore.set("localClineRulesToggles", { "rule-1": true })
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.false()
result.globalStateCount.should.equal(0)
result.workspaceStateCount.should.equal(0)
// Should NOT have the VSCode values — migration was skipped
const modeVal = storageContext.globalState.get("mode")
;(modeVal === undefined).should.be.true()
})
it("should skip everything when both sentinels are higher version", async () => {
storageContext.globalState.update("__vscodeMigrationVersion", 999)
storageContext.workspaceState.set("__vscodeMigrationVersion", 999)
const mockCtx = createMockVSCodeContext()
mockCtx._globalStateStore.set("mode", "act")
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.false()
})
it("should re-run migration if sentinels are lower version", async () => {
storageContext.globalState.update("__vscodeMigrationVersion", 0)
storageContext.workspaceState.set("__vscodeMigrationVersion", 0)
const mockCtx = createMockVSCodeContext()
mockCtx._globalStateStore.set("mode", "plan")
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
})
it("should migrate workspace state when globals already migrated (new workspace)", async () => {
// Simulate: globals+secrets already migrated, but this is a fresh workspace
storageContext.globalState.update("__vscodeMigrationVersion", 1)
// workspaceState has NO sentinel — this is a new workspace
const mockCtx = createMockVSCodeContext()
mockCtx._globalStateStore.set("mode", "plan") // should be skipped
mockCtx._secretsStore.set("apiKey", "sk-test") // should be skipped
mockCtx._workspaceStateStore.set("localClineRulesToggles", { "rule-1": true })
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
// Global state and secrets should NOT have been migrated
result.globalStateCount.should.equal(0)
result.secretsCount.should.equal(0)
// Workspace state SHOULD have been migrated
result.workspaceStateCount.should.equal(1)
const stored = storageContext.workspaceState.get("localClineRulesToggles") as any
stored.should.deepEqual({ "rule-1": true })
// Workspace sentinel should now be set
storageContext.workspaceState.get("__vscodeMigrationVersion")!.should.equal(1)
})
it("should migrate globals when workspace already migrated", async () => {
// Edge case: workspace was somehow migrated but globals were not
storageContext.workspaceState.set("__vscodeMigrationVersion", 1)
// globalState has NO sentinel
const mockCtx = createMockVSCodeContext()
mockCtx._globalStateStore.set("mode", "plan")
mockCtx._workspaceStateStore.set("localClineRulesToggles", { "rule-1": true }) // should be skipped
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
// Global state SHOULD have been migrated
result.globalStateCount.should.be.greaterThan(0)
storageContext.globalState.get("mode")!.should.equal("plan")
// Workspace state should NOT have been migrated
result.workspaceStateCount.should.equal(0)
// Global sentinel should now be set
storageContext.globalState.get("__vscodeMigrationVersion")!.should.equal(1)
})
})
describe("global state migration", () => {
it("should migrate global state keys", async () => {
const mockCtx = createMockVSCodeContext()
mockCtx._globalStateStore.set("mode", "plan")
mockCtx._globalStateStore.set("yoloModeToggled", true)
mockCtx._globalStateStore.set("enableCheckpointsSetting", false)
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
storageContext.globalState.get("mode")!.should.equal("plan")
storageContext.globalState.get("yoloModeToggled")!.should.equal(true)
storageContext.globalState.get("enableCheckpointsSetting")!.should.equal(false)
})
it("should NOT overwrite existing file store values", async () => {
// Pre-populate the file store with a value
storageContext.globalState.update("mode", "act")
const mockCtx = createMockVSCodeContext()
mockCtx._globalStateStore.set("mode", "plan") // VSCode has different value
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
result.skippedExisting.should.be.greaterThan(0)
// File store value should be preserved, NOT overwritten
storageContext.globalState.get("mode")!.should.equal("act")
})
it("should skip undefined values", async () => {
const mockCtx = createMockVSCodeContext()
// Don't set anything — all keys will be undefined
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
result.globalStateCount.should.equal(0)
})
it("should skip taskHistory (it has its own file)", async () => {
const mockCtx = createMockVSCodeContext()
mockCtx._globalStateStore.set("taskHistory", [{ id: "old", ts: 123 }])
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
// taskHistory should NOT be in the file store
const val = storageContext.globalState.get("taskHistory")
;(val === undefined).should.be.true()
})
})
describe("secrets migration", () => {
it("should migrate secret keys", async () => {
const mockCtx = createMockVSCodeContext()
mockCtx._secretsStore.set("apiKey", "sk-test-123")
mockCtx._secretsStore.set("openRouterApiKey", "or-test-456")
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
result.secretsCount.should.equal(2)
storageContext.secrets.get("apiKey")!.should.equal("sk-test-123")
storageContext.secrets.get("openRouterApiKey")!.should.equal("or-test-456")
})
it("should NOT overwrite existing secrets in file store", async () => {
storageContext.secrets.set("apiKey", "existing-key")
const mockCtx = createMockVSCodeContext()
mockCtx._secretsStore.set("apiKey", "vscode-key")
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
result.skippedExisting.should.be.greaterThan(0)
storageContext.secrets.get("apiKey")!.should.equal("existing-key")
})
it("should skip empty string secrets", async () => {
const mockCtx = createMockVSCodeContext()
mockCtx._secretsStore.set("apiKey", "")
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
result.secretsCount.should.equal(0)
})
it("should continue even if a single secret read fails", async () => {
const mockCtx = createMockVSCodeContext()
mockCtx._secretsStore.set("openRouterApiKey", "or-key-123")
// Make one secret read fail
const origGet = mockCtx.secrets.get.bind(mockCtx.secrets)
mockCtx.secrets.get = async (key: string) => {
if (key === "apiKey") {
throw new Error("Simulated secret read error")
}
return origGet(key)
}
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
result.secretsCount.should.equal(1)
storageContext.secrets.get("openRouterApiKey")!.should.equal("or-key-123")
})
})
describe("workspace state migration", () => {
it("should migrate workspace state keys", async () => {
const toggles = { "rule-1": true, "rule-2": false }
const mockCtx = createMockVSCodeContext()
mockCtx._workspaceStateStore.set("localClineRulesToggles", toggles)
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
result.workspaceStateCount.should.equal(1)
const stored = storageContext.workspaceState.get("localClineRulesToggles") as any
stored.should.deepEqual(toggles)
})
it("should NOT overwrite existing workspace state", async () => {
const existingToggles = { "rule-existing": true }
storageContext.workspaceState.set("localClineRulesToggles", existingToggles)
const mockCtx = createMockVSCodeContext()
mockCtx._workspaceStateStore.set("localClineRulesToggles", { "rule-vscode": true })
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
const stored = storageContext.workspaceState.get("localClineRulesToggles") as any
stored.should.deepEqual(existingToggles)
})
})
describe("error handling", () => {
it("should NOT write sentinel if migration throws", async () => {
const mockCtx = createMockVSCodeContext()
// Stub setBatch to throw an error
sandbox.stub(storageContext.globalState, "setBatch").callsFake(() => {
throw new Error("Simulated disk write error")
})
mockCtx._globalStateStore.set("mode", "act")
mockCtx._globalStateStore.set("yoloModeToggled", true)
mockCtx._globalStateStore.set("enableCheckpointsSetting", true)
try {
await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.equal("Simulated disk write error")
}
// Sentinel should NOT be written
const sentinel = storageContext.globalState.get("__vscodeMigrationVersion")
;(sentinel === undefined).should.be.true()
})
})
describe("idempotency", () => {
it("should produce same result when run twice", async () => {
const mockCtx = createMockVSCodeContext()
mockCtx._globalStateStore.set("mode", "plan")
mockCtx._secretsStore.set("apiKey", "sk-test")
// First run
const result1 = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result1.migrated.should.be.true()
// Second run — should be skipped due to sentinel
const result2 = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result2.migrated.should.be.false()
result2.globalStateCount.should.equal(0)
// Values should still be correct
storageContext.globalState.get("mode")!.should.equal("plan")
storageContext.secrets.get("apiKey")!.should.equal("sk-test")
})
})
})
describe("createStorageContext", () => {
let tempDir: string
beforeEach(() => {
tempDir = path.join(os.tmpdir(), `storage-ctx-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
})
afterEach(() => {
try {
fs.rmSync(tempDir, { recursive: true, force: true })
} catch {
// Ignore
}
})
it("should create all three stores", () => {
const ctx = createStorageContext({ clineDir: tempDir, workspacePath: "/fake/workspace" })
ctx.globalState.should.be.instanceOf(ClineFileStorage)
ctx.secrets.should.be.instanceOf(ClineFileStorage)
ctx.workspaceState.should.be.instanceOf(ClineFileStorage)
})
it("should create directories", () => {
const ctx = createStorageContext({ clineDir: tempDir, workspacePath: "/fake/workspace" })
fs.existsSync(ctx.dataDir).should.be.true()
fs.existsSync(ctx.workspaceStoragePath).should.be.true()
})
it("should produce deterministic workspace hashes", () => {
const ctx1 = createStorageContext({ clineDir: tempDir, workspacePath: "/some/project" })
const ctx2 = createStorageContext({ clineDir: tempDir, workspacePath: "/some/project" })
ctx1.workspaceStoragePath.should.equal(ctx2.workspaceStoragePath)
})
it("should produce different hashes for different workspaces", () => {
const ctx1 = createStorageContext({ clineDir: tempDir, workspacePath: "/project-a" })
const ctx2 = createStorageContext({ clineDir: tempDir, workspacePath: "/project-b" })
ctx1.workspaceStoragePath.should.not.equal(ctx2.workspaceStoragePath)
})
it("should use explicit workspaceStorageDir when provided", () => {
const explicitDir = path.join(tempDir, "explicit-ws")
const ctx = createStorageContext({
clineDir: tempDir,
workspacePath: "/ignored",
workspaceStorageDir: explicitDir,
})
ctx.workspaceStoragePath.should.equal(explicitDir)
})
it("should store and retrieve values correctly", () => {
const ctx = createStorageContext({ clineDir: tempDir, workspacePath: "/test" })
ctx.globalState.update("testKey", "testValue")
ctx.globalState.get("testKey")!.should.equal("testValue")
ctx.secrets.set("secretKey", "secretValue")
ctx.secrets.get("secretKey")!.should.equal("secretValue")
ctx.workspaceState.set("wsKey", { toggle: true })
const ws = ctx.workspaceState.get("wsKey") as any
ws.toggle.should.equal(true)
})
})
@@ -0,0 +1,206 @@
/**
* One-time migration from VSCode's ExtensionContext storage to file-backed stores.
*
* VSCode historically stored global state, workspace state, and secrets via the
* ExtensionContext API (backed by SQLite under ~/.vscode/). This module migrates
* that data to the shared file-backed stores in ~/.cline/data/ so all platforms
* (VSCode, CLI, JetBrains) share the same persistence layer.
*
* ## Migration semantics
*
* - **Two independent sentinels** control migration:
* - `__migrationVersion` in the file-backed `globalState` gates global state + secrets.
* - `__migrationVersion` in the file-backed `workspaceState` gates per-workspace state.
* This ensures that when a new workspace is opened for the first time, its workspace
* state is still migrated even though globals+secrets were already exported previously.
*
* - **Merge strategy: file-backed store wins.** If a key already exists in the
* file store (e.g. because CLI or JetBrains wrote it), we do NOT overwrite.
* This prevents the migration from clobbering newer data written by another client.
*
* - VSCode storage is NOT cleared after migration. This ensures safe downgrade:
* if the user rolls back to an older extension version that doesn't know about
* file-backed stores, the old code path still works.
*
* - taskHistory is NOT migrated here. It uses its own file-based storage
* at {globalStorageFsPath}/state/taskHistory.json. Note that for VSCode,
* globalStorageFsPath is still the VSCode-managed path (not ~/.cline/data/),
* so task history is NOT yet shared across clients.
*
* TODO: Migrate taskHistory.json and task data files ({globalStorageFsPath}/tasks/)
* to ~/.cline/data/ so that tasks created in VSCode are visible in CLI/JetBrains
* and vice versa. See also: checkpoints at {globalStorageFsPath}/checkpoints/.
*/
import type * as vscode from "vscode"
import { Logger } from "@/shared/services/Logger"
import { GlobalStateAndSettingKeys, LocalStateKeys, SecretKeys } from "@/shared/storage/state-keys"
import type { StorageContext } from "@/shared/storage/storage-context"
/** Bump this when adding new migration steps. */
const CURRENT_MIGRATION_VERSION = 1
/** Sentinel key written to both globalState and workspaceState to track migration independently. */
const MIGRATION_VERSION_KEY = "__vscodeMigrationVersion"
/**
* Keys that should NOT be migrated from VSCode storage.
* These are either:
* - async/computed (taskHistory has its own file)
* - ephemeral/transient
*/
const SKIP_GLOBAL_STATE_KEYS = new Set<string>([
"taskHistory", // Already file-based in tasks/taskHistory.json
])
export interface MigrationResult {
migrated: boolean
globalStateCount: number
secretsCount: number
workspaceStateCount: number
skippedExisting: number
}
/**
* Run the one-time migration from VSCode ExtensionContext storage to file-backed stores.
*
* Safe to call on every startup it checks sentinels and returns immediately
* if migration has already been completed at the current version.
*
* Global state and secrets share one sentinel (in globalState file store).
* Workspace state has its own sentinel (in workspaceState file store) so that
* each new workspace gets migrated independently.
*
* @param vscodeContext The VSCode ExtensionContext (source of truth for legacy data)
* @param storage The file-backed StorageContext (destination)
* @returns Summary of what was migrated
*/
export async function exportVSCodeStorageToSharedFiles(
vscodeContext: vscode.ExtensionContext,
storage: StorageContext,
): Promise<MigrationResult> {
const result: MigrationResult = {
migrated: false,
globalStateCount: 0,
secretsCount: 0,
workspaceStateCount: 0,
skippedExisting: 0,
}
// Check sentinels independently
const globalVersion = storage.globalState.get<number>(MIGRATION_VERSION_KEY)
const workspaceVersion = storage.workspaceState.get<number>(MIGRATION_VERSION_KEY)
const needGlobalMigration = globalVersion === undefined || globalVersion < CURRENT_MIGRATION_VERSION
const needWorkspaceMigration = workspaceVersion === undefined || workspaceVersion < CURRENT_MIGRATION_VERSION
if (!needGlobalMigration && !needWorkspaceMigration) {
Logger.info(
`[Migration] File-backed stores already current (global: v${globalVersion}, workspace: v${workspaceVersion}), skipping.`,
)
return result
}
Logger.info(
`[Migration] Starting VSCode → file-backed migration (global: ${globalVersion ?? "none"}, workspace: ${workspaceVersion ?? "none"}, target: ${CURRENT_MIGRATION_VERSION})`,
)
try {
// ─── 1. Migrate global state + secrets (if needed) ─────────────
if (needGlobalMigration) {
// Batch global state keys
const globalStateBatch: Record<string, any> = {}
for (const key of GlobalStateAndSettingKeys) {
if (SKIP_GLOBAL_STATE_KEYS.has(key)) {
continue
}
const vscodeValue = vscodeContext.globalState.get(key)
if (vscodeValue === undefined) {
continue
}
const existingFileValue = storage.globalState.get(key)
if (existingFileValue !== undefined) {
result.skippedExisting++
continue
}
globalStateBatch[key] = vscodeValue
result.globalStateCount++
}
// Add sentinel to batch
globalStateBatch[MIGRATION_VERSION_KEY] = CURRENT_MIGRATION_VERSION
// Write all global state in one operation
storage.globalState.setBatch(globalStateBatch)
// Batch secrets
const secretsBatch: Record<string, string> = {}
for (const key of SecretKeys) {
try {
const vscodeValue = await vscodeContext.secrets.get(key)
if (vscodeValue === undefined || vscodeValue === "") {
continue
}
const existingFileValue = storage.secrets.get(key)
if (existingFileValue !== undefined && existingFileValue !== "") {
result.skippedExisting++
continue
}
secretsBatch[key] = vscodeValue
result.secretsCount++
} catch (error) {
Logger.error(`[Migration] Failed to read secret '${key}' from VSCode:`, error)
}
}
// Write all secrets in one operation
storage.secrets.setBatch(secretsBatch)
}
// ─── 2. Migrate workspace state (if needed) ────────────────────
if (needWorkspaceMigration) {
// Batch workspace state keys
const workspaceStateBatch: Record<string, any> = {}
for (const key of LocalStateKeys) {
const vscodeValue = vscodeContext.workspaceState.get(key)
if (vscodeValue === undefined) {
continue
}
const existingFileValue = storage.workspaceState.get(key)
if (existingFileValue !== undefined) {
result.skippedExisting++
continue
}
workspaceStateBatch[key] = vscodeValue
result.workspaceStateCount++
}
// Add sentinel to batch
workspaceStateBatch[MIGRATION_VERSION_KEY] = CURRENT_MIGRATION_VERSION
// Write all workspace state in one operation
storage.workspaceState.setBatch(workspaceStateBatch)
}
result.migrated = true
Logger.info(
`[Migration] Complete: ${result.globalStateCount} global state keys, ` +
`${result.secretsCount} secrets, ${result.workspaceStateCount} workspace state keys migrated. ` +
`${result.skippedExisting} keys skipped (already in file store).`,
)
} catch (error) {
Logger.error("[Migration] Fatal error during VSCode → file-backed migration:", error)
// Don't write sentinel on failure — migration will retry next startup
throw error
}
return result
}
-21
View File
@@ -1,7 +1,6 @@
import * as crypto from "crypto"
import * as http from "http"
import { URL } from "url"
import type { ExtensionContext } from "vscode"
import { z } from "zod"
import { StateManager } from "@/core/storage/StateManager"
import { fetch } from "@/shared/net"
@@ -336,7 +335,6 @@ export function isTokenExpired(credentials: OpenAiCodexCredentials): boolean {
* OpenAiCodexOAuthManager - Handles OAuth flow and token management
*/
export class OpenAiCodexOAuthManager {
private context: ExtensionContext | null = null
private credentials: OpenAiCodexCredentials | null = null
private refreshPromise: Promise<OpenAiCodexCredentials> | null = null
private pendingAuth: {
@@ -345,13 +343,6 @@ export class OpenAiCodexOAuthManager {
server?: http.Server
} | null = null
/**
* Initialize the OAuth manager with VS Code extension context
*/
initialize(context: ExtensionContext): void {
this.context = context
}
/**
* Force a refresh using the stored refresh token even if the access token is not expired.
* Useful when the server invalidates an access token early.
@@ -390,10 +381,6 @@ export class OpenAiCodexOAuthManager {
* Load credentials from storage via StateManager.
*/
async loadCredentials(): Promise<OpenAiCodexCredentials | null> {
if (!this.context) {
return null
}
try {
const stateManager = StateManager.get()
const credentialsJson = stateManager.getSecretKey("openai-codex-oauth-credentials")
@@ -415,10 +402,6 @@ export class OpenAiCodexOAuthManager {
* Save credentials to storage via StateManager
*/
async saveCredentials(credentials: OpenAiCodexCredentials): Promise<void> {
if (!this.context) {
throw new Error("OAuth manager not initialized")
}
const stateManager = StateManager.get()
stateManager.setSecret("openai-codex-oauth-credentials", JSON.stringify(credentials))
await stateManager.flushPendingState()
@@ -429,10 +412,6 @@ export class OpenAiCodexOAuthManager {
* Clear credentials from storage
*/
async clearCredentials(): Promise<void> {
if (!this.context) {
return
}
const stateManager = StateManager.get()
stateManager.setSecret("openai-codex-oauth-credentials", undefined)
await stateManager.flushPendingState()
+63 -9
View File
@@ -1,4 +1,4 @@
import type { Banner, BannerRules, BannersResponse } from "@shared/ClineBanner"
import type { Banner, BannerAction, BannerRules, BannersResponse } from "@shared/ClineBanner"
import { BannerActionType, type BannerCardData } from "@shared/cline/banner"
import { ClineEnv } from "@/config"
import { Controller } from "@/core/controller"
@@ -140,6 +140,45 @@ export class BannerService {
}
public getActiveBanners(): BannerCardData[] {
this.ensureFreshCache()
const activeBanners = this.cachedBanners
.filter((b) => b.placement !== "welcome")
.filter((b) => !this.isBannerDismissed(b.id))
.map((b) => this.toBannerCardData(b))
.filter((b): b is BannerCardData => b !== null)
return activeBanners
}
/**
* Returns welcome banners (placement === "welcome") for the What's New modal.
* These are version-targeted banners fetched from the backend.
* Gated by REMOTE_WELCOME_BANNERS feature flag when off, returns empty
* so the webview falls back to hardcoded welcome items.
*/
public getWelcomeBanners(): BannerCardData[] | undefined {
const isLocal = process.env.IS_DEV === "true" || process.env.CLINE_ENVIRONMENT === "local"
const flagEnabled = isLocal || featureFlagsService.getBooleanFlagEnabled(FeatureFlag.REMOTE_WELCOME_BANNERS)
if (!flagEnabled) {
return undefined
}
const bypassDismissals = process.env.IS_DEV === "true" || process.env.CLINE_ENVIRONMENT === "local"
this.ensureFreshCache()
const welcomeCandidates = this.cachedBanners.filter((b) => b.placement === "welcome")
const welcomeBanners = welcomeCandidates
.filter((b) => bypassDismissals || !this.isBannerDismissed(b.id))
.map((b) => this.toBannerCardData(b))
.filter((b): b is BannerCardData => b !== null)
return welcomeBanners
}
private ensureFreshCache(): void {
const now = Date.now()
const shouldFetch =
now >= this.backoffUntil &&
@@ -148,17 +187,11 @@ export class BannerService {
!this.authFetchPending
if (shouldFetch) {
Logger.log("[BannerService] Cache expired, fetching new banners")
this.fetchPromise = this.doFetch()
this.fetchPromise.finally(() => {
this.fetchPromise = null
})
}
return this.cachedBanners
.filter((b) => !this.isBannerDismissed(b.id))
.map((b) => this.toBannerCardData(b))
.filter((b): b is BannerCardData => b !== null)
}
public clearCache(): void {
@@ -264,11 +297,23 @@ export class BannerService {
return []
}
Logger.log(
`[BannerService] Raw API response: ${data.data.items.length} items: ${JSON.stringify(
data.data.items.map((b) => ({ id: b.id, placement: b.placement, titleMd: b.titleMd?.substring(0, 50) })),
)}`,
)
const banners = data.data.items.filter((b) => this.matchesProviderRule(b))
this.cachedBanners = banners
this.lastFetchTime = Date.now()
this.consecutiveFailures = 0
Logger.log(
`[BannerService] After provider filter: ${banners.length} banners: ${JSON.stringify(
banners.map((b) => ({ id: b.id, placement: b.placement })),
)}`,
)
this.controller.postStateToWebview().catch((error) => {
Logger.error("Failed to post state to webview after fetching banners:", error)
})
@@ -339,10 +384,15 @@ export class BannerService {
}
private getIdeType(): string {
const ide = this.hostInfo.ide
const ide = this.hostInfo.ide?.toLowerCase() ?? ""
for (const [key, value] of Object.entries(IDE_MAP)) {
if (ide.includes(key)) return value
}
const platform = this.hostInfo.platform?.toLowerCase() ?? ""
if (platform.includes("visual studio") || platform.includes("vscode")) {
return "vscode"
}
return "unknown"
}
@@ -373,8 +423,12 @@ export class BannerService {
}
}
private getBannerActions(banner: Banner): BannerAction[] {
return banner.actions ?? []
}
private toBannerCardData(banner: Banner): BannerCardData | null {
const actions = banner.actions || []
const actions = this.getBannerActions(banner)
// Validate all actions have valid types
for (const action of actions) {
@@ -798,6 +798,155 @@ describe("BannerService", () => {
})
})
describe("IDE Type Detection", () => {
function stubHostInfo(overrides: { ide?: string; platform?: string }) {
;(HostRegistryInfo.get as sinon.SinonStub).returns({
extensionVersion: "1.0.0",
platform: overrides.platform ?? "darwin",
os: "darwin",
ide: overrides.ide ?? "vscode",
distinctId: "test-distinct-id",
})
}
async function getIdeParam(fetch: sinon.SinonStub): Promise<string> {
const url = new URL(fetch.getCall(fetch.callCount - 1).args[0])
return url.searchParams.get("ide") ?? ""
}
const emptyResponse = { data: { items: [] } }
it('should return "vscode" when ide contains "vscode"', async () => {
stubHostInfo({ ide: "vscode" })
mockFetch.resolves(createSuccessResponse(emptyResponse))
await mockFetchForTesting(mockFetch, async () => {
const bannerService = BannerService.initialize(mockController)
bannerService.getActiveBanners()
await new Promise((resolve) => setTimeout(resolve, 10))
expect(await getIdeParam(mockFetch)).to.equal("vscode")
})
})
it('should return "vscode" when ide is "VSCode Extension" (case-insensitive)', async () => {
stubHostInfo({ ide: "VSCode Extension" })
mockFetch.resolves(createSuccessResponse(emptyResponse))
await mockFetchForTesting(mockFetch, async () => {
const bannerService = BannerService.initialize(mockController)
bannerService.getActiveBanners()
await new Promise((resolve) => setTimeout(resolve, 10))
expect(await getIdeParam(mockFetch)).to.equal("vscode")
})
})
it('should return "jetbrains" when ide contains "jetbrains"', async () => {
stubHostInfo({ ide: "jetbrains" })
mockFetch.resolves(createSuccessResponse(emptyResponse))
await mockFetchForTesting(mockFetch, async () => {
const bannerService = BannerService.initialize(mockController)
bannerService.getActiveBanners()
await new Promise((resolve) => setTimeout(resolve, 10))
expect(await getIdeParam(mockFetch)).to.equal("jetbrains")
})
})
it('should return "jetbrains" when ide is "Cline for JetBrains" (case-insensitive)', async () => {
stubHostInfo({ ide: "Cline for JetBrains" })
mockFetch.resolves(createSuccessResponse(emptyResponse))
await mockFetchForTesting(mockFetch, async () => {
const bannerService = BannerService.initialize(mockController)
bannerService.getActiveBanners()
await new Promise((resolve) => setTimeout(resolve, 10))
expect(await getIdeParam(mockFetch)).to.equal("jetbrains")
})
})
it('should return "cli" when ide contains "cli"', async () => {
stubHostInfo({ ide: "cli" })
mockFetch.resolves(createSuccessResponse(emptyResponse))
await mockFetchForTesting(mockFetch, async () => {
const bannerService = BannerService.initialize(mockController)
bannerService.getActiveBanners()
await new Promise((resolve) => setTimeout(resolve, 10))
expect(await getIdeParam(mockFetch)).to.equal("cli")
})
})
it('should fall back to "vscode" when ide is empty but platform contains "Visual Studio"', async () => {
stubHostInfo({ ide: "", platform: "Visual Studio Code 1.103.0" })
mockFetch.resolves(createSuccessResponse(emptyResponse))
await mockFetchForTesting(mockFetch, async () => {
const bannerService = BannerService.initialize(mockController)
bannerService.getActiveBanners()
await new Promise((resolve) => setTimeout(resolve, 10))
expect(await getIdeParam(mockFetch)).to.equal("vscode")
})
})
it('should fall back to "vscode" when ide is empty but platform contains "vscode"', async () => {
stubHostInfo({ ide: "", platform: "vscode" })
mockFetch.resolves(createSuccessResponse(emptyResponse))
await mockFetchForTesting(mockFetch, async () => {
const bannerService = BannerService.initialize(mockController)
bannerService.getActiveBanners()
await new Promise((resolve) => setTimeout(resolve, 10))
expect(await getIdeParam(mockFetch)).to.equal("vscode")
})
})
it('should return "unknown" when both ide and platform are unrecognized', async () => {
stubHostInfo({ ide: "some-random-ide", platform: "some-random-platform" })
mockFetch.resolves(createSuccessResponse(emptyResponse))
await mockFetchForTesting(mockFetch, async () => {
const bannerService = BannerService.initialize(mockController)
bannerService.getActiveBanners()
await new Promise((resolve) => setTimeout(resolve, 10))
expect(await getIdeParam(mockFetch)).to.equal("unknown")
})
})
it('should return "unknown" when both ide and platform are empty', async () => {
stubHostInfo({ ide: "", platform: "" })
mockFetch.resolves(createSuccessResponse(emptyResponse))
await mockFetchForTesting(mockFetch, async () => {
const bannerService = BannerService.initialize(mockController)
bannerService.getActiveBanners()
await new Promise((resolve) => setTimeout(resolve, 10))
expect(await getIdeParam(mockFetch)).to.equal("unknown")
})
})
it("should prefer ide field over platform field for detection", async () => {
stubHostInfo({ ide: "Cline for JetBrains", platform: "Visual Studio Code 1.103.0" })
mockFetch.resolves(createSuccessResponse(emptyResponse))
await mockFetchForTesting(mockFetch, async () => {
const bannerService = BannerService.initialize(mockController)
bannerService.getActiveBanners()
await new Promise((resolve) => setTimeout(resolve, 10))
expect(await getIdeParam(mockFetch)).to.equal("jetbrains")
})
})
})
describe("Rate Limit Backoff (429)", () => {
it("should trigger backoff on 429 response and return cached banners during backoff", async () => {
const clock = sandbox.useFakeTimers(Date.now())
+2 -9
View File
@@ -1,27 +1,20 @@
import { BrowserSettings, DEFAULT_BROWSER_SETTINGS } from "@shared/BrowserSettings" // Import the interface and defaults
import * as cheerio from "cheerio"
// @ts-ignore
import { Browser, Page } from "puppeteer-core"
import TurndownService from "turndown"
import * as vscode from "vscode"
import { StateManager } from "@/core/storage/StateManager"
import { ensureChromiumExists } from "./utils"
export class UrlContentFetcher {
private context: vscode.ExtensionContext
private browser?: Browser
private page?: Page
constructor(context: vscode.ExtensionContext) {
this.context = context
}
async launchBrowser(): Promise<void> {
if (this.browser) {
return
}
const stats = await ensureChromiumExists()
// Read browser settings from globalState for custom args only
const browserSettings = this.context.globalState.get<BrowserSettings>("browserSettings", DEFAULT_BROWSER_SETTINGS)
const browserSettings = StateManager.get().getGlobalSettingsKey("browserSettings")
const customArgsStr = browserSettings.customArgs || ""
const customArgs = customArgsStr.trim() ? customArgsStr.split(/\s+/) : []
this.browser = await stats.puppeteer.launch({
+11 -11
View File
@@ -2,15 +2,15 @@ import { expect } from "chai"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as nodeMachineId from "node-machine-id"
import * as sinon from "sinon"
import * as vscode from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import { _GENERATED_MACHINE_ID_KEY, getDistinctId, initializeDistinctId, setDistinctId } from "@/services/logging/distinctId"
import { StorageContext } from "@/shared/storage"
describe("distinctId", () => {
let sandbox: sinon.SinonSandbox
let mockContext: vscode.ExtensionContext
let mockStorage: StorageContext
let mockGlobalState: any
let hostProviderInitialized: boolean = false
let hostProviderInitialized = false
const MOCK_GLOBAL_STATE_ID = "existing-distinct-id-123"
const MOCK_MACHINE_ID = "machine-id-456"
@@ -55,8 +55,8 @@ describe("distinctId", () => {
// Mock global state
mockGlobalState = { get: sandbox.stub(), update: sandbox.stub() }
// Mock extension context
mockContext = { globalState: mockGlobalState } as unknown as vscode.ExtensionContext
// Mock extension storage
mockStorage = { globalState: mockGlobalState } as unknown as StorageContext
// Reset the distinctId module state
setDistinctId("")
@@ -76,7 +76,7 @@ describe("distinctId", () => {
mockGlobalState.get.withArgs(_GENERATED_MACHINE_ID_KEY).returns(MOCK_GLOBAL_STATE_ID)
const machineIdStub = sandbox.stub(nodeMachineId, "machineId")
await initializeDistinctId(mockContext, mockUuidGenerator)
await initializeDistinctId(mockStorage, mockUuidGenerator)
expect(getDistinctId()).to.equal(MOCK_GLOBAL_STATE_ID)
expect(machineIdStub.notCalled).to.be.true
@@ -87,7 +87,7 @@ describe("distinctId", () => {
// Mock node-machine-id to return a machine ID
const machineIdStub = sandbox.stub(nodeMachineId, "machineId").resolves(MOCK_MACHINE_ID)
await initializeDistinctId(mockContext, mockUuidGenerator)
await initializeDistinctId(mockStorage, mockUuidGenerator)
expect(getDistinctId()).to.equal(MOCK_MACHINE_ID)
expect(machineIdStub.calledOnce).to.be.true
@@ -99,10 +99,10 @@ describe("distinctId", () => {
// Mock node-machine-id to return a machine ID
sandbox.stub(nodeMachineId, "machineId").resolves(MOCK_MACHINE_ID)
await initializeDistinctId(mockContext, mockUuidGenerator)
await initializeDistinctId(mockStorage, mockUuidGenerator)
expect(getDistinctId()).to.equal(MOCK_MACHINE_ID)
await initializeDistinctId(mockContext, mockUuidGenerator)
await initializeDistinctId(mockStorage, mockUuidGenerator)
expect(getDistinctId()).to.equal(MOCK_MACHINE_ID)
expect(mockGlobalState.update.notCalled).to.be.true
@@ -113,7 +113,7 @@ describe("distinctId", () => {
// Mock node-machine-id to return empty string
const machineIdStub = sandbox.stub(nodeMachineId, "machineId").resolves("")
await initializeDistinctId(mockContext, mockUuidGenerator)
await initializeDistinctId(mockStorage, mockUuidGenerator)
expect(getDistinctId()).to.equal(GENERATED_MACHINE_ID)
expect(machineIdStub.calledOnce).to.be.true
@@ -125,7 +125,7 @@ describe("distinctId", () => {
// Mock node-machine-id to throw an error
const machineIdStub = sandbox.stub(nodeMachineId, "machineId").rejects(new Error("Failed to get machine ID"))
await initializeDistinctId(mockContext, mockUuidGenerator)
await initializeDistinctId(mockStorage, mockUuidGenerator)
expect(getDistinctId()).to.equal(GENERATED_MACHINE_ID)
expect(machineIdStub.calledOnce).to.be.true
+6 -6
View File
@@ -1,13 +1,13 @@
import { machineId } from "node-machine-id"
import { v4 as uuidv4 } from "uuid"
import { HostRegistryInfo } from "@/registry"
import { ClineExtensionContext } from "@/shared/cline/context"
import { Logger } from "@/shared/services/Logger"
import { StorageContext } from "@/shared/storage"
/*
* Unique identifier for the current installation.
*/
let _distinctId: string = ""
let _distinctId = ""
/**
* Some environments don't return a value for the machine ID. For these situations we generated
@@ -15,9 +15,9 @@ let _distinctId: string = ""
*/
export const _GENERATED_MACHINE_ID_KEY = "cline.generatedMachineId"
export async function initializeDistinctId(context: ClineExtensionContext, uuid: () => string = uuidv4) {
export async function initializeDistinctId(storage: StorageContext, uuid: () => string = uuidv4) {
// Try to read the ID from storage.
let distinctId = context.globalState.get<string>(_GENERATED_MACHINE_ID_KEY)
let distinctId = storage.globalState.get<string>(_GENERATED_MACHINE_ID_KEY)
if (!distinctId) {
// Get the ID from the host environment.
@@ -27,8 +27,8 @@ export async function initializeDistinctId(context: ClineExtensionContext, uuid:
// Fallback to generating a unique ID and keeping in global storage.
Logger.warn("No machine ID found for telemetry, generating UUID")
// Add a prefix to the UUID so we can see in the telemetry how many clients are don't have a machine ID.
distinctId = "cl-" + uuid()
context.globalState.update(_GENERATED_MACHINE_ID_KEY, distinctId)
distinctId = `cl-${uuid()}`
storage.globalState.update(_GENERATED_MACHINE_ID_KEY, distinctId)
}
setDistinctId(distinctId)
+3 -2
View File
@@ -3,7 +3,7 @@
*/
export type BannerSeverity = "info" | "success" | "warning"
export type BannerPlacement = "top" | "bottom"
export type BannerPlacement = "top" | "bottom" | "welcome"
export interface Banner {
id: string
@@ -16,7 +16,6 @@ export interface Banner {
activeFrom?: string
activeTo?: string
// Severity and placement are not used in the extension
severity?: BannerSeverity
placement?: BannerPlacement
}
@@ -58,6 +57,8 @@ export interface BannerRules {
org_type?: "all" | "team_only" | "enterprise_only" | ""
/** Minimum extension version required (e.g., "3.39.2") */
min_extension_version?: string
/** Optional actions embedded in rules JSON for banners */
actions?: BannerAction[]
}
/**
+1 -1
View File
@@ -91,7 +91,6 @@ export interface ExtensionState {
focusChainSettings: FocusChainSettings
dictationSettings: DictationSettings
customPrompt?: string
autoCondenseThreshold?: number
favoritedModelIds: string[]
// NEW: Add workspace information
workspaceRoots: WorkspaceRoot[]
@@ -112,6 +111,7 @@ export interface ExtensionState {
optOutOfRemoteConfig?: boolean
doubleCheckCompletionEnabled?: boolean
banners?: BannerCardData[]
welcomeBanners?: BannerCardData[]
openAiCodexIsAuthenticated?: boolean
}
+109 -20
View File
@@ -164,6 +164,29 @@ export const anthropicDefaultModelId: AnthropicModelId = "claude-sonnet-4-5-2025
export const ANTHROPIC_MIN_THINKING_BUDGET = 1_024
export const ANTHROPIC_MAX_THINKING_BUDGET = 6_000
export const anthropicModels = {
"claude-sonnet-4-6": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
},
"claude-sonnet-4-6:1m": {
maxTokens: 8192,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
tiers: CLAUDE_SONNET_1M_TIERS,
},
"claude-sonnet-4-5-20250929": {
maxTokens: 8192,
contextWindow: 200_000,
@@ -361,6 +384,16 @@ export const claudeCodeModels = {
supportsImages: false,
supportsPromptCache: false,
},
"claude-sonnet-4-6": {
...anthropicModels["claude-sonnet-4-6"],
supportsImages: false,
supportsPromptCache: false,
},
"claude-sonnet-4-6[1m]": {
...anthropicModels["claude-sonnet-4-6:1m"],
supportsImages: false,
supportsPromptCache: false,
},
"claude-sonnet-4-5-20250929": {
...anthropicModels["claude-sonnet-4-5-20250929"],
supportsImages: false,
@@ -418,6 +451,31 @@ export const claudeCodeModels = {
export type BedrockModelId = keyof typeof bedrockModels
export const bedrockDefaultModelId: BedrockModelId = "anthropic.claude-sonnet-4-5-20250929-v1:0"
export const bedrockModels = {
"anthropic.claude-sonnet-4-6": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
supportsGlobalEndpoint: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
},
"anthropic.claude-sonnet-4-6:1m": {
maxTokens: 8192,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
supportsGlobalEndpoint: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
tiers: CLAUDE_SONNET_1M_TIERS,
},
"anthropic.claude-sonnet-4-5-20250929-v1:0": {
maxTokens: 8192,
contextWindow: 200_000,
@@ -713,6 +771,7 @@ export const bedrockModels = {
export const openRouterDefaultModelId = "anthropic/claude-sonnet-4.5" // will always exist in openRouterModels
export const openRouterClaudeSonnet41mModelId = `anthropic/claude-sonnet-4${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeSonnet451mModelId = `anthropic/claude-sonnet-4.5${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeSonnet461mModelId = `anthropic/claude-sonnet-4.6${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeOpus461mModelId = `anthropic/claude-opus-4.6${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterDefaultModelInfo: ModelInfo = {
maxTokens: 8192,
@@ -724,7 +783,7 @@ export const openRouterDefaultModelInfo: ModelInfo = {
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
description:
"Claude Sonnet 4.5 delivers superior intelligence across coding, agentic search, and AI agent capabilities. It's a powerful choice for agentic coding, and can complete tasks across the entire software development lifecyclefrom initial planning to bug fixes, maintenance to large refactors. It offers strong performance in both planning and solving for complex coding tasks, making it an ideal choice to power end-to-end software development processes.\n\nRead more in the [blog post here](https://www.anthropic.com/claude/sonnet)",
"Claude Sonnet 4.5 delivers superior intelligence across coding, agentic search, and AI agent capabilities. It's a powerful choice for agentic coding, and can complete tasks across the entire software development lifecycle, from initial planning to bug fixes, maintenance to large refactors. It offers strong performance in both planning and solving for complex coding tasks, making it an ideal choice to power end-to-end software development processes.\n\nRead more in the [blog post here](https://www.anthropic.com/claude/sonnet)",
}
// Cline custom model - Devstral
@@ -862,6 +921,29 @@ export const vertexModels = {
supportsThinkingLevel: true,
},
},
"claude-sonnet-4-6": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
supportsReasoning: true,
},
"claude-sonnet-4-6:1m": {
maxTokens: 8192,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
supportsReasoning: true,
tiers: CLAUDE_SONNET_1M_TIERS,
},
"claude-sonnet-4-5@20250929": {
maxTokens: 8192,
contextWindow: 200_000,
@@ -2894,6 +2976,14 @@ export const askSageModels = {
inputPrice: 0,
outputPrice: 0,
},
"claude-4.6-sonnet": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"claude-4-sonnet": {
maxTokens: 8192,
contextWindow: 200_000,
@@ -3485,24 +3575,6 @@ export const cerebrasModels = {
outputPrice: 0,
description: "Intelligent model with ~1400 tokens/s",
},
"llama-3.3-70b": {
maxTokens: 64000,
contextWindow: 64000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Powerful model with ~2600 tokens/s",
},
"qwen-3-32b": {
maxTokens: 64000,
contextWindow: 64000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "SOTA coding performance with ~2500 tokens/s",
},
} as const satisfies Record<string, ModelInfo>
// Groq
@@ -3655,6 +3727,13 @@ export const sapAiCoreModels = {
supportsPromptCache: true,
description: sapAiCoreModelDescription,
},
"anthropic--claude-4.6-sonnet": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
description: sapAiCoreModelDescription,
},
"anthropic--claude-4.5-sonnet": {
maxTokens: 8192,
contextWindow: 200_000,
@@ -4361,8 +4440,18 @@ export const qwenCodeDefaultModelId: QwenCodeModelId = "qwen3-coder-plus"
// https://www.minimax.io/platform/document/text_api_intro
// https://www.minimax.io/platform/document/pricing
export type MinimaxModelId = keyof typeof minimaxModels
export const minimaxDefaultModelId: MinimaxModelId = "MiniMax-M2.1"
export const minimaxDefaultModelId: MinimaxModelId = "MiniMax-M2.5"
export const minimaxModels = {
"MiniMax-M2.5": {
maxTokens: 128_000,
contextWindow: 192_000,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 0.3,
outputPrice: 1.2,
cacheWritesPrice: 0.0375,
cacheReadsPrice: 0.03,
},
"MiniMax-M2.1": {
maxTokens: 128_000,
contextWindow: 192_000,
+19 -2
View File
@@ -87,13 +87,30 @@ export interface BannerAction {
*/
export const BANNER_DATA: BannerCardData[] = [
// Sonnet 4.6 banner
{
// Bump this version string when copy/CTA changes and you want the banner to reappear.
id: "claude-sonnet-4-6-2026-feb-18",
icon: "sparkles",
title: "Try Claude Sonnet 4.6",
description: "Anthropic's latest model with strong reasoning and coding performance.",
actions: [
{
title: "Use Sonnet 4.6",
action: BannerActionType.SetModel,
arg: "anthropic/claude-sonnet-4.6",
tab: "recommended",
},
],
},
// Minimax free promo banner
{
// Bump this version string when copy/CTA changes and you want the banner to reappear.
id: `minimax-m2.5-free-2026-feb-13`,
id: "minimax-m2.5-free-2026-feb-18",
icon: "zap",
title: "Try MiniMax M2.5 Free",
description: "SOTA open source model with great coding capability and subagent use.",
description: "SOTA coding capability with lightning fast inference, free in Cline.",
actions: [
{
title: "Try now",
+6 -40
View File
@@ -4,7 +4,6 @@
*/
import { URI } from "vscode-uri"
import { ClineMemento, ClineSecretStore } from "../storage"
enum ExtensionMode {
/**
@@ -40,39 +39,6 @@ export interface ClineExtensionContext {
dispose(): any
}[]
/**
* A memento object that stores state in the context
* of the currently opened {@link workspace.workspaceFolders workspace}.
*/
readonly workspaceState: ClineMemento
/**
* A memento object that stores state independent
* of the current opened {@link workspace.workspaceFolders workspace}.
*/
readonly globalState: ClineMemento & {
/**
* Set the keys whose values should be synchronized across devices when synchronizing user-data
* like configuration, extensions, and mementos.
*
* Note that this function defines the whole set of keys whose values are synchronized:
* - calling it with an empty array stops synchronization for this memento
* - calling it with a non-empty array replaces all keys whose values are synchronized
*
* For any given set of keys this function needs to be called only once but there is no harm in
* repeatedly calling it.
*
* @param keys The set of keys whose values are synced.
*/
setKeysForSync(keys: readonly string[]): void
}
/**
* A storage utility for secrets. Secrets are persisted across reloads and are independent of the
* current opened {@link workspace.workspaceFolders workspace}.
*/
readonly secrets: ClineSecretStore
/**
* The uri of the directory containing the extension.
*/
@@ -107,8 +73,8 @@ export interface ClineExtensionContext {
* up to the extension. However, the parent directory is guaranteed to be existent.
* The value is `undefined` when no workspace nor folder has been opened.
*
* Use {@linkcode ExtensionContext.workspaceState workspaceState} or
* {@linkcode ExtensionContext.globalState globalState} to store key value data.
* Use {@linkcode StateManager.workspaceState} or
* {@linkcode StateManager.globalState} to store key value data.
*
* @see {@linkcode FileSystem workspace.fs} for how to read and write files and folders from
* an uri.
@@ -120,8 +86,8 @@ export interface ClineExtensionContext {
* can store private state. The directory might not exist on disk and creation is
* up to the extension. However, the parent directory is guaranteed to be existent.
*
* Use {@linkcode ExtensionContext.workspaceState workspaceState} or
* {@linkcode ExtensionContext.globalState globalState} to store key value data.
* Use {@linkcode StateManager.workspaceState} or
* {@linkcode StateManager.globalState} to store key value data.
*
* @deprecated Use {@link ExtensionContext.storageUri storageUri} instead.
*/
@@ -132,7 +98,7 @@ export interface ClineExtensionContext {
* The directory might not exist on disk and creation is
* up to the extension. However, the parent directory is guaranteed to be existent.
*
* Use {@linkcode ExtensionContext.globalState globalState} to store key value data.
* Use {@linkcode StateManager.globalState} to store key value data.
*
* @see {@linkcode FileSystem workspace.fs} for how to read and write files and folders from
* an uri.
@@ -144,7 +110,7 @@ export interface ClineExtensionContext {
* The directory might not exist on disk and creation is
* up to the extension. However, the parent directory is guaranteed to be existent.
*
* Use {@linkcode ExtensionContext.globalState globalState} to store key value data.
* Use {@linkcode StateManager.globalState} to store key value data.
*
* @deprecated Use {@link ExtensionContext.globalStorageUri globalStorageUri} instead.
*/
+16
View File
@@ -21,6 +21,22 @@ export const CLINE_ONBOARDING_MODELS: OnboardingModel[] = [
tiers: [],
},
},
{
group: "free",
id: "minimax/minimax-m2.5",
name: "MiniMax: MiniMax M2.5",
score: 90,
latency: 2,
badge: "New",
info: {
contextWindow: 192_000,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 0,
outputPrice: 0,
tiers: [],
},
},
{
group: "free",
id: "arcee-ai/trinity-large-preview:free",
@@ -7,6 +7,9 @@ export enum FeatureFlag {
ONBOARDING_MODELS = "onboarding_models",
// Feature flag for remote banner service
REMOTE_BANNERS = "remote-banners",
// Feature flag for DB-backed welcome banners (What's New modal)
// When off, hardcoded welcome items are shown instead
REMOTE_WELCOME_BANNERS = "remote-welcome-banners",
}
export const FeatureFlagDefaultValue: Partial<Record<FeatureFlag, FeatureFlagPayload>> = {
@@ -14,6 +17,7 @@ export const FeatureFlagDefaultValue: Partial<Record<FeatureFlag, FeatureFlagPay
[FeatureFlag.WORKTREES]: false,
[FeatureFlag.ONBOARDING_MODELS]: process.env.E2E_TEST === "true" ? { models: {} } : undefined,
[FeatureFlag.REMOTE_BANNERS]: process.env.E2E_TEST === "true" || process.env.IS_DEV === "true",
[FeatureFlag.REMOTE_WELCOME_BANNERS]: process.env.E2E_TEST === "true" || process.env.IS_DEV === "true",
}
export const FEATURE_FLAGS = Object.values(FeatureFlag)
+6
View File
@@ -85,4 +85,10 @@ export const CLI_ONLY_COMMANDS: SlashCommand[] = [
section: "default",
cliCompatible: true,
},
{
name: "skills",
description: "View and manage installed skills",
section: "default",
cliCompatible: true,
},
]
+65 -10
View File
@@ -3,6 +3,14 @@ import * as path from "node:path"
import { Logger } from "../services/Logger"
import { ClineSyncStorage } from "./ClineStorage"
export interface ClineFileStorageOptions {
/**
* File permissions mode (e.g., 0o600 for owner read/write only).
* If not set, uses the system default.
*/
fileMode?: number
}
/**
* Synchronous file-backed JSON storage.
* Stores any JSON-serializable values with sync read and write.
@@ -12,11 +20,13 @@ export class ClineFileStorage<T = any> extends ClineSyncStorage<T> {
protected name: string
private data: Record<string, T>
private readonly fsPath: string
private readonly fileMode?: number
constructor(filePath: string, name = "ClineFileStorage") {
constructor(filePath: string, name = "ClineFileStorage", options?: ClineFileStorageOptions) {
super()
this.fsPath = filePath
this.name = name
this.fileMode = options?.fileMode
this.data = this.readFromDisk()
}
@@ -25,17 +35,39 @@ export class ClineFileStorage<T = any> extends ClineSyncStorage<T> {
}
protected _set(key: string, value: T | undefined): void {
if (value === undefined) {
delete this.data[key]
} else {
this.data[key] = value
}
this.writeToDisk()
// Use setBatch for consistency - all writes go through one path
this.setBatch({ [key]: value })
}
protected _delete(key: string): void {
delete this.data[key]
this.writeToDisk()
this.setBatch({ [key]: undefined })
}
/**
* Set multiple keys in a single write operation.
* More efficient than calling set() for each key individually,
* since it only writes to disk once.
*/
public setBatch(entries: Record<string, T | undefined>): Thenable<void> {
const changedKeys: string[] = []
for (const [key, value] of Object.entries(entries)) {
if (value === undefined) {
if (key in this.data) {
delete this.data[key]
changedKeys.push(key)
}
} else {
this.data[key] = value
changedKeys.push(key)
}
}
if (changedKeys.length > 0) {
this.writeToDisk()
for (const key of changedKeys) {
this.fireChange(key)
}
}
return Promise.resolve()
}
protected _keys(): readonly string[] {
@@ -57,9 +89,32 @@ export class ClineFileStorage<T = any> extends ClineSyncStorage<T> {
try {
const dir = path.dirname(this.fsPath)
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(this.fsPath, JSON.stringify(this.data, null, 2), "utf-8")
atomicWriteFileSync(this.fsPath, JSON.stringify(this.data, null, 2), this.fileMode)
} catch (error) {
Logger.error(`[${this.name}] failed to write to ${this.fsPath}:`, error)
}
}
}
/**
* Synchronously, atomically write data to a file using temp file + rename pattern.
* Prefer core/storage's async atomicWriteFile to this.
*/
function atomicWriteFileSync(filePath: string, data: string, mode?: fs.Mode | undefined): void {
const tmpPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).substring(7)}.json`
try {
fs.writeFileSync(tmpPath, data, {
flag: "wx",
encoding: "utf-8",
mode,
})
// Rename temp file to target (atomic in most cases)
fs.renameSync(tmpPath, filePath)
} catch (error) {
// Clean up temp file if it exists
try {
fs.unlinkSync(tmpPath)
} catch {}
throw error
}
}
-77
View File
@@ -1,77 +0,0 @@
import { Logger } from "../services/Logger"
import { ClineStorage } from "./ClineStorage"
export type SecretStores = VSCodeSecretStorage | ClineStorage
/**
* Wrapper around VSCode Secret Storage or any other storage type for managing secrets.
*/
export class ClineSecretStorage extends ClineStorage {
override readonly name = "ClineSecretStorage"
private static store: ClineSecretStorage | null = null
static get instance(): ClineSecretStorage {
if (!ClineSecretStorage.store) {
ClineSecretStorage.store = new ClineSecretStorage()
}
return ClineSecretStorage.store
}
private secretStorage: SecretStores | null = null
public get storage(): SecretStores {
if (!this.secretStorage) {
throw new Error("[ClineSecretStorage] init not called")
}
return this.secretStorage
}
public init(store: SecretStores) {
if (!this.secretStorage) {
this.secretStorage = store
Logger.info("[ClineSecretStorage] initialized")
}
return this.secretStorage
}
protected async _get(key: string): Promise<string | undefined> {
try {
return key ? await this.storage.get(key) : undefined
} catch {
return undefined
}
}
/**
* [SECURITY] Avoid logging secrets values.
*/
protected async _store(key: string, value: string): Promise<void> {
try {
if (value && value.length > 0) {
await this.storage.store(key, value)
} else {
await this.storage.delete(key)
}
} catch (error) {
Logger.error("[ClineSecretStorage] Failed to store", error)
}
}
protected async _delete(key: string): Promise<void> {
await this.storage.delete(key)
}
}
interface VSCodeSecretStorage {
get(key: string): Thenable<string | undefined>
store(key: string, value: string): Thenable<void>
delete(key: string): Thenable<void>
onDidChange: any
}
/**
* Singleton instance of ClineSecretStorage
*/
export const secretStorage = ClineSecretStorage.instance
+23 -33
View File
@@ -19,17 +19,11 @@ export interface ClineMemento {
get<T>(key: string, defaultValue: T): T
update(key: string, value: any): Thenable<void>
keys(): readonly string[]
}
/**
* SecretStorage-compatible interface for async secret storage.
* VSCode's SecretStorage and ClineStorage both satisfy this interface.
*/
export interface ClineSecretStore {
get(key: string): Thenable<string | undefined>
store(key: string, value: string): Thenable<void>
delete(key: string): Thenable<void>
onDidChange: any
/**
* Set multiple keys in a single operation.
* More efficient than calling update() for each key individually.
*/
setBatch(entries: Record<string, any>): Thenable<void>
}
/**
@@ -60,13 +54,6 @@ export abstract class ClineStorage {
}
}
/**
* Fire storage change event to all subscribers.
*/
protected async fire(key: string): Promise<void> {
await Promise.all(this.subscribers.map((subscriber) => subscriber({ key })))
}
/**
* Get a value from storage. This method is final and cannot be overridden.
* Subclasses should implement _get() to define their storage retrieval logic.
@@ -81,7 +68,6 @@ export abstract class ClineStorage {
async _dangerousStore(key: string, value: string): Promise<void> {
await this._store(key, value)
await this.fire(key)
}
/**
@@ -105,7 +91,6 @@ export abstract class ClineStorage {
public async delete(key: string): Promise<void> {
try {
await this._delete(key)
await this.fire(key)
} catch {
// Silently fail on delete errors
}
@@ -137,33 +122,38 @@ export abstract class ClineStorage {
* values and provides synchronous access - required for VSCode Memento compatibility.
*/
export type SyncStorageEventListener = (event: ClineStorageChangeEvent) => void
export abstract class ClineSyncStorage<T = any> {
protected abstract name: string
private readonly subscribers: Array<StorageEventListener> = []
/**
* List of subscribers to storage change events.
*/
private readonly changeSubscribers: Array<SyncStorageEventListener> = []
/**
* Subscribe to storage change events.
* Subscribe to storage change events. Returns an unsubscribe function.
*/
public onDidChange(callback: StorageEventListener): () => void {
this.subscribers.push(callback)
public onDidChange(callback: SyncStorageEventListener): () => void {
this.changeSubscribers.push(callback)
return () => {
const callbackIndex = this.subscribers.indexOf(callback)
if (callbackIndex >= 0) {
this.subscribers.splice(callbackIndex, 1)
const idx = this.changeSubscribers.indexOf(callback)
if (idx >= 0) {
this.changeSubscribers.splice(idx, 1)
}
}
}
/**
* Fire storage change event to all subscribers.
* Notify all subscribers of a key change.
*/
protected fire(key: string): void {
for (const subscriber of this.subscribers) {
protected fireChange(key: string): void {
for (const subscriber of this.changeSubscribers) {
try {
subscriber({ key })
} catch (error) {
Logger.error(`[${this.name}] subscriber error for '${key}':`, error)
Logger.error(`[${this.name}] change subscriber error for '${key}':`, error)
}
}
}
@@ -191,7 +181,7 @@ export abstract class ClineSyncStorage<T = any> {
public set(key: string, value: T | undefined): void {
try {
this._set(key, value)
this.fire(key)
this.fireChange(key)
} catch (error) {
Logger.error(`[${this.name}] failed to set '${key}':`, error)
}
@@ -200,7 +190,7 @@ export abstract class ClineSyncStorage<T = any> {
public delete(key: string): void {
try {
this._delete(key)
this.fire(key)
this.fireChange(key)
} catch (error) {
Logger.error(`[${this.name}] failed to delete '${key}':`, error)
}
+1 -1
View File
@@ -1,7 +1,7 @@
export * from "./ClineBlobStorage"
export * from "./ClineFileStorage"
export * from "./ClineSecretStorage"
export * from "./ClineStorage"
export * from "./provider-keys"
export * from "./state-keys"
export * from "./storage-context"
export * from "./types"
+2 -2
View File
@@ -1,6 +1,6 @@
// Map providers to their specific model ID keys
import { SettingsKey } from "@shared/storage/state-keys"
import { Secrets, SettingsKey } from "@shared/storage/state-keys"
import {
ApiProvider,
anthropicDefaultModelId,
@@ -47,7 +47,7 @@ const ProviderKeyMap: Partial<Record<ApiProvider, string>> = {
"vercel-ai-gateway": "VercelAiGatewayModelId",
} as const
export const ProviderToApiKeyMap: Partial<Record<ApiProvider, string | string[]>> = {
export const ProviderToApiKeyMap: Partial<Record<ApiProvider, keyof Secrets | (keyof Secrets)[]>> = {
cline: ["clineApiKey", "clineAccountId"],
anthropic: "apiKey",
openrouter: "openRouterApiKey",
+4 -1
View File
@@ -65,6 +65,8 @@ const REMOTE_CONFIG_EXTRA_FIELDS = {
} satisfies FieldDefinitions
const GLOBAL_STATE_FIELDS = {
clineVersion: { default: undefined as string | undefined },
"cline.generatedMachineId": { default: undefined as string | undefined }, // Note, distinctId reads/writes this directly from/to StorageContext before StateManager is initialized.
lastShownAnnouncementId: { default: undefined as string | undefined },
taskHistory: { default: [] as HistoryItem[], isAsync: true },
userInfo: { default: undefined as UserInfo | undefined },
@@ -266,7 +268,6 @@ const USER_SETTINGS_FIELDS = {
},
focusChainSettings: { default: DEFAULT_FOCUS_CHAIN_SETTINGS as FocusChainSettings },
customPrompt: { default: undefined as "compact" | undefined },
autoCondenseThreshold: { default: 0.75 as number }, // number from 0 to 1
enableParallelToolCalling: { default: true as boolean },
backgroundEditEnabled: { default: false as boolean },
optOutOfRemoteConfig: { default: false as boolean },
@@ -346,6 +347,8 @@ const SECRETS_KEYS = [
"openai-codex-oauth-credentials", // JSON blob containing OAuth tokens for OpenAI Codex (ChatGPT subscription)
] as const
// WARNING, these are not ALL of the local state keys in practice. For example, FileContextTracker
// uses dynamic keys like pendingFileContextWarning_${taskId}.
export const LocalStateKeys = [
"localClineRulesToggles",
"localCursorRulesToggles",
+126
View File
@@ -0,0 +1,126 @@
import fsSync from "node:fs"
import os from "node:os"
import path from "node:path"
import { ClineFileStorage } from "./ClineFileStorage"
import { ClineMemento } from "./ClineStorage"
/**
* The storage backend context object used by StateManager and other components.
* Global, workspace and secret key-value storage goes through this component.
*
* This replaces the previous pattern of passing VSCode's ExtensionContext around
* for storage access. All platforms (VSCode, CLI, JetBrains) use the same
* file-backed implementation.
*/
export interface StorageContext {
/** Global state — settings, task history references, UI state, etc. */
readonly globalState: ClineMemento
// TODO: Privatize this field after StorageContext becomes class with a reset method.
/**
* The backing store for global state. Prefer `globalState` when possible.
*
* This split exists because CLI needs to intercept the ClineMemento interface to global state,
* but state resets need to write through to the backing store.
*/
readonly globalStateBackingStore: ClineFileStorage
/** Secrets — API keys and other sensitive values. File uses restricted permissions (0o600). */
readonly secrets: ClineFileStorage<string>
/** Workspace-scoped state — per-project toggles, rules, etc. */
readonly workspaceState: ClineFileStorage
/** The resolved path to the data directory (~/.cline/data) */
readonly dataDir: string
/** The resolved path to the workspace storage directory (contains workspaceState.json) */
readonly workspaceStoragePath: string
}
export interface StorageContextOptions {
/**
* Override the Cline home directory. Defaults to CLINE_DIR env var or ~/.cline.
*/
clineDir?: string
/**
* The workspace/project directory path. Used to compute a hash-based
* workspace storage subdirectory. Defaults to process.cwd().
*/
workspacePath?: string
/**
* Explicit workspace storage directory override.
* When set, this path is used directly instead of computing a hash.
* Used by JetBrains (via WORKSPACE_STORAGE_DIR env var).
*
* TODO: Unify JetBrains workspace path scheme with the hash-based approach
* once the JetBrains client side is cleaned up.
*/
workspaceStorageDir?: string
}
const SETTINGS_SUBFOLDER = "data"
/**
* Create a short deterministic hash of a string for use in directory names.
* Produces an up-to-8-character hex string.
*/
function hashString(str: string): string {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash // Convert to 32-bit integer
}
return Math.abs(hash).toString(16).substring(0, 8)
}
/**
* Creates a StorageContext backed by JSON files on disk.
*
* All path computation is contained here callers should not
* construct paths to these storage files themselves.
*
* File layout:
* ~/.cline/data/globalState.json global state
* ~/.cline/data/secrets.json secrets (mode 0o600)
* ~/.cline/data/workspaces/<hash>/workspaceState.json per-workspace state
*
* @param opts Configuration options for path resolution
* @returns A StorageContext ready for use by StateManager
*/
export function createStorageContext(opts: StorageContextOptions = {}): StorageContext {
const clineDir = opts.clineDir || process.env.CLINE_DIR || path.join(os.homedir(), ".cline")
const dataDir = path.join(clineDir, SETTINGS_SUBFOLDER)
// Resolve workspace storage directory
let workspaceDir: string
if (opts.workspaceStorageDir) {
// Explicit override (JetBrains via env var, or test overrides)
workspaceDir = opts.workspaceStorageDir
} else {
// Hash-based workspace isolation (CLI, VSCode)
const workspacePath = opts.workspacePath || process.cwd()
const workspaceHash = hashString(workspacePath)
workspaceDir = path.join(dataDir, "workspaces", workspaceHash)
}
// Ensure directories exist
fsSync.mkdirSync(dataDir, { recursive: true })
fsSync.mkdirSync(workspaceDir, { recursive: true })
const globalState = new ClineFileStorage(path.join(dataDir, "globalState.json"), "GlobalState")
return {
globalState,
globalStateBackingStore: globalState,
secrets: new ClineFileStorage<string>(path.join(dataDir, "secrets.json"), "Secrets", {
fileMode: 0o600, // Owner read/write only — protects API keys
}),
workspaceState: new ClineFileStorage(path.join(workspaceDir, "workspaceState.json"), "WorkspaceState"),
dataDir,
workspaceStoragePath: workspaceDir,
}
}
+4 -1
View File
@@ -12,6 +12,7 @@ import { HostProvider } from "@/hosts/host-provider"
import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
import { StandaloneTerminalManager } from "@/integrations/terminal"
import { Logger } from "@/shared/services/Logger"
import { createStorageContext } from "@/shared/storage/storage-context"
import { HOSTBRIDGE_PORT, waitForHostBridgeReady } from "./hostbridge-client"
import { setLockManager } from "./lock-manager"
import { PROTOBUS_PORT, startProtobusService } from "./protobus-service"
@@ -60,7 +61,9 @@ async function main() {
// The host bridge should be available before creating the host provider because it depends on the host bridge.
setupHostProvider(extensionContext, EXTENSION_DIR, DATA_DIR)
const webviewProvider = await initialize(extensionContext)
// Create shared file-backed storage
const storageContext = createStorageContext()
const webviewProvider = await initialize(storageContext)
// Enable the localhost HTTP server that handles auth redirects.
AuthHandler.getInstance().setEnabled(true)
+8 -24
View File
@@ -1,4 +1,4 @@
import type { Locator, Page } from "@playwright/test"
import type { Page } from "@playwright/test"
export const openTab = async (_page: Page, tabName: string) => {
await _page
@@ -8,36 +8,20 @@ export const openTab = async (_page: Page, tabName: string) => {
}
export const addSelectedCodeToClineWebview = async (_page: Page) => {
const clickActionIfVisible = async (locator: Locator) => {
try {
await locator.waitFor({ state: "visible", timeout: 5000 })
await locator.click({ delay: 100 })
return true
} catch {
return false
}
}
await _page.locator("div:nth-child(4) > span > span").first().click()
await _page.getByRole("textbox", { name: "The editor is not accessible" }).press("ControlOrMeta+a")
// Open Code Actions via keyboard for cross-platform reliability
await _page.keyboard.press("ControlOrMeta+.")
// Target the explicit action instead of pressing Enter on the first item.
// The first item can vary by platform or diagnostics.
const addToClineOption = _page.getByRole("option", { name: /Add to Cline/i }).first()
const addToClineMenuItem = _page.getByRole("menuitem", { name: /Add to Cline/i }).first()
if (await clickActionIfVisible(addToClineOption)) {
return
}
if (await clickActionIfVisible(addToClineMenuItem)) {
return
}
// Fallback for unexpected code action UIs.
await _page.keyboard.press("Enter", { delay: 100 })
const addToCline = _page.getByText(/Add to Cline/i)
await addToCline.waitFor({ state: "visible" })
// For whatever reason, we need to move the mouse to make the context menu item clickable
await _page.mouse.move(10, 10)
await _page.mouse.move(20, 10)
await addToCline.click()
}
export const toggleNotifications = async (_page: Page) => {
+39 -4
View File
@@ -230,6 +230,9 @@ export const e2e = test
const executablePath = await downloadAndUnzipVSCode(channel, undefined, new SilentReporter())
await use(async (workspacePath: string) => {
// Create isolated Cline data directory for this test
const clineTestDir = mkdtempSync(path.join(os.tmpdir(), "cline-e2e-"))
const app = await _electron.launch({
executablePath,
env: {
@@ -237,6 +240,7 @@ export const e2e = test
TEMP_PROFILE: "true",
E2E_TEST: "true",
CLINE_ENVIRONMENT: "local",
CLINE_DIR: clineTestDir, // Isolate test data from user's ~/.cline
GRPC_RECORDER_FILE_NAME: E2ETestHelper.generateTestFileName(testInfo.title, testInfo.project.name),
// GRPC_RECORDER_ENABLED: "true",
// GRPC_RECORDER_TESTS_FILTERS_ENABLED: "true"
@@ -264,22 +268,53 @@ export const e2e = test
})
},
})
.extend<{ app: ElectronApplication }>({
.extend<{ app: ElectronApplication; clineTestDir: string }>({
app: async ({ openVSCode, userDataDir, extensionsDir, workspaceType, workspaceDir, multiRootWorkspaceDir }, use) => {
const workspacePath = workspaceType === "single" ? workspaceDir : multiRootWorkspaceDir
// Track the clineTestDir created in openVSCode
let clineTestDir: string | undefined
const originalOpenVSCode = openVSCode
const wrappedOpenVSCode = async (wp: string) => {
const app = await originalOpenVSCode(wp)
// Extract CLINE_DIR from the launched app's environment
// We'll need to pass it through the fixture chain
return app
}
const app = await openVSCode(workspacePath)
try {
await use(app)
} finally {
await app.close()
// Cleanup in parallel
await Promise.allSettled([
// Cleanup in parallel - include clineTestDir if it was created
const cleanupTasks = [
E2ETestHelper.rmForRetries(userDataDir, { recursive: true }),
E2ETestHelper.rmForRetries(extensionsDir, { recursive: true }),
])
]
// Clean up the isolated Cline data directory
// Find all temp directories matching our pattern
const tmpDir = os.tmpdir()
try {
const entries = require("node:fs").readdirSync(tmpDir)
for (const entry of entries) {
if (entry.startsWith("cline-e2e-")) {
cleanupTasks.push(E2ETestHelper.rmForRetries(path.join(tmpDir, entry), { recursive: true }))
}
}
} catch (error) {
// Ignore cleanup errors
}
await Promise.allSettled(cleanupTasks)
}
},
clineTestDir: async ({}, use) => {
// This will be set by the openVSCode fixture
await use("")
},
})
.extend<{ helper: E2ETestHelper }>({
helper: async ({}, use) => {
+12 -35
View File
@@ -160,7 +160,6 @@
"version": "7.28.4",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -501,7 +500,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -523,7 +521,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -531,7 +528,6 @@
"node_modules/@emotion/is-prop-valid": {
"version": "1.2.2",
"license": "MIT",
"peer": true,
"dependencies": {
"@emotion/memoize": "^0.8.1"
}
@@ -1037,7 +1033,6 @@
"node_modules/@firebase/app": {
"version": "0.13.2",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@firebase/component": "0.6.18",
"@firebase/logger": "0.4.4",
@@ -1094,7 +1089,6 @@
"node_modules/@firebase/app-compat": {
"version": "0.4.2",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@firebase/app": "0.13.2",
"@firebase/component": "0.6.18",
@@ -1108,8 +1102,7 @@
},
"node_modules/@firebase/app-types": {
"version": "0.9.3",
"license": "Apache-2.0",
"peer": true
"license": "Apache-2.0"
},
"node_modules/@firebase/auth-compat": {
"version": "0.5.28",
@@ -1496,7 +1489,6 @@
"version": "1.12.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
},
@@ -2593,7 +2585,6 @@
"node_modules/@heroui/system": {
"version": "2.4.22",
"license": "MIT",
"peer": true,
"dependencies": {
"@heroui/react-utils": "2.1.13",
"@heroui/system-rsc": "2.3.19",
@@ -2678,7 +2669,6 @@
"node_modules/@heroui/theme": {
"version": "2.4.22",
"license": "MIT",
"peer": true,
"dependencies": {
"@heroui/shared-utils": "2.1.11",
"clsx": "^1.2.1",
@@ -6361,7 +6351,8 @@
"node_modules/@types/aria-query": {
"version": "5.0.4",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -6738,7 +6729,6 @@
"node_modules/@types/react": {
"version": "18.3.18",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -6748,7 +6738,6 @@
"version": "18.3.7",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@@ -7140,7 +7129,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
@@ -7270,7 +7258,6 @@
"node_modules/chevrotain": {
"version": "11.0.3",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@chevrotain/cst-dts-gen": "11.0.3",
"@chevrotain/gast": "11.0.3",
@@ -7541,7 +7528,6 @@
"node_modules/cytoscape": {
"version": "3.33.1",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10"
}
@@ -7874,7 +7860,6 @@
"node_modules/d3-selection": {
"version": "3.0.0",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -8101,7 +8086,8 @@
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/dompurify": {
"version": "3.2.7",
@@ -8164,7 +8150,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -8445,7 +8430,6 @@
"node_modules/framer-motion": {
"version": "12.23.18",
"license": "MIT",
"peer": true,
"dependencies": {
"motion-dom": "^12.23.18",
"motion-utils": "^12.23.6",
@@ -9388,7 +9372,6 @@
"version": "26.1.0",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@@ -9859,6 +9842,7 @@
"version": "1.5.0",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -11761,6 +11745,7 @@
"version": "27.5.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -11774,6 +11759,7 @@
"version": "5.2.0",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -11836,7 +11822,6 @@
"node_modules/react": {
"version": "18.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -11875,7 +11860,6 @@
"node_modules/react-dom": {
"version": "18.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -11887,7 +11871,8 @@
"node_modules/react-is": {
"version": "17.0.2",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-markdown": {
"version": "10.1.0",
@@ -12551,7 +12536,6 @@
"version": "4.52.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -12875,7 +12859,6 @@
"version": "9.1.17",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@storybook/global": "^5.0.0",
"@testing-library/jest-dom": "^6.6.3",
@@ -13141,7 +13124,6 @@
"node_modules/tailwind-merge": {
"version": "3.3.1",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
@@ -13166,8 +13148,7 @@
},
"node_modules/tailwindcss": {
"version": "4.1.13",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
@@ -13379,14 +13360,12 @@
},
"node_modules/tslib": {
"version": "2.8.1",
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.2",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13739,7 +13718,6 @@
"version": "7.2.2",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -13861,7 +13839,6 @@
"version": "3.2.4",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
-1
View File
@@ -240,7 +240,6 @@ const mockStreamingMessages: ClineMessage[] = [
const createMockState = (overrides: any = {}) => ({
...useExtensionState(),
useAutoCondense: true,
autoCondenseThreshold: 0.5,
version: "0.0.1-stories",
welcomeViewCompleted: true,
showWelcome: false,
@@ -455,6 +455,17 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
)
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isSelectAllShortcut =
(event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === "a"
if (isSelectAllShortcut) {
event.preventDefault()
event.stopPropagation()
const textArea = event.currentTarget
textArea.setSelectionRange(0, textArea.value.length)
setCursorPosition(0)
return
}
if (showSlashCommandsMenu) {
if (event.key === "Escape") {
setShowSlashCommandsMenu(false)
@@ -69,12 +69,10 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({
}
setIsProcessing(true)
// Special handling for cancel action
if (action === "cancel") {
void messageHandlers.executeButtonAction(action, text, images, files).catch(() => {
// Reset processing state on errors to avoid getting stuck.
setIsProcessing(false)
}
messageHandlers.executeButtonAction(action, text, images, files)
})
},
[messageHandlers, isProcessing],
)
@@ -85,10 +83,10 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({
if (event.key === "Escape") {
event.preventDefault()
event.stopPropagation()
messageHandlers.executeButtonAction("cancel")
handleActionClick("cancel")
}
},
[messageHandlers],
[handleActionClick],
)
useEffect(() => {
@@ -3,7 +3,7 @@ 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 React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import BannerCarousel from "@/components/common/BannerCarousel"
import WhatsNewModal from "@/components/common/WhatsNewModal"
import HistoryPreview from "@/components/history/HistoryPreview"
@@ -37,6 +37,7 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
// Track if we've shown the "What's New" modal this session
const [hasShownWhatsNewModal, setHasShownWhatsNewModal] = useState(false)
const [showWhatsNewModal, setShowWhatsNewModal] = useState(false)
const bannerWaitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Quick launch worktree modal
const [showCreateWorktreeModal, setShowCreateWorktreeModal] = useState(false)
@@ -65,22 +66,52 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
navigateToWorktrees,
worktreesEnabled,
banners,
welcomeBanners,
} = useExtensionState()
const { handleFieldsChange } = useApiConfigurationHandlers()
// Show modal when there's a new announcement and we haven't shown it this session
// Show modal when there's a new announcement and we haven't shown it this session.
// We delay opening slightly to wait for welcome banners from the backend API,
// which are fetched asynchronously and may not be available on the first state push.
// The modal opens immediately if banners arrive, or after a 3s timeout as fallback.
useEffect(() => {
if (showAnnouncement && !hasShownWhatsNewModal) {
if (showAnnouncement && !hasShownWhatsNewModal && !bannerWaitTimeoutRef.current) {
bannerWaitTimeoutRef.current = setTimeout(() => {
bannerWaitTimeoutRef.current = null
setShowWhatsNewModal(true)
setHasShownWhatsNewModal(true)
}, 3000)
}
return () => {
if (bannerWaitTimeoutRef.current) {
clearTimeout(bannerWaitTimeoutRef.current)
bannerWaitTimeoutRef.current = null
}
}
}, [showAnnouncement, hasShownWhatsNewModal])
// Open modal early if welcome banners arrive before the timeout
useEffect(() => {
if (bannerWaitTimeoutRef.current && welcomeBanners && welcomeBanners.length > 0) {
if (bannerWaitTimeoutRef.current) {
clearTimeout(bannerWaitTimeoutRef.current)
bannerWaitTimeoutRef.current = null
}
setShowWhatsNewModal(true)
setHasShownWhatsNewModal(true)
}
}, [showAnnouncement, hasShownWhatsNewModal])
}, [welcomeBanners])
const handleCloseWhatsNewModal = useCallback(() => {
setShowWhatsNewModal(false)
// Call hideAnnouncement to persist dismissal (same as old banner behavior)
hideAnnouncement()
}, [hideAnnouncement])
if (welcomeBanners && welcomeBanners.length > 0) {
for (const banner of welcomeBanners) {
StateServiceClient.dismissBanner({ value: banner.id }).catch(console.error)
}
}
}, [hideAnnouncement, welcomeBanners])
// Handle click on home page worktree element with telemetry
const handleWorktreeClick = useCallback(() => {
@@ -154,7 +185,7 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
break
case BannerActionType.SetModel: {
const modelId = action.arg || "anthropic/claude-opus-4.6"
const modelId = action.arg || "anthropic/claude-sonnet-4.5"
const initialModelTab = action.tab || "recommended"
handleFieldsChange({
planModeOpenRouterModelId: modelId,
@@ -246,7 +277,13 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
return (
<div className="flex flex-col flex-1 w-full h-full p-0 m-0">
<WhatsNewModal onClose={handleCloseWhatsNewModal} open={showWhatsNewModal} version={version} />
<WhatsNewModal
onBannerAction={handleBannerAction}
onClose={handleCloseWhatsNewModal}
open={showWhatsNewModal}
version={version}
welcomeBanners={welcomeBanners}
/>
<div className="overflow-y-auto flex flex-col pb-2.5">
<HomeHeader shouldShowQuickWins={shouldShowQuickWins} />
{!showWhatsNewModal && (
@@ -1,7 +1,7 @@
import type { ClineMessage } from "@shared/ExtensionMessage"
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
import { AskResponseRequest, NewTaskRequest } from "@shared/proto/cline/task"
import { useCallback } from "react"
import { useCallback, useRef } from "react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { SlashServiceClient, TaskServiceClient } from "@/services/grpc-client"
import type { ButtonActionType } from "../shared/buttonConfig"
@@ -24,6 +24,7 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
clineAsk,
lastMessage,
} = chatState
const cancelInFlightRef = useRef(false)
// Handle sending a message
const handleSendMessage = useCallback(
@@ -249,16 +250,28 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
}
break
case "cancel":
if (backgroundCommandRunning) {
await TaskServiceClient.cancelBackgroundCommand(EmptyRequest.create({}))
} else {
await TaskServiceClient.cancelTask(EmptyRequest.create({}))
case "cancel": {
if (cancelInFlightRef.current) {
return
}
cancelInFlightRef.current = true
setSendingDisabled(true)
setEnableButtons(false)
try {
if (backgroundCommandRunning) {
await TaskServiceClient.cancelBackgroundCommand(EmptyRequest.create({})).catch((err) =>
console.error("Failed to cancel background command:", err),
)
}
await TaskServiceClient.cancelTask(EmptyRequest.create({}))
} finally {
cancelInFlightRef.current = false
// Clear any pending state that might interfere with resume
setSendingDisabled(false)
setEnableButtons(true)
}
// Clear any pending state that might interfere with resume
setSendingDisabled(false)
setEnableButtons(true)
break
}
case "utility":
switch (clineAsk) {
@@ -1,129 +0,0 @@
import { cn } from "@heroui/react"
import React, { useEffect, useMemo, useRef, useState } from "react"
export const AutoCondenseMarker: React.FC<{
threshold: number
usage: number
isContextWindowHoverOpen?: boolean
shouldAnimate?: boolean
}> = ({ threshold, usage, isContextWindowHoverOpen, shouldAnimate = false }) => {
const [isAnimating, setIsAnimating] = useState(false)
const [animatedPosition, setAnimatedPosition] = useState(0)
const [showPercentageAfterAnimation, setShowPercentageAfterAnimation] = useState(false)
const [isFadingOut, setIsFadingOut] = useState(false)
// Refs to store animation frame and timeout IDs for cleanup
const animationFrameRef = useRef<number | null>(null)
const fadeOutTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// Animation effect when shouldAnimate prop changes (initial load)
useEffect(() => {
// Cleanup function to cancel any pending animations or timeouts
const cleanup = () => {
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
if (fadeOutTimeoutRef.current !== null) {
clearTimeout(fadeOutTimeoutRef.current)
fadeOutTimeoutRef.current = null
}
if (hideTimeoutRef.current !== null) {
clearTimeout(hideTimeoutRef.current)
hideTimeoutRef.current = null
}
}
if (shouldAnimate && threshold > 0) {
// Clean up any existing animations before starting new one
cleanup()
setIsAnimating(true)
const targetPosition = threshold * 100
const duration = 1200 // ms - slowed down from 800ms
const startTime = Date.now()
const animate = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
// Ease-out animation curve
const easeOut = 1 - (1 - progress) ** 3
const currentPosition = easeOut * targetPosition
setAnimatedPosition(currentPosition)
if (progress < 1) {
animationFrameRef.current = requestAnimationFrame(animate)
} else {
animationFrameRef.current = null
setIsAnimating(false)
setShowPercentageAfterAnimation(true)
// Start fade out after 1 second
fadeOutTimeoutRef.current = setTimeout(() => {
setIsFadingOut(true)
// Completely hide after fade transition
hideTimeoutRef.current = setTimeout(() => {
setShowPercentageAfterAnimation(false)
setIsFadingOut(false)
hideTimeoutRef.current = null
setAnimatedPosition(threshold * 100) // Ensure it ends exactly at threshold
}, 300) // 300ms fade duration
fadeOutTimeoutRef.current = null
}, 1000)
}
}
animationFrameRef.current = requestAnimationFrame(animate)
}
// Cleanup on unmount or when dependencies change
return cleanup
}, [shouldAnimate, threshold])
// The marker position is calculated based on the threshold percentage
// It goes over the progress bar to indicate where the auto-condense will trigger
// and it should highlight from what the current percentage (usage) is
// to the threshold percentage
const marker = useMemo(() => {
const _threshold = threshold * 100
// Always use the current threshold for position and label - animation only affects visual movement
const position = _threshold
const startingPosition = isAnimating ? animatedPosition : position
return {
start: startingPosition + "%",
label: startingPosition.toFixed(0),
end: usage > startingPosition ? usage - startingPosition + "%" : 0,
}
}, [threshold, usage, isAnimating, animatedPosition])
if (!threshold) {
return null
}
return (
<div className="flex-1" id="auto-condense-threshold-marker">
<div
className={cn(
"absolute top-0 bottom-0 h-full cursor-pointer pointer-events-none z-10 bg-button-background shadow-lg w-1",
{
"transition-all duration-75": !isAnimating,
},
)}
style={{
left: marker.start,
transform: isAnimating ? `translateX(${animatedPosition - threshold * 100}%)` : "translateX(0)",
}}>
{(isContextWindowHoverOpen || isAnimating || showPercentageAfterAnimation) && (
<div
className={cn("absolute -top-4 -left-1 text-button-background font-mono text-xs", {
"opacity-0": isFadingOut,
})}>
{marker.label}%
</div>
)}
</div>
</div>
)
}
AutoCondenseMarker.displayName = "AutoCondenseMarker"
@@ -1,11 +1,9 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import debounce from "debounce"
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { updateSetting } from "@/components/settings/utils/settingsHandlers"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
import { Progress } from "@/components/ui/progress"
import { formatLargeNumber as formatTokenNumber } from "@/utils/format"
import { AutoCondenseMarker } from "./AutoCondenseMarker"
import CompactTaskButton from "./buttons/CompactTaskButton"
import { ContextWindowSummary } from "./ContextWindowSummary"
@@ -22,7 +20,6 @@ interface ContextWindowProgressProps extends ContextWindowInfoProps {
useAutoCondense: boolean
lastApiReqTotalTokens?: number
contextWindow?: number
autoCondenseThreshold?: number
onSendMessage?: (command: string, files: string[], images: string[]) => void
}
@@ -58,7 +55,6 @@ ConfirmationDialog.displayName = "ConfirmationDialog"
const ContextWindow: React.FC<ContextWindowProgressProps> = ({
contextWindow = 0,
lastApiReqTotalTokens = 0,
autoCondenseThreshold = 0.75,
onSendMessage,
useAutoCondense,
tokensIn,
@@ -67,32 +63,8 @@ const ContextWindow: React.FC<ContextWindowProgressProps> = ({
cacheReads,
}) => {
const [isOpened, setIsOpened] = useState(false)
const [threshold, setThreshold] = useState(useAutoCondense ? autoCondenseThreshold : 0)
const [confirmationNeeded, setConfirmationNeeded] = useState(false)
const progressBarRef = useRef<HTMLDivElement>(null)
const [shouldAnimateMarker, setShouldAnimateMarker] = useState(false)
// Trigger marker animation when component first mounts (TaskHeader expands)
useEffect(() => {
if (useAutoCondense && threshold > 0) {
setShouldAnimateMarker(true)
// Reset animation flag after animation completes
const timer = setTimeout(() => {
setShouldAnimateMarker(false)
}, 1400) // Slightly longer than animation duration (1200ms + buffer)
return () => clearTimeout(timer)
}
}, []) // Empty dependency array means this only runs on mount
const handleContextWindowBarClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect()
const clickX = event.clientX - rect.left
const percentage = Math.max(0, Math.min(1, clickX / rect.width))
const newThreshold = Math.round(percentage * 100) / 100
setConfirmationNeeded(false)
setThreshold(newThreshold)
updateSetting("autoCondenseThreshold", newThreshold)
}, [])
const handleCompactClick = useCallback(
(e: React.MouseEvent) => {
@@ -138,43 +110,6 @@ const ContextWindow: React.FC<ContextWindowProgressProps> = ({
return showHover(false)
}, [])
// Keyboard event handlers
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (!useAutoCondense) {
return
}
const step = event.shiftKey ? 0.1 : 0.05 // Larger step with Shift
let newThreshold = threshold
switch (event.key) {
case "ArrowLeft":
case "ArrowDown":
event.preventDefault()
event.stopPropagation()
setIsOpened(true) // Keep tooltip open on interaction
newThreshold = Math.max(0, threshold - step)
break
case "ArrowRight":
case "ArrowUp":
event.preventDefault()
event.stopPropagation()
setIsOpened(true) // Keep tooltip open on interaction
newThreshold = Math.min(1, threshold + step)
break
default:
return
}
if (newThreshold !== threshold) {
setThreshold(newThreshold)
updateSetting("autoCondenseThreshold", newThreshold)
}
},
[threshold, useAutoCondense, setIsOpened],
)
const handleFocus = useCallback(() => {
setIsOpened(true)
}, [])
@@ -183,7 +118,7 @@ const ContextWindow: React.FC<ContextWindowProgressProps> = ({
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element
const isInsideProgressBar = progressBarRef.current && progressBarRef.current.contains(target as Node)
const isInsideProgressBar = progressBarRef.current?.contains(target as Node)
// Check if click is inside any tooltip content by looking for our custom class
const isInsideTooltipContent = target.closest(".context-window-tooltip-content") !== null
@@ -214,7 +149,6 @@ const ContextWindow: React.FC<ContextWindowProgressProps> = ({
<HoverCard>
<HoverCardContent className="bg-menu rounded-xs shadow-sm">
<ContextWindowSummary
autoCompactThreshold={useAutoCondense ? threshold : undefined}
cacheReads={cacheReads}
cacheWrites={cacheWrites}
contextWindow={tokenData.max}
@@ -225,32 +159,17 @@ const ContextWindow: React.FC<ContextWindowProgressProps> = ({
/>
</HoverCardContent>
<HoverCardTrigger asChild>
{/* TODO: Re-add role="slider", aria-value*, onKeyDown, onClick, and tabIndex
when click-to-set-threshold is implemented. See PR #9348 for context. */}
<div
aria-label="Auto condense threshold"
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={Math.round(threshold * 100)}
aria-valuetext={`${Math.round(threshold * 100)}% threshold`}
className="relative w-full text-foreground context-window-progress brightness-100"
onFocus={handleFocus}
onKeyDown={handleKeyDown}
ref={progressBarRef}
role="slider"
tabIndex={useAutoCondense ? 0 : -1}>
ref={progressBarRef}>
<Progress
aria-label="Context window usage progress"
color="success"
onClick={handleContextWindowBarClick}
value={tokenData.percentage}
/>
{useAutoCondense && (
<AutoCondenseMarker
isContextWindowHoverOpen={isOpened}
shouldAnimate={shouldAnimateMarker}
threshold={threshold}
usage={tokenData.percentage}
/>
)}
{isOpened}
</div>
</HoverCardTrigger>
@@ -0,0 +1,111 @@
import type { BannerAction, BannerCardData } from "@shared/cline/banner"
import React from "react"
import Markdown from "react-markdown"
interface WhatsNewItemsProps {
welcomeBanners?: BannerCardData[]
onBannerAction?: (action: BannerAction) => void
onClose: () => void
inlineCodeStyle: React.CSSProperties
onNavigateToModelPicker: (initialModelTab: "recommended" | "free", modelId?: string) => void
}
type InlineModelLinkProps = { pickerTab: "recommended" | "free"; modelId: string; label: string }
export const WhatsNewItems: React.FC<WhatsNewItemsProps> = ({
welcomeBanners,
onBannerAction,
onClose,
inlineCodeStyle,
onNavigateToModelPicker,
}) => {
const InlineModelLink: React.FC<InlineModelLinkProps> = ({ pickerTab, modelId, label }) => (
<span
onClick={() => onNavigateToModelPicker(pickerTab, modelId)}
style={{ color: "var(--vscode-textLink-foreground)", cursor: "pointer" }}>
{label}
</span>
)
const hasWelcomeBanners = welcomeBanners && welcomeBanners.length > 0
return (
<ul className="text-sm pl-3 list-disc" style={{ color: "var(--vscode-descriptionForeground)" }}>
{hasWelcomeBanners ? (
welcomeBanners.map((banner) => (
<li className="mb-2" key={banner.id}>
{banner.title && <strong>{banner.title}</strong>}{" "}
{banner.description && (
<Markdown
components={{
a: ({ href, children }) => (
<a
href={href}
rel="noopener noreferrer"
style={{ color: "var(--vscode-textLink-foreground)" }}
target="_blank">
{children}
</a>
),
code: ({ children }) => <code style={inlineCodeStyle}>{children}</code>,
p: ({ children }) => <p style={{ display: "inline", margin: 0 }}>{children}</p>,
}}>
{banner.description}
</Markdown>
)}
{banner.actions && banner.actions.length > 0 && onBannerAction && (
<span className="inline-flex gap-2 ml-2 align-middle">
{banner.actions.map((action, idx) => (
<a
href="#"
key={idx}
onClick={(event) => {
event.preventDefault()
onBannerAction(action)
onClose()
}}
style={{
color: "var(--vscode-textLink-foreground)",
cursor: "pointer",
}}>
{action.title}
</a>
))}
</span>
)}
</li>
))
) : (
<>
{/* Hardcoded fallback items shown when remote welcome banners feature flag is off */}
<li className="mb-2">
<strong>Claude Sonnet 4.6 is here!</strong> Advanced reasoning and coding performance.{" "}
<InlineModelLink label="Try now" modelId="anthropic/claude-sonnet-4.6" pickerTab="recommended" />
</li>
<li className="mb-2">
<strong>Gemini 3.1 Pro:</strong> Google's latest SOTA model across most reasoning, coding, and stem use
cases <InlineModelLink label="Try now" modelId="google/gemini-3.1-pro-preview" pickerTab="recommended" />
</li>{" "}
<li className="mb-2">
<strong>MiniMax M2.5 & Z.ai GLM 5:</strong> available in Cline with free promo, ends Friday Feb 20{" "}
<InlineModelLink label="Try now" modelId="minimax/minimax-m2.5" pickerTab="free" />
</li>
<li className="mb-2">
<strong>Cline CLI 2.0:</strong> Major upgrade bringing interactive and autonomous agentic coding to your
terminal. Install with <code style={inlineCodeStyle}>npm install -g cline</code>
<a
href="https://cline.bot/cli"
rel="noopener noreferrer"
style={{ color: "var(--vscode-textLink-foreground)" }}
target="_blank">
{" "}
Learn more
</a>
</li>
</>
)}
</ul>
)
}
export default WhatsNewItems

Some files were not shown because too many files have changed in this diff Show More