mirror of
https://github.com/cline/cline.git
synced 2026-09-05 05:02:27 +08:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0eab54ab12 | |||
| 7fa0a4924b | |||
| 8680218e0e | |||
| 8787ab35b9 | |||
| 6ed3944f04 | |||
| 1dd8e763d1 | |||
| 28e6297769 | |||
| 5a2a5d1c0a | |||
| 113039a259 | |||
| 4023c18257 | |||
| 7c95b53892 | |||
| 9fd2b99be4 | |||
| 7c782abaf4 | |||
| 3a14a88f4f | |||
| 28d60a83a4 | |||
| ae6468b161 | |||
| af93e31862 | |||
| ca154eb8f5 | |||
| 1d8497c6bf | |||
| 5871fd02b1 | |||
| 2d81c310d2 | |||
| 0c691f72d2 | |||
| a8409137b3 | |||
| 34a21b8e26 | |||
| eb9f53edf4 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
docs: remove stale Claude 5 mention from auto compact model support list
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Remove the expired MiniMax M2.5 free promo, restore GLM 5 free promo in What's New, and keep Sonnet 4.6 free messaging at 24 hours.
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
+16
-6
@@ -1,16 +1,26 @@
|
||||
# cline
|
||||
|
||||
## 2.4.2
|
||||
|
||||
## [2.4.0]
|
||||
### Added
|
||||
|
||||
### Changes
|
||||
|
||||
- Adding Anthropic Sonnet 4.6
|
||||
|
||||
## [2.3.0]
|
||||
- Gemini-3.1 Pro Preview
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cline",
|
||||
"version": "2.4.0",
|
||||
"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": {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -13,17 +13,23 @@ export interface FeaturedModel {
|
||||
export const FEATURED_MODELS: { recommended: FeaturedModel[]; free: FeaturedModel[] } = {
|
||||
recommended: [
|
||||
{
|
||||
id: "anthropic/claude-opus-4.6",
|
||||
name: "Claude Opus 4.6",
|
||||
description: "State-of-the-art for complex coding",
|
||||
labels: ["BEST"],
|
||||
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: "Best balance of speed, cost, and quality",
|
||||
description: "Latest Sonnet release with strong coding and agent performance",
|
||||
labels: ["NEW"],
|
||||
},
|
||||
{
|
||||
id: "anthropic/claude-opus-4.6",
|
||||
name: "Claude Opus 4.6",
|
||||
description: "Most intelligent model for agents and coding",
|
||||
labels: ["BEST"],
|
||||
},
|
||||
{
|
||||
id: "openai/gpt-5.2-codex",
|
||||
name: "GPT 5.2 Codex",
|
||||
@@ -33,9 +39,9 @@ export const FEATURED_MODELS: { recommended: FeaturedModel[]; free: FeaturedMode
|
||||
],
|
||||
free: [
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
description: "Best balance of speed, cost, and quality, now free in Cline",
|
||||
id: "minimax/minimax-m2.5",
|
||||
name: "MiniMax M2.5",
|
||||
description: "MiniMax-M2.5 is a lightweight, state-of-the-art LLM optimized for coding and agentic workflows",
|
||||
labels: ["FREE"],
|
||||
},
|
||||
{
|
||||
|
||||
+5
-10
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+105
-66
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.64.0",
|
||||
"version": "3.66.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.64.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
@@ -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.64.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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -66,7 +66,6 @@ export interface ApiProviderInfo {
|
||||
model: ApiHandlerModel
|
||||
mode: Mode
|
||||
customPrompt?: string // "compact"
|
||||
autoCondenseThreshold?: number // 0-1 range
|
||||
}
|
||||
|
||||
export interface SingleCompletionHandler {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,7 +30,7 @@ interface ClineHandlerOptions extends CommonApiHandlerOptions {
|
||||
clineApiKey?: string
|
||||
}
|
||||
|
||||
const CLINE_FREE_MODELS = ["anthropic/claude-sonnet-4.6", "anthropic/claude-4.6-sonnet", "kwaipilot/kat-coder-pro", "z-ai/glm-5"]
|
||||
const CLINE_FREE_MODELS = ["minimax/minimax-m2.5", "kwaipilot/kat-coder-pro", "z-ai/glm-5"]
|
||||
|
||||
export class ClineHandler implements ApiHandler {
|
||||
private options: ClineHandlerOptions
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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.`,
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
+4
-10
@@ -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,
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+11
-19
@@ -3575,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
|
||||
@@ -4458,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,
|
||||
|
||||
@@ -90,15 +90,32 @@ 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-free-limited-2026-feb-17",
|
||||
id: "claude-sonnet-4-6-2026-feb-18",
|
||||
icon: "sparkles",
|
||||
title: "Try Claude Sonnet 4.6 Free",
|
||||
description: "Free for a limited 24-hour window. Offer ends February 18, 2026 at 12:00 PM PST.",
|
||||
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-18",
|
||||
icon: "zap",
|
||||
title: "Try MiniMax M2.5 Free",
|
||||
description: "SOTA coding capability with lightning fast inference, free in Cline.",
|
||||
actions: [
|
||||
{
|
||||
title: "Try now",
|
||||
action: BannerActionType.SetModel,
|
||||
arg: "anthropic/claude-sonnet-4.6",
|
||||
arg: "minimax/minimax-m2.5",
|
||||
tab: "free",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -5,22 +5,6 @@ import type { OnboardingModel } from "../proto/cline/state"
|
||||
* NOTE: Can be overridden by feature flag onboarding models payload.
|
||||
*/
|
||||
export const CLINE_ONBOARDING_MODELS: OnboardingModel[] = [
|
||||
{
|
||||
group: "free",
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
name: "Anthropic: Claude Sonnet 4.6",
|
||||
badge: "New",
|
||||
score: 97,
|
||||
latency: 3,
|
||||
info: {
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
tiers: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
group: "free",
|
||||
id: "kwaipilot/kat-coder-pro",
|
||||
@@ -37,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",
|
||||
@@ -55,8 +55,8 @@ export const CLINE_ONBOARDING_MODELS: OnboardingModel[] = [
|
||||
},
|
||||
{
|
||||
group: "frontier",
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
name: "Anthropic: Claude Sonnet 4.6",
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
name: "Anthropic: Claude Sonnet 4.5",
|
||||
badge: "Best",
|
||||
score: 97,
|
||||
latency: 3,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Generated
+12
-35
@@ -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",
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -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
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BannerAction, BannerCardData } from "@shared/cline/banner"
|
||||
import React, { useCallback } from "react"
|
||||
import { useMount } from "react-use"
|
||||
import DiscordIcon from "@/assets/DiscordIcon"
|
||||
@@ -5,6 +6,7 @@ import GitHubIcon from "@/assets/GitHubIcon"
|
||||
import LinkedInIcon from "@/assets/LinkedInIcon"
|
||||
import RedditIcon from "@/assets/RedditIcon"
|
||||
import XIcon from "@/assets/XIcon"
|
||||
import WhatsNewItems from "@/components/common/WhatsNewItems"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { useApiConfigurationHandlers } from "../settings/utils/useApiConfigurationHandlers"
|
||||
@@ -13,9 +15,11 @@ interface WhatsNewModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
version: string
|
||||
welcomeBanners?: BannerCardData[]
|
||||
onBannerAction?: (action: BannerAction) => void
|
||||
}
|
||||
|
||||
export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ open, onClose, version }) => {
|
||||
export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ open, onClose, version, welcomeBanners, onBannerAction }) => {
|
||||
const { openRouterModels, refreshOpenRouterModels, navigateToSettingsModelPicker } = useExtensionState()
|
||||
const { handleFieldsChange } = useApiConfigurationHandlers()
|
||||
|
||||
@@ -43,18 +47,6 @@ export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ open, onClose, ver
|
||||
[handleFieldsChange, navigateToSettingsModelPicker, onClose, openRouterModels],
|
||||
)
|
||||
|
||||
type InlineModelLinkProps = { pickerTab: "recommended" | "free"; modelId: string; label: string }
|
||||
|
||||
const InlineModelLink: React.FC<InlineModelLinkProps> = (props) => {
|
||||
return (
|
||||
<span
|
||||
onClick={() => navigateToModelPicker(props.pickerTab, props.modelId)}
|
||||
style={{ color: "var(--vscode-textLink-foreground)", cursor: "pointer" }}>
|
||||
{props.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const inlineCodeStyle: React.CSSProperties = {
|
||||
backgroundColor: "var(--vscode-textCodeBlock-background)",
|
||||
padding: "2px 6px",
|
||||
@@ -77,41 +69,13 @@ export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ open, onClose, ver
|
||||
🎉 New in v{version}
|
||||
</h2>
|
||||
|
||||
{/* Description */}
|
||||
<ul className="text-sm pl-3 list-disc" style={{ color: "var(--vscode-descriptionForeground)" }}>
|
||||
<li className="mb-2">
|
||||
<strong>Claude Sonnet 4.6 is here!</strong> Anthropic's latest model with advanced reasoning and
|
||||
coding performance. Free for 24 hours for Cline users.{" "}
|
||||
<InlineModelLink label="Try now" modelId="anthropic/claude-sonnet-4.6" pickerTab="free" />
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
<strong>GLM 5:</strong> SOTA coding capability with lightning fast inference, now available with free
|
||||
promo!{" "}
|
||||
<InlineModelLink label="Try now" modelId="z-ai/glm-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>
|
||||
<li className="mb-2">
|
||||
<strong> Subagents experimental feature</strong> available in VSCode and the CLI.{" "}
|
||||
<a
|
||||
href="https://docs.cline.bot/features/subagents"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: "var(--vscode-textLink-foreground)" }}
|
||||
target="_blank">
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<WhatsNewItems
|
||||
inlineCodeStyle={inlineCodeStyle}
|
||||
onBannerAction={onBannerAction}
|
||||
onClose={onClose}
|
||||
onNavigateToModelPicker={navigateToModelPicker}
|
||||
welcomeBanners={welcomeBanners}
|
||||
/>
|
||||
|
||||
{/* Social Icons Section */}
|
||||
<div className="flex flex-col items-center gap-3 mt-4 pt-4 border-t border-[var(--vscode-widget-border)]">
|
||||
|
||||
@@ -49,6 +49,6 @@ export const STEP_CONFIG = {
|
||||
|
||||
export const USER_TYPE_SELECTIONS: UserTypeSelection[] = [
|
||||
{ title: "Absolutely Free", description: "Get started at no cost", type: NEW_USER_TYPE.FREE },
|
||||
{ title: "Frontier Model", description: "Claude 4.6, GPT-5 Codex, etc", type: NEW_USER_TYPE.POWER },
|
||||
{ title: "Frontier Model", description: "Claude 4.5, GPT-5 Codex, etc", type: NEW_USER_TYPE.POWER },
|
||||
{ title: "Bring my own API key", description: "Use Cline with your provider of choice", type: NEW_USER_TYPE.BYOK },
|
||||
]
|
||||
|
||||
@@ -53,32 +53,32 @@ export interface OpenRouterModelPickerProps {
|
||||
|
||||
// Featured models for Cline provider organized by tabs
|
||||
export const recommendedModels = [
|
||||
{
|
||||
id: "google/gemini-3.1-pro-preview",
|
||||
description: "Latest Gemini release with 1m ctx window and strong coding performance",
|
||||
label: "NEW",
|
||||
},
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
description: "Best balance of speed, cost, and quality",
|
||||
label: "BEST",
|
||||
description: "Latest Sonnet release with strong coding and agent performance",
|
||||
label: "NEW",
|
||||
},
|
||||
{
|
||||
id: "anthropic/claude-opus-4.6",
|
||||
description: "Most intelligent model for agents and coding",
|
||||
label: "NEW",
|
||||
label: "BEST",
|
||||
},
|
||||
{
|
||||
id: "openai/gpt-5.2-codex",
|
||||
description: "OpenAI's latest with strong coding abilities",
|
||||
label: "HOT",
|
||||
},
|
||||
{
|
||||
id: "google/gemini-3-pro-preview",
|
||||
description: "1M context window for large codebases",
|
||||
label: "1M CTX",
|
||||
},
|
||||
]
|
||||
|
||||
export const freeModels = [
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
description: "Claude Sonnet 4.6 with strong coding and agent performance, now available free in Cline",
|
||||
id: "minimax/minimax-m2.5",
|
||||
description: "MiniMax-M2.5 is a lightweight, state-of-the-art LLM optimized for coding and agentic workflows",
|
||||
label: "FREE",
|
||||
},
|
||||
{
|
||||
@@ -98,7 +98,7 @@ export const freeModels = [
|
||||
},
|
||||
]
|
||||
|
||||
const FREE_CLINE_MODELS = [...freeModels.map((m) => m.id), "anthropic/claude-4.6-sonnet"]
|
||||
const FREE_CLINE_MODELS = freeModels.map((m) => m.id)
|
||||
|
||||
const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({
|
||||
isPopup,
|
||||
|
||||
@@ -268,7 +268,6 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
subagentsEnabled: false,
|
||||
clineWebToolsEnabled: { user: true, featureFlag: false },
|
||||
worktreesEnabled: { user: true, featureFlag: false },
|
||||
autoCondenseThreshold: undefined,
|
||||
favoritedModelIds: [],
|
||||
lastDismissedInfoBannerVersion: 0,
|
||||
lastDismissedModelBannerVersion: 0,
|
||||
|
||||
Reference in New Issue
Block a user