mirror of
https://github.com/cline/cline.git
synced 2026-09-04 11:44:01 +08:00
Compare commits
30 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 | |||
| c60f18d907 | |||
| 36c68a6ab9 | |||
| 80dfce0f60 | |||
| 955ae2f62f | |||
| 8cb0c6d236 |
@@ -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.
|
||||
+22
-2
@@ -1,13 +1,33 @@
|
||||
# cline
|
||||
|
||||
## 2.2.3
|
||||
## 2.4.2
|
||||
|
||||
### Added
|
||||
|
||||
- Gemini-3.1 Pro Preview
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c0020e1: Allows users to enter custom aws region when selecting bedrock as a provider in CLI
|
||||
- VSCode uses shared files for global, workspace and secret state.
|
||||
|
||||
## [2.4.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix infinite retry loop when write_to_file fails with missing content parameter. Provides progressive guidance to the model, escalating from suggestions to hard stops, with context window awareness to break the loop.
|
||||
|
||||
## [2.4.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Adding Anthropic Sonnet 4.6
|
||||
- Allows users to enter custom aws region when selecting bedrock as a provider in CLI
|
||||
- Keep reasoning rows visible when low-stakes tool groups start immediately after reasoning.
|
||||
- Restore reasoning trace visibility in chat and improve the thinking row UX so streamed reasoning is visible, then collapsible after completion.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Banners now display immediately when opening the extension instead of requiring user interaction first
|
||||
- Resolved 17 security vulnerabilities including high-severity DoS issues in dependencies (body-parser, axios, qs, tar, and others)
|
||||
|
||||
## [2.2.2]
|
||||
|
||||
@@ -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.2.3",
|
||||
"version": "2.4.2",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"main": "dist/cli.mjs",
|
||||
"bin": {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -646,7 +689,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Model ID</Text>
|
||||
<Text> </Text>
|
||||
<Text color="gray">e.g., claude-sonnet-4-20250514, gpt-4o</Text>
|
||||
<Text color="gray">e.g., claude-sonnet-4-6, gpt-4o</Text>
|
||||
<Text> </Text>
|
||||
<TextInput onChange={setModelId} onSubmit={handleModelIdSubmit} placeholder="model-id" value={modelId} />
|
||||
<Text> </Text>
|
||||
@@ -741,6 +784,18 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
/>
|
||||
)
|
||||
|
||||
case "bedrock_custom":
|
||||
return (
|
||||
<BedrockCustomModelFlow
|
||||
isActive={step === "bedrock_custom"}
|
||||
onCancel={() => setStep("modelid")}
|
||||
onComplete={(arn, baseModelId) => {
|
||||
setStep("saving")
|
||||
saveCustomBedrockConfiguration(arn, baseModelId)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
case "import":
|
||||
if (!importSource) {
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Bedrock Custom Model Flow component
|
||||
* Two-step flow: ARN/custom model ID input → base model selection for capability detection.
|
||||
* Used by both AuthView (onboarding) and SettingsPanelContent (/settings).
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
// biome-ignore lint/correctness/noUnusedImports: React is needed for JSX at runtime
|
||||
import React, { useCallback, useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { getModelList } from "./ModelPicker"
|
||||
import { SearchableList } from "./SearchableList"
|
||||
|
||||
type FlowStep = "arn_input" | "base_model"
|
||||
|
||||
interface BedrockCustomModelFlowProps {
|
||||
/** Whether this component should capture keyboard input */
|
||||
isActive: boolean
|
||||
/** Called when the user completes both steps (ARN + base model selection) */
|
||||
onComplete: (arn: string, baseModelId: string) => void
|
||||
/** Called when the user presses Escape on the first step (ARN input) */
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export const BedrockCustomModelFlow: React.FC<BedrockCustomModelFlowProps> = ({ isActive, onComplete, onCancel }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [step, setStep] = useState<FlowStep>("arn_input")
|
||||
const [customArn, setCustomArn] = useState("")
|
||||
|
||||
const handleArnSubmit = useCallback(() => {
|
||||
if (customArn.trim()) {
|
||||
setStep("base_model")
|
||||
}
|
||||
}, [customArn])
|
||||
|
||||
const handleBaseModelCancel = useCallback(() => {
|
||||
setStep("arn_input")
|
||||
}, [])
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (step === "arn_input") {
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
} else if (key.return) {
|
||||
handleArnSubmit()
|
||||
} else if (key.backspace || key.delete) {
|
||||
setCustomArn((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
setCustomArn((prev) => prev + input)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (step === "base_model") {
|
||||
if (key.escape) {
|
||||
handleBaseModelCancel()
|
||||
}
|
||||
// Other input is handled by SearchableList
|
||||
}
|
||||
},
|
||||
{ isActive: isActive && isRawModeSupported },
|
||||
)
|
||||
|
||||
if (step === "arn_input") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Custom Model ID
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Enter your Application Inference Profile ARN or custom model ID</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
{customArn ? (
|
||||
<Text color="white">{customArn}</Text>
|
||||
) : (
|
||||
<Text color="gray">e.g. arn:aws:bedrock:region:account:application-inference-profile/...</Text>
|
||||
)}
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Enter to continue, Esc to go back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// step === "base_model"
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Base Inference Model
|
||||
</Text>
|
||||
<Text color="gray">Select the base model your inference profile uses (for capability detection)</Text>
|
||||
<Box marginTop={1}>
|
||||
<SearchableList
|
||||
isActive={isActive && step === "base_model"}
|
||||
items={getModelList("bedrock").map((id) => ({ id, label: id }))}
|
||||
onSelect={(item) => {
|
||||
onComplete(customArn, item.id)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Type to search, arrows to navigate, Enter to select, Esc to go back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -12,23 +12,29 @@ export interface FeaturedModel {
|
||||
|
||||
export const FEATURED_MODELS: { recommended: FeaturedModel[]; free: FeaturedModel[] } = {
|
||||
recommended: [
|
||||
{
|
||||
id: "google/gemini-3.1-pro-preview",
|
||||
name: "Gemini 3.1 Pro Preview",
|
||||
description: "Latest Gemini release with 1m ctx window and strong coding performance",
|
||||
labels: ["NEW"],
|
||||
},
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
description: "Latest Sonnet release with strong coding and agent performance",
|
||||
labels: ["NEW"],
|
||||
},
|
||||
{
|
||||
id: "anthropic/claude-opus-4.6",
|
||||
name: "Claude Opus 4.6",
|
||||
description: "State-of-the-art for complex coding",
|
||||
description: "Most intelligent model for agents and coding",
|
||||
labels: ["BEST"],
|
||||
},
|
||||
{
|
||||
id: "openai/gpt-5.2-codex",
|
||||
name: "GPT 5.2 Codex",
|
||||
description: "OpenAI's latest with strong coding abilities",
|
||||
labels: ["NEW"],
|
||||
},
|
||||
{
|
||||
id: "google/gemini-3-pro-preview",
|
||||
name: "Gemini 3 Pro",
|
||||
description: "1M context window for large codebases",
|
||||
labels: ["TRENDING"],
|
||||
labels: ["HOT"],
|
||||
},
|
||||
],
|
||||
free: [
|
||||
|
||||
+6
-11
@@ -8,12 +8,11 @@ import { Command } from "commander"
|
||||
import { render } from "ink"
|
||||
import React from "react"
|
||||
import { ClineEndpoint } from "@/config"
|
||||
import { Controller } from "@/core/controller"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { AuthHandler } from "@/hosts/external/AuthHandler"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { FileEditProvider } from "@/integrations/editor/FileEditProvider"
|
||||
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
|
||||
import { StandaloneTerminalManager } from "@/integrations/terminal/standalone/StandaloneTerminalManager"
|
||||
import { ErrorService } from "@/services/error/ErrorService"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
@@ -425,7 +424,7 @@ interface InitOptions {
|
||||
*/
|
||||
async function initializeCli(options: InitOptions): Promise<CliContext> {
|
||||
const workspacePath = options.cwd || process.cwd()
|
||||
const { extensionContext, DATA_DIR, EXTENSION_DIR } = initializeCliContext({
|
||||
const { extensionContext, storageContext, DATA_DIR, EXTENSION_DIR } = initializeCliContext({
|
||||
clineDir: options.config,
|
||||
workspaceDir: workspacePath,
|
||||
})
|
||||
@@ -466,13 +465,9 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
|
||||
DATA_DIR,
|
||||
)
|
||||
|
||||
await StateManager.initialize(extensionContext as any)
|
||||
|
||||
await StateManager.initialize(storageContext)
|
||||
await ErrorService.initialize()
|
||||
|
||||
// Initialize OpenAI Codex OAuth manager with extension context for secrets storage
|
||||
openAiCodexOAuthManager.initialize(extensionContext)
|
||||
|
||||
const webview = HostProvider.get().createWebviewProvider() as CliWebviewProvider
|
||||
const controller = webview.controller
|
||||
|
||||
@@ -754,7 +749,7 @@ program
|
||||
.option("-a, --act", "Run in act mode")
|
||||
.option("-p, --plan", "Run in plan mode")
|
||||
.option("-y, --yolo", "Enable yes/yolo mode (auto-approve actions)")
|
||||
.option("-t, --timeout <seconds>", "Timeout in seconds for yes/yolo mode (default: 600)")
|
||||
.option("-t, --timeout <seconds>", "Optional timeout in seconds (applies only when provided)")
|
||||
.option("-m, --model <model>", "Model to use for the task")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory for the task")
|
||||
@@ -792,7 +787,7 @@ program
|
||||
.description("Authenticate a provider and configure what model is used")
|
||||
.option("-p, --provider <id>", "Provider ID for quick setup (e.g., openai-native, anthropic, moonshot)")
|
||||
.option("-k, --apikey <key>", "API key for the provider")
|
||||
.option("-m, --modelid <id>", "Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929, kimi-k2.5)")
|
||||
.option("-m, --modelid <id>", "Model ID to configure (e.g., gpt-4o, claude-sonnet-4-6, kimi-k2.5)")
|
||||
.option("-b, --baseurl <url>", "Base URL (optional, only for openai provider)")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory for the task")
|
||||
@@ -983,7 +978,7 @@ program
|
||||
.option("-a, --act", "Run in act mode")
|
||||
.option("-p, --plan", "Run in plan mode")
|
||||
.option("-y, --yolo", "Enable yolo mode (auto-approve actions)")
|
||||
.option("-t, --timeout <seconds>", "Timeout in seconds for yolo mode (default: 600)")
|
||||
.option("-t, --timeout <seconds>", "Optional timeout in seconds (applies only when provided)")
|
||||
.option("-m, --model <model>", "Model to use for the task")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
|
||||
@@ -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,8 +16,13 @@ description: "Learn how to configure and use Anthropic Claude models with Cline.
|
||||
|
||||
Cline supports the following Anthropic Claude models:
|
||||
|
||||
#### Claude 4.6 Series
|
||||
- `claude-sonnet-4-6` - Latest Sonnet with extended thinking support
|
||||
- `claude-sonnet-4-6:1m` - 1M context window variant with tiered pricing
|
||||
|
||||
|
||||
#### Claude 4.5 Series
|
||||
- `claude-sonnet-4-5-20250929` (Recommended) - Latest Sonnet with extended thinking support
|
||||
- `claude-sonnet-4-5-20250929` (Recommended) - Stable default Sonnet with reasoning support
|
||||
- `claude-sonnet-4-5-20250929:1m` - 1M context window variant with tiered pricing
|
||||
|
||||
#### Claude 4 Series
|
||||
@@ -56,8 +61,8 @@ Cline users can leverage this by checking the `Enable Extended Thinking` box bel
|
||||
|
||||
**Key Aspects of Extended Thinking:**
|
||||
|
||||
- **Supported Models:** This feature is available for select models, including Claude Opus 4, Claude Sonnet 4.5, and Claude Sonnet 3.7.
|
||||
- **Summarized Thinking (Claude 4):** For Claude 4 and 4.5 models, the API returns a summary of the full thinking process to balance insight with efficiency and prevent misuse. You are billed for the full thinking tokens, not just the summary.
|
||||
- **Supported Models:** This feature is available for select models, including Claude Opus 4, Claude Sonnet 3.7+.
|
||||
- **Summarized Thinking (Claude 3.7+):** For Claude 3.7+ models, the API returns a summary of the full thinking process to balance insight with efficiency and prevent misuse. You are billed for the full thinking tokens, not just the summary.
|
||||
- **Streaming:** Extended thinking responses, including the `thinking` blocks, can be streamed.
|
||||
- **Tool Use & Prompt Caching:** Extended thinking interacts with tool use (requiring thinking blocks to be passed back) and prompt caching (with specific behaviors around cache invalidation and context).
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ Cline supports the following AskSage models (subscription-based pricing):
|
||||
|
||||
#### Claude Models
|
||||
- `claude-4-sonnet` (Default) - Claude 4 Sonnet via AskSage
|
||||
- `claude-4.6-sonnet` - Claude 4.6 Sonnet via AskSage
|
||||
- `claude-4-opus` - Claude 4 Opus via AskSage
|
||||
- `claude-37-sonnet` - Claude 3.7 Sonnet
|
||||
- `claude-35-sonnet` - Claude 3.5 Sonnet
|
||||
|
||||
@@ -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.63.0",
|
||||
"version": "3.66.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.63.0",
|
||||
"version": "3.66.0",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
".",
|
||||
"cli"
|
||||
],
|
||||
"dependencies": {
|
||||
@@ -45,8 +46,8 @@
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.37.0",
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@sap-ai-sdk/ai-api": "^2.1.0",
|
||||
"@sap-ai-sdk/orchestration": "^2.1.0",
|
||||
"@sap-ai-sdk/ai-api": "^2.7.0",
|
||||
"@sap-ai-sdk/orchestration": "^2.7.0",
|
||||
"@sap-cloud-sdk/connectivity": "^4.2.0",
|
||||
"@streamparser/json": "^0.0.22",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
@@ -161,7 +162,7 @@
|
||||
},
|
||||
"cli": {
|
||||
"name": "cline",
|
||||
"version": "2.2.1",
|
||||
"version": "2.4.1",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
@@ -1575,7 +1576,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -3129,7 +3129,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
|
||||
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@js-sdsl/ordered-map": "^4.4.2"
|
||||
@@ -4032,7 +4031,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
|
||||
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"ajv": "^8.17.1",
|
||||
@@ -4108,7 +4106,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -5799,7 +5796,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.57.1",
|
||||
@@ -5812,7 +5810,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.57.1",
|
||||
@@ -5825,7 +5824,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.57.1",
|
||||
@@ -5838,7 +5838,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.57.1",
|
||||
@@ -5851,7 +5852,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.57.1",
|
||||
@@ -5864,7 +5866,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.57.1",
|
||||
@@ -5877,7 +5880,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.57.1",
|
||||
@@ -5890,7 +5894,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.57.1",
|
||||
@@ -5903,7 +5908,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.57.1",
|
||||
@@ -5916,7 +5922,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.57.1",
|
||||
@@ -5929,7 +5936,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.57.1",
|
||||
@@ -5942,7 +5950,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.57.1",
|
||||
@@ -5955,7 +5964,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.57.1",
|
||||
@@ -5968,7 +5978,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.57.1",
|
||||
@@ -5981,7 +5992,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.57.1",
|
||||
@@ -5994,7 +6006,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.57.1",
|
||||
@@ -6007,7 +6020,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
@@ -6020,7 +6034,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.57.1",
|
||||
@@ -6033,7 +6048,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.57.1",
|
||||
@@ -6046,7 +6062,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.57.1",
|
||||
@@ -6059,7 +6076,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.57.1",
|
||||
@@ -6072,7 +6090,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.57.1",
|
||||
@@ -6085,7 +6104,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
@@ -6098,7 +6118,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.57.1",
|
||||
@@ -6111,23 +6132,24 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@sap-ai-sdk/ai-api": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/ai-api/-/ai-api-2.6.0.tgz",
|
||||
"integrity": "sha512-HdQ3T6FSD/jlPnsDKEGsNg+SQONpHBvxfu6OVwW/5nLdlvPmWpysirYvOc0eqj7e+X3zBHGIgoGWKSxIcYOjNg==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/ai-api/-/ai-api-2.7.0.tgz",
|
||||
"integrity": "sha512-dDc2Si5Mu62dVZkJ/f55fBIlbIjVjCKmPi9tt2jWix59o/f9z2Zkw7l7Il+H0RbgANyyQmZsCkXDHRUmzAKKDw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@sap-ai-sdk/core": "^2.6.0",
|
||||
"@sap-ai-sdk/core": "^2.7.0",
|
||||
"@sap-cloud-sdk/connectivity": "^4.4.0",
|
||||
"@sap-cloud-sdk/util": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sap-ai-sdk/core": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/core/-/core-2.6.0.tgz",
|
||||
"integrity": "sha512-HgB2xtjx6iFwAq+a5cEH6rss4sBXAkkW6Vas07OxoZihc7BijjNFm+H19rkEAhBYQmkA415zJG3imHd6/slooQ==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/core/-/core-2.7.0.tgz",
|
||||
"integrity": "sha512-NSuCrEFinMR8/EYKJcACh71a1kj1H3pNfpnJH1R4Z+cxnlr0CtiFDzz9hKwsXFtf+ixO6WnIf7xMVmXkoeGNSQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@sap-cloud-sdk/connectivity": "^4.4.0",
|
||||
@@ -6137,25 +6159,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sap-ai-sdk/orchestration": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/orchestration/-/orchestration-2.6.0.tgz",
|
||||
"integrity": "sha512-FGBZ0oiRbbZdL9UUytsJ7QBiVUmrg4eD/i61aHVeBGvaCvsgSlx7nJM07CAT5JHhJC8ss97+EYPXXzVNPATcyQ==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/orchestration/-/orchestration-2.7.0.tgz",
|
||||
"integrity": "sha512-/aCRwb3o5yJu5/8jCDVatMGBIxPO5rKtr0sT+zAl5w3hD/PqAqbFZXNztQ263xK1ooW8YAFite8PpXJgdjnFZg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@sap-ai-sdk/ai-api": "^2.6.0",
|
||||
"@sap-ai-sdk/core": "^2.6.0",
|
||||
"@sap-ai-sdk/prompt-registry": "^2.6.0",
|
||||
"@sap-ai-sdk/ai-api": "^2.7.0",
|
||||
"@sap-ai-sdk/core": "^2.7.0",
|
||||
"@sap-ai-sdk/prompt-registry": "^2.7.0",
|
||||
"@sap-cloud-sdk/util": "^4.4.0",
|
||||
"yaml": "^2.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@sap-ai-sdk/prompt-registry": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/prompt-registry/-/prompt-registry-2.6.0.tgz",
|
||||
"integrity": "sha512-vSyBCx437Cba3AsBHf1G0KCldv4dPi60kNgp2rpF/kDZjf3Rga3VwZ6OnETaKtbJJbM8G8itR0USHgviMU8UUg==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/prompt-registry/-/prompt-registry-2.7.0.tgz",
|
||||
"integrity": "sha512-hbfb75CD67dgKl/hLLCXdzhsR80FHFqKCpVUjiULs3KW1Rauxc+i6ZKNRcc+uzdb8kgsdhKizFFCtsz90ay44g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@sap-ai-sdk/core": "^2.6.0",
|
||||
"@sap-ai-sdk/core": "^2.7.0",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
},
|
||||
@@ -7783,7 +7805,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.32.tgz",
|
||||
"integrity": "sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -7846,7 +7867,6 @@
|
||||
"integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -8781,7 +8801,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -9746,7 +9765,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -10288,6 +10306,10 @@
|
||||
"integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/claude-dev": {
|
||||
"resolved": "",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/clean-stack": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
||||
@@ -11145,8 +11167,7 @@
|
||||
"version": "0.0.1367902",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz",
|
||||
"integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "5.2.2",
|
||||
@@ -12163,7 +12184,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -13471,7 +13491,6 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz",
|
||||
"integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -13750,7 +13769,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.7.tgz",
|
||||
"integrity": "sha512-QHyxhNF5VonF5cRmdAJD/UPucB9nRx3FozWMjQrDGfBxfAL9lpyu72/MlFPgloS1TMTGsOt7YN6dTPPA6mh0Aw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -15004,7 +15022,6 @@
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
@@ -15354,7 +15371,6 @@
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||
"license": "MPL-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
@@ -19091,7 +19107,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -21799,7 +21814,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -22121,7 +22135,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -22203,6 +22216,7 @@
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22219,6 +22233,7 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22235,6 +22250,7 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22251,6 +22267,7 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22267,6 +22284,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22283,6 +22301,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22299,6 +22318,7 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22315,6 +22335,7 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22331,6 +22352,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22347,6 +22369,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22363,6 +22386,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22379,6 +22403,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22395,6 +22420,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22411,6 +22437,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22427,6 +22454,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22443,6 +22471,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22459,6 +22488,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22475,6 +22505,7 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22491,6 +22522,7 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22507,6 +22539,7 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22523,6 +22556,7 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22539,6 +22573,7 @@
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22555,6 +22590,7 @@
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22571,6 +22607,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22587,6 +22624,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22603,6 +22641,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22658,6 +22697,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
@@ -23592,7 +23632,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
+4
-3
@@ -2,9 +2,10 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.63.0",
|
||||
"version": "3.66.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"workspaces": [
|
||||
".",
|
||||
"cli"
|
||||
],
|
||||
"engines": {
|
||||
@@ -535,8 +536,8 @@
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.37.0",
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@sap-ai-sdk/ai-api": "^2.1.0",
|
||||
"@sap-ai-sdk/orchestration": "^2.1.0",
|
||||
"@sap-ai-sdk/ai-api": "^2.7.0",
|
||||
"@sap-ai-sdk/orchestration": "^2.7.0",
|
||||
"@sap-cloud-sdk/connectivity": "^4.2.0",
|
||||
"@streamparser/json": "^0.0.22",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -938,6 +938,19 @@ describe("AwsBedrockHandler", () => {
|
||||
modelId.should.equal("jp.anthropic.claude-sonnet-4-5-20250929-v1:0")
|
||||
})
|
||||
|
||||
it("should apply JP cross-region prefix for sonnet 4.6", async () => {
|
||||
const jpOptions: AwsBedrockHandlerOptions = {
|
||||
...mockOptions,
|
||||
awsUseCrossRegionInference: true,
|
||||
apiModelId: "anthropic.claude-sonnet-4-6",
|
||||
awsRegion: "ap-northeast-1",
|
||||
}
|
||||
const jpHandler = new AwsBedrockHandler(jpOptions)
|
||||
|
||||
const modelId = await jpHandler.getModelId()
|
||||
modelId.should.equal("jp.anthropic.claude-sonnet-4-6")
|
||||
})
|
||||
|
||||
it("should apply global cross-region prefix for supported models", async () => {
|
||||
const globalOptions: AwsBedrockHandlerOptions = {
|
||||
...mockOptions,
|
||||
|
||||
@@ -276,6 +276,16 @@ describe("ClaudeCodeHandler", () => {
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Sonnet 4.6 1m model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-sonnet-4-6[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-sonnet-4-6[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should return default model when not specified", () => {
|
||||
const handler = new ClaudeCodeHandler({})
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ describe("SapAiCoreHandler", () => {
|
||||
|
||||
it("should support different Claude model variants", () => {
|
||||
const modelVariants = [
|
||||
"anthropic--claude-4.6-sonnet",
|
||||
"anthropic--claude-4-sonnet",
|
||||
"anthropic--claude-4-opus",
|
||||
"anthropic--claude-3.7-sonnet",
|
||||
|
||||
@@ -127,9 +127,11 @@ interface ProviderChainOptions {
|
||||
profile?: string
|
||||
}
|
||||
|
||||
// a special jp inference profile was created for opus 4.6, sonnet 4.5 & haiku 4.5
|
||||
// a special jp inference profile was created for sonnet 4.6, opus 4.6, sonnet 4.5 & haiku 4.5
|
||||
// https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html
|
||||
const JP_SUPPORTED_CRIS_MODELS = [
|
||||
"anthropic.claude-sonnet-4-6",
|
||||
"anthropic.claude-sonnet-4-6:1m",
|
||||
"anthropic.claude-opus-4-6-v1",
|
||||
"anthropic.claude-opus-4-6-v1:1m",
|
||||
"anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
|
||||
@@ -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,6 +30,8 @@ interface ClineHandlerOptions extends CommonApiHandlerOptions {
|
||||
clineApiKey?: string
|
||||
}
|
||||
|
||||
const CLINE_FREE_MODELS = ["minimax/minimax-m2.5", "kwaipilot/kat-coder-pro", "z-ai/glm-5"]
|
||||
|
||||
export class ClineHandler implements ApiHandler {
|
||||
private options: ClineHandlerOptions
|
||||
private clineAccountService = ClineAccountService.getInstance()
|
||||
@@ -198,7 +200,7 @@ export class ClineHandler implements ApiHandler {
|
||||
// @ts-expect-error-next-line
|
||||
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
|
||||
const modelId = this.getModel().id
|
||||
const isFreeModel = ["kwaipilot/kat-coder-pro", "minimax/minimax-m2.5", "z-ai/glm-5"].includes(modelId)
|
||||
const isFreeModel = CLINE_FREE_MODELS.includes(modelId)
|
||||
|
||||
if (isFreeModel) {
|
||||
totalCost = 0
|
||||
@@ -252,7 +254,7 @@ export class ClineHandler implements ApiHandler {
|
||||
const generation = response.data
|
||||
let totalCost = generation?.total_cost || 0
|
||||
const modelId = this.getModel().id
|
||||
const isFreeModel = ["kwaipilot/kat-coder-pro", "minimax/minimax-m2.5", "z-ai/glm-5"].includes(modelId)
|
||||
const isFreeModel = CLINE_FREE_MODELS.includes(modelId)
|
||||
|
||||
if (isFreeModel) {
|
||||
totalCost = 0
|
||||
|
||||
@@ -78,6 +78,8 @@ export class RequestyHandler implements ApiHandler {
|
||||
: { thinking: { type: "disabled" } }
|
||||
const thinkingArgs =
|
||||
model.id.includes("claude-opus-4-6") ||
|
||||
model.id.includes("claude-sonnet-4-6") ||
|
||||
model.id.includes("claude-4.6-sonnet") ||
|
||||
model.id.includes("claude-3-7-sonnet") ||
|
||||
model.id.includes("claude-sonnet-4") ||
|
||||
model.id.includes("claude-opus-4") ||
|
||||
|
||||
@@ -610,6 +610,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
const anthropicModels = [
|
||||
"anthropic--claude-4.5-haiku",
|
||||
"anthropic--claude-4.5-opus",
|
||||
"anthropic--claude-4.6-sonnet",
|
||||
"anthropic--claude-4.5-sonnet",
|
||||
"anthropic--claude-4-sonnet",
|
||||
"anthropic--claude-4-opus",
|
||||
@@ -659,6 +660,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
|
||||
if (
|
||||
model.id === "anthropic--claude-4.5-opus" ||
|
||||
model.id === "anthropic--claude-4.6-sonnet" ||
|
||||
model.id === "anthropic--claude-4.5-sonnet" ||
|
||||
model.id === "anthropic--claude-4.5-haiku" ||
|
||||
model.id === "anthropic--claude-4-sonnet" ||
|
||||
@@ -791,6 +793,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
yield* this.streamCompletionGPT(response.data, model)
|
||||
} else if (
|
||||
model.id === "anthropic--claude-4.5-opus" ||
|
||||
model.id === "anthropic--claude-4.6-sonnet" ||
|
||||
model.id === "anthropic--claude-4.5-sonnet" ||
|
||||
model.id === "anthropic--claude-4.5-haiku" ||
|
||||
model.id === "anthropic--claude-4-sonnet" ||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
OPENROUTER_PROVIDER_PREFERENCES,
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeSonnet41mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
openRouterClaudeSonnet451mModelId,
|
||||
} from "@shared/api"
|
||||
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
|
||||
@@ -34,6 +35,7 @@ export async function createOpenRouterStream(
|
||||
const isClaude1m =
|
||||
model.id === openRouterClaudeSonnet41mModelId ||
|
||||
model.id === openRouterClaudeSonnet451mModelId ||
|
||||
model.id === openRouterClaudeSonnet461mModelId ||
|
||||
model.id === openRouterClaudeOpus461mModelId
|
||||
if (isClaude1m) {
|
||||
// remove the custom :1m suffix, to create the model id openrouter API expects
|
||||
@@ -50,6 +52,8 @@ export async function createOpenRouterStream(
|
||||
case "anthropic/claude-opus-4.6":
|
||||
case "anthropic/claude-haiku-4.5":
|
||||
case "anthropic/claude-4.5-haiku":
|
||||
case "anthropic/claude-sonnet-4.6":
|
||||
case "anthropic/claude-4.6-sonnet":
|
||||
case "anthropic/claude-sonnet-4.5":
|
||||
case "anthropic/claude-4.5-sonnet": // OpenRouter accidentally included this in model list for a brief moment, and users may be using this model id. And to support prompt caching, we need to add it here.
|
||||
case "anthropic/claude-sonnet-4":
|
||||
@@ -118,6 +122,8 @@ export async function createOpenRouterStream(
|
||||
case "anthropic/claude-opus-4.6":
|
||||
case "anthropic/claude-haiku-4.5":
|
||||
case "anthropic/claude-4.5-haiku":
|
||||
case "anthropic/claude-sonnet-4.6":
|
||||
case "anthropic/claude-4.6-sonnet":
|
||||
case "anthropic/claude-sonnet-4.5":
|
||||
case "anthropic/claude-4.5-sonnet":
|
||||
case "anthropic/claude-sonnet-4":
|
||||
@@ -166,6 +172,8 @@ export async function createOpenRouterStream(
|
||||
case "anthropic/claude-opus-4.6":
|
||||
case "anthropic/claude-haiku-4.5":
|
||||
case "anthropic/claude-4.5-haiku":
|
||||
case "anthropic/claude-sonnet-4.6":
|
||||
case "anthropic/claude-4.6-sonnet":
|
||||
case "anthropic/claude-sonnet-4.5":
|
||||
case "anthropic/claude-4.5-sonnet":
|
||||
case "anthropic/claude-sonnet-4":
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ModelInfo,
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeSonnet41mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
openRouterClaudeSonnet451mModelId,
|
||||
} from "@shared/api"
|
||||
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
|
||||
@@ -32,6 +33,7 @@ export async function createVercelAIGatewayStream(
|
||||
const isClaude1m =
|
||||
model.id === openRouterClaudeSonnet41mModelId ||
|
||||
model.id === openRouterClaudeSonnet451mModelId ||
|
||||
model.id === openRouterClaudeSonnet461mModelId ||
|
||||
model.id === openRouterClaudeOpus461mModelId
|
||||
if (isClaude1m) {
|
||||
// remove the custom :1m suffix, to create the model id the API expects
|
||||
|
||||
@@ -154,15 +154,21 @@ export class ContextManager {
|
||||
thresholdPercentage?: number,
|
||||
): boolean {
|
||||
if (previousApiReqIndex >= 0) {
|
||||
const previousRequest = clineMessages[previousApiReqIndex]
|
||||
if (previousRequest && previousRequest.text) {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
const previousRequestText = clineMessages[previousApiReqIndex]?.text
|
||||
if (previousRequestText) {
|
||||
try {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequestText)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
|
||||
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
|
||||
const roundedThreshold = thresholdPercentage ? Math.floor(contextWindow * thresholdPercentage) : maxAllowedSize
|
||||
const thresholdTokens = Math.min(roundedThreshold, maxAllowedSize)
|
||||
return totalTokens >= thresholdTokens
|
||||
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
|
||||
const roundedThreshold = thresholdPercentage
|
||||
? Math.floor(contextWindow * thresholdPercentage)
|
||||
: maxAllowedSize
|
||||
const thresholdTokens = Math.min(roundedThreshold, maxAllowedSize)
|
||||
return totalTokens >= thresholdTokens
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -195,10 +201,10 @@ export class ContextManager {
|
||||
}
|
||||
|
||||
if (targetIndex >= 0) {
|
||||
const targetRequest = clineMessages[targetIndex]
|
||||
if (targetRequest && targetRequest.text) {
|
||||
const targetRequestText = clineMessages[targetIndex]?.text
|
||||
if (targetRequestText) {
|
||||
try {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(targetRequest.text)
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(targetRequestText)
|
||||
const tokensUsed = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
|
||||
const { contextWindow } = getContextWindowInfo(api)
|
||||
@@ -232,10 +238,10 @@ export class ContextManager {
|
||||
if (!useAutoCondense) {
|
||||
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
|
||||
if (previousApiReqIndex >= 0) {
|
||||
const previousRequest = clineMessages[previousApiReqIndex]
|
||||
if (previousRequest && previousRequest.text) {
|
||||
const timestamp = previousRequest.ts
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
|
||||
const previousRequestText = clineMessages[previousApiReqIndex]?.text
|
||||
if (previousRequestText) {
|
||||
const timestamp = clineMessages[previousApiReqIndex].ts
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequestText)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
const { maxAllowedSize } = getContextWindowInfo(api)
|
||||
|
||||
@@ -846,9 +852,8 @@ export class ContextManager {
|
||||
}
|
||||
// otherwise there are still file reads here we can overwrite, so still need to process this text chunk
|
||||
// to do so we need to keep track of which files we've already replaced so we don't replace them again
|
||||
else {
|
||||
thisExistingFileReads = blockUpdates[blockUpdates.length - 1][3][0]
|
||||
}
|
||||
|
||||
thisExistingFileReads = blockUpdates[blockUpdates.length - 1][3][0]
|
||||
}
|
||||
} else {
|
||||
// for all other cases we can assume that we dont need to check this again
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { expect } from "chai"
|
||||
import { ContextManager } from "../ContextManager"
|
||||
|
||||
// Minimal mock for ApiHandler — only getModel().info.contextWindow is used by shouldCompactContextWindow
|
||||
function createMockApi(contextWindow: number) {
|
||||
return {
|
||||
getModel: () => ({ id: "test-model", info: { contextWindow } }),
|
||||
} as any
|
||||
}
|
||||
|
||||
function createApiReqMessage(tokens: {
|
||||
tokensIn?: number
|
||||
tokensOut?: number
|
||||
cacheWrites?: number
|
||||
cacheReads?: number
|
||||
}): ClineMessage {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: "api_req_started",
|
||||
text: JSON.stringify(tokens),
|
||||
}
|
||||
}
|
||||
|
||||
describe("ContextManager", () => {
|
||||
function createMessages(count: number): Anthropic.Messages.MessageParam[] {
|
||||
const messages: Anthropic.Messages.MessageParam[] = []
|
||||
@@ -381,4 +403,93 @@ describe("ContextManager", () => {
|
||||
expect((content[0] as Anthropic.Messages.TextBlockParam).text).to.equal("Additional user text")
|
||||
})
|
||||
})
|
||||
|
||||
describe("shouldCompactContextWindow", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("does not compact at 33K tokens with default 0.75 threshold on 200K context", () => {
|
||||
const api = createMockApi(200_000)
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 30_000, tokensOut: 3_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
|
||||
it("compacts when tokens exceed 0.75 threshold on 200K context", () => {
|
||||
const api = createMockApi(200_000)
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 140_000, tokensOut: 15_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
|
||||
expect(result).to.equal(true)
|
||||
})
|
||||
|
||||
it("compacts at only 10K tokens when threshold is accidentally set to 0.05", () => {
|
||||
const contextWindow = 200_000
|
||||
const accidentalThreshold = 0.05
|
||||
// floor(200000 * 0.05) = 10000 — this is the bug case from PR #9348.
|
||||
// Accidental clicks on the progress bar set threshold to ~5%, triggering
|
||||
// compaction at 10K tokens instead of the intended 150K (0.75 * 200K).
|
||||
const compactionTriggersAt = Math.floor(contextWindow * accidentalThreshold) // 10,000
|
||||
const totalTokens = compactionTriggersAt + 500 // 10,500 — just above the trigger
|
||||
|
||||
const api = createMockApi(contextWindow)
|
||||
const tokensIn = totalTokens - 1_500
|
||||
const tokensOut = 1_500
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn, tokensOut })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, accidentalThreshold)
|
||||
expect(result).to.equal(true)
|
||||
})
|
||||
|
||||
it("falls back to maxAllowedSize when threshold is undefined", () => {
|
||||
const api = createMockApi(200_000)
|
||||
// 155K tokens — above 0.75 threshold (150K) but below maxAllowedSize (160K)
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 150_000, tokensOut: 5_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, undefined)
|
||||
// undefined → uses maxAllowedSize (160K), so 155K < 160K → false
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
|
||||
it("falls back to maxAllowedSize when threshold is 0", () => {
|
||||
const api = createMockApi(200_000)
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 150_000, tokensOut: 5_000 })]
|
||||
|
||||
// 0 is falsy, so ternary falls back to maxAllowedSize (160K)
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0)
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
|
||||
it("includes cacheWrites and cacheReads in total token count", () => {
|
||||
const api = createMockApi(200_000)
|
||||
// Low direct tokens but high cache reads push total over threshold
|
||||
const clineMessages: ClineMessage[] = [
|
||||
createApiReqMessage({ tokensIn: 5_000, tokensOut: 500, cacheWrites: 0, cacheReads: 150_000 }),
|
||||
]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
|
||||
expect(result).to.equal(true)
|
||||
})
|
||||
|
||||
it("returns false when previousApiReqIndex is negative", () => {
|
||||
const api = createMockApi(200_000)
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 200_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, -1, 0.75)
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
|
||||
it("threshold is capped at maxAllowedSize even when percentage is very high", () => {
|
||||
const api = createMockApi(200_000)
|
||||
// threshold of 1.0 → floor(200000 * 1.0) = 200000, but min(200000, 160000) = 160000
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 165_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 1.0)
|
||||
expect(result).to.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,8 @@ import { getTaskMetadata, readTaskHistoryFromState, saveTaskMetadata } from "@co
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import chokidar, { FSWatcher } from "chokidar"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getCwd } from "@/utils/path"
|
||||
import type { FileMetadataEntry } from "./ContextTrackerTypes"
|
||||
@@ -250,6 +250,8 @@ export class FileContextTracker {
|
||||
async retrievePendingFileContextWarning(): Promise<string[] | undefined> {
|
||||
try {
|
||||
const key = `pendingFileContextWarning_${this.taskId}`
|
||||
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
|
||||
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
|
||||
const files = this.controller.stateManager.getWorkspaceStateKey(key as any) as string[]
|
||||
return files
|
||||
} catch (error) {
|
||||
@@ -265,6 +267,8 @@ export class FileContextTracker {
|
||||
try {
|
||||
const files = await this.retrievePendingFileContextWarning()
|
||||
if (files) {
|
||||
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
|
||||
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
|
||||
this.controller.stateManager.setWorkspaceState(`pendingFileContextWarning_${this.taskId}` as any, undefined)
|
||||
return files
|
||||
}
|
||||
@@ -278,12 +282,12 @@ export class FileContextTracker {
|
||||
* Static method to clean up orphaned pending file context warnings at startup
|
||||
* This removes warnings for tasks that may no longer exist
|
||||
*/
|
||||
static async cleanupOrphanedWarnings(context: vscode.ExtensionContext): Promise<void> {
|
||||
static async cleanupOrphanedWarnings(stateManager: StateManager): Promise<void> {
|
||||
const startTime = Date.now()
|
||||
try {
|
||||
const taskHistory = await readTaskHistoryFromState()
|
||||
const existingTaskIds = new Set(taskHistory.map((task) => task.id))
|
||||
const allStateKeys = context.workspaceState.keys()
|
||||
const allStateKeys = Object.keys(stateManager.getAllWorkspaceStateEntries())
|
||||
const pendingWarningKeys = allStateKeys.filter((key) => key.startsWith("pendingFileContextWarning_"))
|
||||
|
||||
const orphanedPendingContextTasks: string[] = []
|
||||
@@ -296,8 +300,9 @@ export class FileContextTracker {
|
||||
|
||||
if (orphanedPendingContextTasks.length > 0) {
|
||||
for (const key of orphanedPendingContextTasks) {
|
||||
// eslint-disable-next-line eslint-rules/no-direct-vscode-state-api
|
||||
await context.workspaceState.update(key, undefined)
|
||||
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
|
||||
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
|
||||
await stateManager.setWorkspaceState(key as any, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { ChatContent } from "@shared/ChatContent"
|
||||
import type { ExtensionState, Platform } from "@shared/ExtensionMessage"
|
||||
import type { HistoryItem } from "@shared/HistoryItem"
|
||||
import type { McpMarketplaceCatalog, McpMarketplaceItem } from "@shared/mcp"
|
||||
import type { Settings } from "@shared/storage/state-keys"
|
||||
import { type Settings } from "@shared/storage/state-keys"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import type { TelemetrySetting } from "@shared/TelemetrySetting"
|
||||
import type { UserInfo } from "@shared/UserInfo"
|
||||
@@ -23,7 +23,6 @@ import fs from "fs/promises"
|
||||
import open from "open"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import * as path from "path"
|
||||
import type * as vscode from "vscode"
|
||||
import { ClineEnv } from "@/config"
|
||||
import type { FolderLockWithRetryResult } from "@/core/locks/types"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
@@ -35,6 +34,7 @@ import { BannerService } from "@/services/banner/BannerService"
|
||||
import { featureFlagsService } from "@/services/feature-flags"
|
||||
import { getDistinctId } from "@/services/logging/distinctId"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineExtensionContext } from "@/shared/cline"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
@@ -117,7 +117,7 @@ export class Controller {
|
||||
this.remoteConfigTimer = setInterval(() => fetchRemoteConfig(this), 3600000) // 1 hour
|
||||
}
|
||||
|
||||
constructor(readonly context: vscode.ExtensionContext) {
|
||||
constructor(readonly context: ClineExtensionContext) {
|
||||
Session.reset() // Reset session on controller initialization
|
||||
PromptRegistry.getInstance() // Ensure prompts and tools are registered
|
||||
this.stateManager = StateManager.get()
|
||||
@@ -894,7 +894,6 @@ export class Controller {
|
||||
const localCursorRulesToggles = this.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
|
||||
const localAgentsRulesToggles = this.stateManager.getWorkspaceStateKey("localAgentsRulesToggles")
|
||||
const workflowToggles = this.stateManager.getWorkspaceStateKey("workflowToggles")
|
||||
const autoCondenseThreshold = this.stateManager.getGlobalSettingsKey("autoCondenseThreshold")
|
||||
|
||||
const currentTaskItem = this.task?.taskId ? (taskHistory || []).find((item) => item.id === this.task?.taskId) : undefined
|
||||
// Spread to create new array reference - React needs this to detect changes in useEffect dependencies
|
||||
@@ -914,6 +913,7 @@ export class Controller {
|
||||
const clineConfig = ClineEnv.config()
|
||||
const environment = clineConfig.environment
|
||||
const banners = BannerService.get().getActiveBanners() ?? []
|
||||
const welcomeBanners = BannerService.get().getWelcomeBanners() ?? []
|
||||
|
||||
// Check OpenAI Codex authentication status
|
||||
const { openAiCodexOAuthManager } = await import("@/integrations/openai-codex/oauth")
|
||||
@@ -976,7 +976,6 @@ export class Controller {
|
||||
taskHistory: processedTaskHistory,
|
||||
shouldShowAnnouncement,
|
||||
favoritedModelIds,
|
||||
autoCondenseThreshold,
|
||||
backgroundCommandRunning: this.backgroundCommandRunning,
|
||||
backgroundCommandTaskId: this.backgroundCommandTaskId,
|
||||
// NEW: Add workspace information
|
||||
@@ -1007,6 +1006,7 @@ export class Controller {
|
||||
optOutOfRemoteConfig: this.stateManager.getGlobalSettingsKey("optOutOfRemoteConfig"),
|
||||
doubleCheckCompletionEnabled,
|
||||
banners,
|
||||
welcomeBanners,
|
||||
openAiCodexIsAuthenticated,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,21 +12,34 @@ export function getClineOnboardingModels(): OnboardingModelGroup {
|
||||
}
|
||||
|
||||
const remoteOverrides = featureFlagsService.getOnboardingOverrides()
|
||||
const models = new Map<string, OnboardingModel>(CLINE_ONBOARDING_MODELS.map((model) => [model.id, model]))
|
||||
const models = [...CLINE_ONBOARDING_MODELS]
|
||||
|
||||
// Apply remote overrides if available
|
||||
if (remoteOverrides) {
|
||||
for (const [id, override] of Object.entries(remoteOverrides) as [string, OnboardingModelOverride][]) {
|
||||
if (override.hidden) {
|
||||
models.delete(id)
|
||||
for (let i = models.length - 1; i >= 0; i--) {
|
||||
if (models[i].id === id) {
|
||||
models.splice(i, 1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const baseModel = models.get(id)
|
||||
models.set(id, mergeModelWithOverride(baseModel, override))
|
||||
let found = false
|
||||
for (let i = 0; i < models.length; i++) {
|
||||
if (models[i].id === id) {
|
||||
models[i] = mergeModelWithOverride(models[i], override)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
models.push(mergeModelWithOverride(undefined, override))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cached = { models: Array.from(models.values()) }
|
||||
cached = { models }
|
||||
return cached
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CLAUDE_SONNET_1M_TIERS,
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeSonnet41mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
openRouterClaudeSonnet451mModelId,
|
||||
} from "@/shared/api"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
@@ -144,6 +145,8 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
|
||||
}
|
||||
|
||||
switch (rawModel.id) {
|
||||
case "anthropic/claude-sonnet-4.6":
|
||||
case "anthropic/claude-4.6-sonnet":
|
||||
case "anthropic/claude-sonnet-4.5":
|
||||
case "anthropic/claude-4.5-sonnet":
|
||||
case "anthropic/claude-sonnet-4":
|
||||
@@ -264,14 +267,28 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
|
||||
models[rawModel.id] = modelInfo
|
||||
|
||||
// add custom :1m model variant for sonnet
|
||||
if (rawModel.id === "anthropic/claude-sonnet-4" || rawModel.id === "anthropic/claude-sonnet-4.5") {
|
||||
if (
|
||||
rawModel.id === "anthropic/claude-sonnet-4" ||
|
||||
rawModel.id === "anthropic/claude-sonnet-4.5" ||
|
||||
rawModel.id === "anthropic/claude-4.5-sonnet" ||
|
||||
rawModel.id === "anthropic/claude-sonnet-4.6" ||
|
||||
rawModel.id === "anthropic/claude-4.6-sonnet"
|
||||
) {
|
||||
const claudeSonnet1mModelInfo = cloneDeep(modelInfo)
|
||||
claudeSonnet1mModelInfo.contextWindow = 1_000_000 // limiting providers to those that support 1m context window
|
||||
claudeSonnet1mModelInfo.tiers = CLAUDE_SONNET_1M_TIERS
|
||||
// sonnet 4
|
||||
models[openRouterClaudeSonnet41mModelId] = claudeSonnet1mModelInfo
|
||||
if (rawModel.id === "anthropic/claude-sonnet-4") {
|
||||
models[openRouterClaudeSonnet41mModelId] = claudeSonnet1mModelInfo
|
||||
}
|
||||
// sonnet 4.5
|
||||
models[openRouterClaudeSonnet451mModelId] = claudeSonnet1mModelInfo
|
||||
if (rawModel.id === "anthropic/claude-sonnet-4.5" || rawModel.id === "anthropic/claude-4.5-sonnet") {
|
||||
models[openRouterClaudeSonnet451mModelId] = claudeSonnet1mModelInfo
|
||||
}
|
||||
// sonnet 4.6
|
||||
if (rawModel.id === "anthropic/claude-sonnet-4.6" || rawModel.id === "anthropic/claude-4.6-sonnet") {
|
||||
models[openRouterClaudeSonnet461mModelId] = claudeSonnet1mModelInfo
|
||||
}
|
||||
}
|
||||
|
||||
// add custom :1m model variant for opus 4.6
|
||||
|
||||
@@ -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"),
|
||||
|
||||
+5
-11
@@ -309,7 +309,7 @@ export class Task {
|
||||
this.terminalManager.setTerminalOutputLineLimit(terminalOutputLineLimit)
|
||||
this.terminalManager.setDefaultTerminalProfile(defaultTerminalProfile)
|
||||
|
||||
this.urlContentFetcher = new UrlContentFetcher(controller.context)
|
||||
this.urlContentFetcher = new UrlContentFetcher()
|
||||
this.browserSession = new BrowserSession(stateManager)
|
||||
this.contextManager = new ContextManager()
|
||||
this.streamHandler = new StreamResponseHandler()
|
||||
@@ -530,7 +530,6 @@ export class Task {
|
||||
this.commandExecutor = new CommandExecutor(commandExecutorConfig, commandExecutorCallbacks)
|
||||
|
||||
this.toolExecutor = new ToolExecutor(
|
||||
this.controller.context,
|
||||
this.taskState,
|
||||
this.messageStateHandler,
|
||||
this.api,
|
||||
@@ -2262,7 +2261,7 @@ export class Task {
|
||||
"mistake_limit_reached",
|
||||
this.api.getModel().id.includes("claude")
|
||||
? `This may indicate a failure in Cline's thought process or inability to use a tool properly, which can be mitigated with some user guidance (e.g. "Try breaking down the task into smaller steps").`
|
||||
: "Cline uses complex prompts and iterative task execution that may be challenging for less capable models. For best results, it's recommended to use Claude 4 Sonnet for its advanced agentic coding capabilities.",
|
||||
: "Cline uses complex prompts and iterative task execution that may be challenging for less capable models. For best results, it's recommended to use Claude 4.5 Sonnet for its advanced agentic coding capabilities.",
|
||||
)
|
||||
if (response === "messageResponse") {
|
||||
// Display the user's message in the chat UI
|
||||
@@ -2389,14 +2388,10 @@ export class Task {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const autoCondenseThreshold = this.stateManager.getGlobalSettingsKey("autoCondenseThreshold") as
|
||||
| number
|
||||
| undefined
|
||||
shouldCompact = this.contextManager.shouldCompactContextWindow(
|
||||
this.messageStateHandler.getClineMessages(),
|
||||
this.api,
|
||||
previousApiReqIndex,
|
||||
autoCondenseThreshold,
|
||||
)
|
||||
|
||||
// Edge case: summarize_task tool call completes but user cancels next request before it finishes.
|
||||
@@ -2529,7 +2524,7 @@ export class Task {
|
||||
|
||||
// if last message is a partial we need to update and save it
|
||||
const lastMessage = this.messageStateHandler.getClineMessages().at(-1)
|
||||
if (lastMessage && lastMessage.partial) {
|
||||
if (lastMessage?.partial) {
|
||||
// lastMessage.ts = Date.now() DO NOT update ts since it is used as a key for virtuoso list
|
||||
lastMessage.partial = false
|
||||
// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
|
||||
@@ -3517,8 +3512,7 @@ export class Task {
|
||||
let shouldShowContextWindow = true
|
||||
// For next-gen models, only show context window usage if it exceeds a certain threshold
|
||||
if (isNextGenModel) {
|
||||
const autoCondenseThreshold =
|
||||
(this.stateManager.getGlobalSettingsKey("autoCondenseThreshold") as number | undefined) ?? 0.75
|
||||
const autoCondenseThreshold = 0.75
|
||||
const displayThreshold = autoCondenseThreshold - 0.15
|
||||
const currentUsageRatio = lastApiReqTotalTokens / contextWindow
|
||||
shouldShowContextWindow = currentUsageRatio >= displayThreshold
|
||||
@@ -3532,7 +3526,7 @@ export class Task {
|
||||
details += "\n\n# Current Mode"
|
||||
const mode = this.stateManager.getGlobalSettingsKey("mode")
|
||||
if (mode === "plan") {
|
||||
details += "\nPLAN MODE\n" + formatResponse.planModeInstructions()
|
||||
details += `\nPLAN MODE\n${formatResponse.planModeInstructions()}`
|
||||
} else {
|
||||
details += "\nACT MODE"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { formatResponse } from "@core/prompts/responses"
|
||||
import { getWorkspaceBasename, resolveWorkspacePath } from "@core/workspace"
|
||||
import { processFilesIntoText } from "@integrations/misc/extract-text"
|
||||
import { ClineSayTool } from "@shared/ExtensionMessage"
|
||||
import { getLastApiReqTotalTokens } from "@shared/getApiMetrics"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import { arePathsEqual, getReadablePath, isLocatedInWorkspace } from "@utils/path"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
@@ -117,7 +118,27 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
if (block.name === "write_to_file" && !rawContent) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
await config.services.diffViewProvider.reset()
|
||||
return await config.callbacks.sayAndCreateMissingParamError(block.name, "content")
|
||||
|
||||
// Use progressive error with token budget awareness
|
||||
const relPath = rawRelPath || "unknown"
|
||||
const contextWindow = config.api.getModel().info.contextWindow ?? 128_000
|
||||
const lastApiReqTotalTokens = getLastApiReqTotalTokens(config.messageState.getClineMessages())
|
||||
const contextUsagePercent = contextWindow > 0 ? Math.round((lastApiReqTotalTokens / contextWindow) * 100) : undefined
|
||||
const errorMessage = formatResponse.writeToFileMissingContentError(
|
||||
relPath,
|
||||
config.taskState.consecutiveMistakeCount,
|
||||
contextUsagePercent,
|
||||
)
|
||||
|
||||
await config.callbacks.say(
|
||||
"error",
|
||||
`Cline tried to use write_to_file for '${relPath}' without value for required parameter 'content'. ${
|
||||
config.taskState.consecutiveMistakeCount >= 2
|
||||
? "This has happened multiple times — Cline will try a different approach."
|
||||
: "Retrying..."
|
||||
}`,
|
||||
)
|
||||
return formatResponse.toolError(errorMessage)
|
||||
}
|
||||
|
||||
if (block.name === "new_rule" && !rawContent) {
|
||||
@@ -246,35 +267,34 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
|
||||
await config.services.diffViewProvider.revertChanges()
|
||||
return `The user denied this operation. ${fileDeniedNote}`
|
||||
} else {
|
||||
// User hit the approve button, and may have provided feedback
|
||||
if (text || (images && images.length > 0) || (files && files.length > 0)) {
|
||||
let fileContentString = ""
|
||||
if (files && files.length > 0) {
|
||||
fileContentString = await processFilesIntoText(files)
|
||||
}
|
||||
|
||||
// Push additional tool feedback using existing utilities
|
||||
ToolResultUtils.pushAdditionalToolFeedback(
|
||||
config.taskState.userMessageContent,
|
||||
text,
|
||||
images,
|
||||
fileContentString,
|
||||
)
|
||||
await config.callbacks.say("user_feedback", text, images, files)
|
||||
}
|
||||
// User hit the approve button, and may have provided feedback
|
||||
if (text || (images && images.length > 0) || (files && files.length > 0)) {
|
||||
let fileContentString = ""
|
||||
if (files && files.length > 0) {
|
||||
fileContentString = await processFilesIntoText(files)
|
||||
}
|
||||
|
||||
telemetryService.captureToolUsage(
|
||||
config.ulid,
|
||||
block.name,
|
||||
modelId,
|
||||
providerId,
|
||||
false,
|
||||
true,
|
||||
workspaceContext,
|
||||
block.isNativeToolCall,
|
||||
// Push additional tool feedback using existing utilities
|
||||
ToolResultUtils.pushAdditionalToolFeedback(
|
||||
config.taskState.userMessageContent,
|
||||
text,
|
||||
images,
|
||||
fileContentString,
|
||||
)
|
||||
await config.callbacks.say("user_feedback", text, images, files)
|
||||
}
|
||||
|
||||
telemetryService.captureToolUsage(
|
||||
config.ulid,
|
||||
block.name,
|
||||
modelId,
|
||||
providerId,
|
||||
false,
|
||||
true,
|
||||
workspaceContext,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
}
|
||||
|
||||
// Run PreToolUse hook after approval but before execution
|
||||
@@ -327,9 +347,8 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
finalContent,
|
||||
newProblemsMessage,
|
||||
)
|
||||
} else {
|
||||
return formatResponse.fileEditWithoutUserChanges(relPath, autoFormattingEdits, finalContent, newProblemsMessage)
|
||||
}
|
||||
return formatResponse.fileEditWithoutUserChanges(relPath, autoFormattingEdits, finalContent, newProblemsMessage)
|
||||
} catch (error) {
|
||||
// Reset diff view on error
|
||||
await config.services.diffViewProvider.revertChanges()
|
||||
|
||||
@@ -737,9 +737,7 @@ export class SubagentRunner {
|
||||
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
|
||||
const useAutoCondense = this.baseConfig.services.stateManager.getGlobalSettingsKey("useAutoCondense")
|
||||
if (useAutoCondense && isNextGenModelFamily(modelId)) {
|
||||
const autoCondenseThreshold = this.baseConfig.services.stateManager.getGlobalSettingsKey("autoCondenseThreshold") as
|
||||
| number
|
||||
| undefined
|
||||
const autoCondenseThreshold = 0.75
|
||||
const roundedThreshold = autoCondenseThreshold ? Math.floor(contextWindow * autoCondenseThreshold) : maxAllowedSize
|
||||
const thresholdTokens = Math.min(roundedThreshold, maxAllowedSize)
|
||||
return previousRequestTotalTokens >= thresholdTokens
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+109
-20
@@ -164,6 +164,29 @@ export const anthropicDefaultModelId: AnthropicModelId = "claude-sonnet-4-5-2025
|
||||
export const ANTHROPIC_MIN_THINKING_BUDGET = 1_024
|
||||
export const ANTHROPIC_MAX_THINKING_BUDGET = 6_000
|
||||
export const anthropicModels = {
|
||||
"claude-sonnet-4-6": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
},
|
||||
"claude-sonnet-4-6:1m": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
tiers: CLAUDE_SONNET_1M_TIERS,
|
||||
},
|
||||
"claude-sonnet-4-5-20250929": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
@@ -361,6 +384,16 @@ export const claudeCodeModels = {
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-sonnet-4-6": {
|
||||
...anthropicModels["claude-sonnet-4-6"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-sonnet-4-6[1m]": {
|
||||
...anthropicModels["claude-sonnet-4-6:1m"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-sonnet-4-5-20250929": {
|
||||
...anthropicModels["claude-sonnet-4-5-20250929"],
|
||||
supportsImages: false,
|
||||
@@ -418,6 +451,31 @@ export const claudeCodeModels = {
|
||||
export type BedrockModelId = keyof typeof bedrockModels
|
||||
export const bedrockDefaultModelId: BedrockModelId = "anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
export const bedrockModels = {
|
||||
"anthropic.claude-sonnet-4-6": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
},
|
||||
"anthropic.claude-sonnet-4-6:1m": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
tiers: CLAUDE_SONNET_1M_TIERS,
|
||||
},
|
||||
"anthropic.claude-sonnet-4-5-20250929-v1:0": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
@@ -713,6 +771,7 @@ export const bedrockModels = {
|
||||
export const openRouterDefaultModelId = "anthropic/claude-sonnet-4.5" // will always exist in openRouterModels
|
||||
export const openRouterClaudeSonnet41mModelId = `anthropic/claude-sonnet-4${CLAUDE_SONNET_1M_SUFFIX}`
|
||||
export const openRouterClaudeSonnet451mModelId = `anthropic/claude-sonnet-4.5${CLAUDE_SONNET_1M_SUFFIX}`
|
||||
export const openRouterClaudeSonnet461mModelId = `anthropic/claude-sonnet-4.6${CLAUDE_SONNET_1M_SUFFIX}`
|
||||
export const openRouterClaudeOpus461mModelId = `anthropic/claude-opus-4.6${CLAUDE_SONNET_1M_SUFFIX}`
|
||||
export const openRouterDefaultModelInfo: ModelInfo = {
|
||||
maxTokens: 8192,
|
||||
@@ -724,7 +783,7 @@ export const openRouterDefaultModelInfo: ModelInfo = {
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
description:
|
||||
"Claude Sonnet 4.5 delivers superior intelligence across coding, agentic search, and AI agent capabilities. It's a powerful choice for agentic coding, and can complete tasks across the entire software development lifecycle—from initial planning to bug fixes, maintenance to large refactors. It offers strong performance in both planning and solving for complex coding tasks, making it an ideal choice to power end-to-end software development processes.\n\nRead more in the [blog post here](https://www.anthropic.com/claude/sonnet)",
|
||||
"Claude Sonnet 4.5 delivers superior intelligence across coding, agentic search, and AI agent capabilities. It's a powerful choice for agentic coding, and can complete tasks across the entire software development lifecycle, from initial planning to bug fixes, maintenance to large refactors. It offers strong performance in both planning and solving for complex coding tasks, making it an ideal choice to power end-to-end software development processes.\n\nRead more in the [blog post here](https://www.anthropic.com/claude/sonnet)",
|
||||
}
|
||||
|
||||
// Cline custom model - Devstral
|
||||
@@ -862,6 +921,29 @@ export const vertexModels = {
|
||||
supportsThinkingLevel: true,
|
||||
},
|
||||
},
|
||||
"claude-sonnet-4-6": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
"claude-sonnet-4-6:1m": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_SONNET_1M_TIERS,
|
||||
},
|
||||
"claude-sonnet-4-5@20250929": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
@@ -2894,6 +2976,14 @@ export const askSageModels = {
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"claude-4.6-sonnet": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"claude-4-sonnet": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
@@ -3485,24 +3575,6 @@ export const cerebrasModels = {
|
||||
outputPrice: 0,
|
||||
description: "Intelligent model with ~1400 tokens/s",
|
||||
},
|
||||
"llama-3.3-70b": {
|
||||
maxTokens: 64000,
|
||||
contextWindow: 64000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Powerful model with ~2600 tokens/s",
|
||||
},
|
||||
"qwen-3-32b": {
|
||||
maxTokens: 64000,
|
||||
contextWindow: 64000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "SOTA coding performance with ~2500 tokens/s",
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// Groq
|
||||
@@ -3655,6 +3727,13 @@ export const sapAiCoreModels = {
|
||||
supportsPromptCache: true,
|
||||
description: sapAiCoreModelDescription,
|
||||
},
|
||||
"anthropic--claude-4.6-sonnet": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
description: sapAiCoreModelDescription,
|
||||
},
|
||||
"anthropic--claude-4.5-sonnet": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
@@ -4361,8 +4440,18 @@ export const qwenCodeDefaultModelId: QwenCodeModelId = "qwen3-coder-plus"
|
||||
// https://www.minimax.io/platform/document/text_api_intro
|
||||
// https://www.minimax.io/platform/document/pricing
|
||||
export type MinimaxModelId = keyof typeof minimaxModels
|
||||
export const minimaxDefaultModelId: MinimaxModelId = "MiniMax-M2.1"
|
||||
export const minimaxDefaultModelId: MinimaxModelId = "MiniMax-M2.5"
|
||||
export const minimaxModels = {
|
||||
"MiniMax-M2.5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 192_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.3,
|
||||
outputPrice: 1.2,
|
||||
cacheWritesPrice: 0.0375,
|
||||
cacheReadsPrice: 0.03,
|
||||
},
|
||||
"MiniMax-M2.1": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 192_000,
|
||||
|
||||
@@ -87,13 +87,30 @@ export interface BannerAction {
|
||||
*/
|
||||
|
||||
export const BANNER_DATA: BannerCardData[] = [
|
||||
// Sonnet 4.6 banner
|
||||
{
|
||||
// Bump this version string when copy/CTA changes and you want the banner to reappear.
|
||||
id: "claude-sonnet-4-6-2026-feb-18",
|
||||
icon: "sparkles",
|
||||
title: "Try Claude Sonnet 4.6",
|
||||
description: "Anthropic's latest model with strong reasoning and coding performance.",
|
||||
actions: [
|
||||
{
|
||||
title: "Use Sonnet 4.6",
|
||||
action: BannerActionType.SetModel,
|
||||
arg: "anthropic/claude-sonnet-4.6",
|
||||
tab: "recommended",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Minimax free promo banner
|
||||
{
|
||||
// Bump this version string when copy/CTA changes and you want the banner to reappear.
|
||||
id: `minimax-m2.5-free-2026-feb-13`,
|
||||
id: "minimax-m2.5-free-2026-feb-18",
|
||||
icon: "zap",
|
||||
title: "Try MiniMax M2.5 Free",
|
||||
description: "SOTA open source model with great coding capability and subagent use.",
|
||||
description: "SOTA coding capability with lightning fast inference, free in Cline.",
|
||||
actions: [
|
||||
{
|
||||
title: "Try now",
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,22 @@ export const CLINE_ONBOARDING_MODELS: OnboardingModel[] = [
|
||||
tiers: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
group: "free",
|
||||
id: "minimax/minimax-m2.5",
|
||||
name: "MiniMax: MiniMax M2.5",
|
||||
score: 90,
|
||||
latency: 2,
|
||||
badge: "New",
|
||||
info: {
|
||||
contextWindow: 192_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
tiers: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
group: "free",
|
||||
id: "arcee-ai/trinity-large-preview:free",
|
||||
|
||||
@@ -7,6 +7,9 @@ export enum FeatureFlag {
|
||||
ONBOARDING_MODELS = "onboarding_models",
|
||||
// Feature flag for remote banner service
|
||||
REMOTE_BANNERS = "remote-banners",
|
||||
// Feature flag for DB-backed welcome banners (What's New modal)
|
||||
// When off, hardcoded welcome items are shown instead
|
||||
REMOTE_WELCOME_BANNERS = "remote-welcome-banners",
|
||||
}
|
||||
|
||||
export const FeatureFlagDefaultValue: Partial<Record<FeatureFlag, FeatureFlagPayload>> = {
|
||||
@@ -14,6 +17,7 @@ export const FeatureFlagDefaultValue: Partial<Record<FeatureFlag, FeatureFlagPay
|
||||
[FeatureFlag.WORKTREES]: false,
|
||||
[FeatureFlag.ONBOARDING_MODELS]: process.env.E2E_TEST === "true" ? { models: {} } : undefined,
|
||||
[FeatureFlag.REMOTE_BANNERS]: process.env.E2E_TEST === "true" || process.env.IS_DEV === "true",
|
||||
[FeatureFlag.REMOTE_WELCOME_BANNERS]: process.env.E2E_TEST === "true" || process.env.IS_DEV === "true",
|
||||
}
|
||||
|
||||
export const FEATURE_FLAGS = Object.values(FeatureFlag)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Locator, Page } from "@playwright/test"
|
||||
import type { Page } from "@playwright/test"
|
||||
|
||||
export const openTab = async (_page: Page, tabName: string) => {
|
||||
await _page
|
||||
@@ -8,36 +8,20 @@ export const openTab = async (_page: Page, tabName: string) => {
|
||||
}
|
||||
|
||||
export const addSelectedCodeToClineWebview = async (_page: Page) => {
|
||||
const clickActionIfVisible = async (locator: Locator) => {
|
||||
try {
|
||||
await locator.waitFor({ state: "visible", timeout: 5000 })
|
||||
await locator.click({ delay: 100 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
await _page.locator("div:nth-child(4) > span > span").first().click()
|
||||
await _page.getByRole("textbox", { name: "The editor is not accessible" }).press("ControlOrMeta+a")
|
||||
|
||||
// Open Code Actions via keyboard for cross-platform reliability
|
||||
await _page.keyboard.press("ControlOrMeta+.")
|
||||
|
||||
// Target the explicit action instead of pressing Enter on the first item.
|
||||
// The first item can vary by platform or diagnostics.
|
||||
const addToClineOption = _page.getByRole("option", { name: /Add to Cline/i }).first()
|
||||
const addToClineMenuItem = _page.getByRole("menuitem", { name: /Add to Cline/i }).first()
|
||||
|
||||
if (await clickActionIfVisible(addToClineOption)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (await clickActionIfVisible(addToClineMenuItem)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback for unexpected code action UIs.
|
||||
await _page.keyboard.press("Enter", { delay: 100 })
|
||||
const addToCline = _page.getByText(/Add to Cline/i)
|
||||
await addToCline.waitFor({ state: "visible" })
|
||||
// For whatever reason, we need to move the mouse to make the context menu item clickable
|
||||
await _page.mouse.move(10, 10)
|
||||
await _page.mouse.move(20, 10)
|
||||
await addToCline.click()
|
||||
}
|
||||
|
||||
export const toggleNotifications = async (_page: Page) => {
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -154,7 +185,7 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
|
||||
break
|
||||
|
||||
case BannerActionType.SetModel: {
|
||||
const modelId = action.arg || "anthropic/claude-opus-4.6"
|
||||
const modelId = action.arg || "anthropic/claude-sonnet-4.5"
|
||||
const initialModelTab = action.tab || "recommended"
|
||||
handleFieldsChange({
|
||||
planModeOpenRouterModelId: modelId,
|
||||
@@ -246,7 +277,13 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 w-full h-full p-0 m-0">
|
||||
<WhatsNewModal onClose={handleCloseWhatsNewModal} open={showWhatsNewModal} version={version} />
|
||||
<WhatsNewModal
|
||||
onBannerAction={handleBannerAction}
|
||||
onClose={handleCloseWhatsNewModal}
|
||||
open={showWhatsNewModal}
|
||||
version={version}
|
||||
welcomeBanners={welcomeBanners}
|
||||
/>
|
||||
<div className="overflow-y-auto flex flex-col pb-2.5">
|
||||
<HomeHeader shouldShowQuickWins={shouldShowQuickWins} />
|
||||
{!showWhatsNewModal && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
|
||||
import { AskResponseRequest, NewTaskRequest } from "@shared/proto/cline/task"
|
||||
import { useCallback } from "react"
|
||||
import { useCallback, useRef } from "react"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { SlashServiceClient, TaskServiceClient } from "@/services/grpc-client"
|
||||
import type { ButtonActionType } from "../shared/buttonConfig"
|
||||
@@ -24,6 +24,7 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
clineAsk,
|
||||
lastMessage,
|
||||
} = chatState
|
||||
const cancelInFlightRef = useRef(false)
|
||||
|
||||
// Handle sending a message
|
||||
const handleSendMessage = useCallback(
|
||||
@@ -249,16 +250,28 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
}
|
||||
break
|
||||
|
||||
case "cancel":
|
||||
if (backgroundCommandRunning) {
|
||||
await TaskServiceClient.cancelBackgroundCommand(EmptyRequest.create({}))
|
||||
} else {
|
||||
await TaskServiceClient.cancelTask(EmptyRequest.create({}))
|
||||
case "cancel": {
|
||||
if (cancelInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
cancelInFlightRef.current = true
|
||||
setSendingDisabled(true)
|
||||
setEnableButtons(false)
|
||||
try {
|
||||
if (backgroundCommandRunning) {
|
||||
await TaskServiceClient.cancelBackgroundCommand(EmptyRequest.create({})).catch((err) =>
|
||||
console.error("Failed to cancel background command:", err),
|
||||
)
|
||||
}
|
||||
await TaskServiceClient.cancelTask(EmptyRequest.create({}))
|
||||
} finally {
|
||||
cancelInFlightRef.current = false
|
||||
// Clear any pending state that might interfere with resume
|
||||
setSendingDisabled(false)
|
||||
setEnableButtons(true)
|
||||
}
|
||||
// Clear any pending state that might interfere with resume
|
||||
setSendingDisabled(false)
|
||||
setEnableButtons(true)
|
||||
break
|
||||
}
|
||||
|
||||
case "utility":
|
||||
switch (clineAsk) {
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { cn } from "@heroui/react"
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
export const AutoCondenseMarker: React.FC<{
|
||||
threshold: number
|
||||
usage: number
|
||||
isContextWindowHoverOpen?: boolean
|
||||
shouldAnimate?: boolean
|
||||
}> = ({ threshold, usage, isContextWindowHoverOpen, shouldAnimate = false }) => {
|
||||
const [isAnimating, setIsAnimating] = useState(false)
|
||||
const [animatedPosition, setAnimatedPosition] = useState(0)
|
||||
const [showPercentageAfterAnimation, setShowPercentageAfterAnimation] = useState(false)
|
||||
const [isFadingOut, setIsFadingOut] = useState(false)
|
||||
|
||||
// Refs to store animation frame and timeout IDs for cleanup
|
||||
const animationFrameRef = useRef<number | null>(null)
|
||||
const fadeOutTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Animation effect when shouldAnimate prop changes (initial load)
|
||||
useEffect(() => {
|
||||
// Cleanup function to cancel any pending animations or timeouts
|
||||
const cleanup = () => {
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = null
|
||||
}
|
||||
if (fadeOutTimeoutRef.current !== null) {
|
||||
clearTimeout(fadeOutTimeoutRef.current)
|
||||
fadeOutTimeoutRef.current = null
|
||||
}
|
||||
if (hideTimeoutRef.current !== null) {
|
||||
clearTimeout(hideTimeoutRef.current)
|
||||
hideTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldAnimate && threshold > 0) {
|
||||
// Clean up any existing animations before starting new one
|
||||
cleanup()
|
||||
|
||||
setIsAnimating(true)
|
||||
const targetPosition = threshold * 100
|
||||
const duration = 1200 // ms - slowed down from 800ms
|
||||
const startTime = Date.now()
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
// Ease-out animation curve
|
||||
const easeOut = 1 - (1 - progress) ** 3
|
||||
const currentPosition = easeOut * targetPosition
|
||||
setAnimatedPosition(currentPosition)
|
||||
|
||||
if (progress < 1) {
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
} else {
|
||||
animationFrameRef.current = null
|
||||
setIsAnimating(false)
|
||||
setShowPercentageAfterAnimation(true)
|
||||
// Start fade out after 1 second
|
||||
fadeOutTimeoutRef.current = setTimeout(() => {
|
||||
setIsFadingOut(true)
|
||||
// Completely hide after fade transition
|
||||
hideTimeoutRef.current = setTimeout(() => {
|
||||
setShowPercentageAfterAnimation(false)
|
||||
setIsFadingOut(false)
|
||||
hideTimeoutRef.current = null
|
||||
setAnimatedPosition(threshold * 100) // Ensure it ends exactly at threshold
|
||||
}, 300) // 300ms fade duration
|
||||
fadeOutTimeoutRef.current = null
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
// Cleanup on unmount or when dependencies change
|
||||
return cleanup
|
||||
}, [shouldAnimate, threshold])
|
||||
|
||||
// The marker position is calculated based on the threshold percentage
|
||||
// It goes over the progress bar to indicate where the auto-condense will trigger
|
||||
// and it should highlight from what the current percentage (usage) is
|
||||
// to the threshold percentage
|
||||
const marker = useMemo(() => {
|
||||
const _threshold = threshold * 100
|
||||
// Always use the current threshold for position and label - animation only affects visual movement
|
||||
const position = _threshold
|
||||
const startingPosition = isAnimating ? animatedPosition : position
|
||||
|
||||
return {
|
||||
start: startingPosition + "%",
|
||||
label: startingPosition.toFixed(0),
|
||||
end: usage > startingPosition ? usage - startingPosition + "%" : 0,
|
||||
}
|
||||
}, [threshold, usage, isAnimating, animatedPosition])
|
||||
|
||||
if (!threshold) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1" id="auto-condense-threshold-marker">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 bottom-0 h-full cursor-pointer pointer-events-none z-10 bg-button-background shadow-lg w-1",
|
||||
{
|
||||
"transition-all duration-75": !isAnimating,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
left: marker.start,
|
||||
transform: isAnimating ? `translateX(${animatedPosition - threshold * 100}%)` : "translateX(0)",
|
||||
}}>
|
||||
{(isContextWindowHoverOpen || isAnimating || showPercentageAfterAnimation) && (
|
||||
<div
|
||||
className={cn("absolute -top-4 -left-1 text-button-background font-mono text-xs", {
|
||||
"opacity-0": isFadingOut,
|
||||
})}>
|
||||
{marker.label}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
AutoCondenseMarker.displayName = "AutoCondenseMarker"
|
||||
@@ -1,11 +1,9 @@
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import debounce from "debounce"
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { updateSetting } from "@/components/settings/utils/settingsHandlers"
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { formatLargeNumber as formatTokenNumber } from "@/utils/format"
|
||||
import { AutoCondenseMarker } from "./AutoCondenseMarker"
|
||||
import CompactTaskButton from "./buttons/CompactTaskButton"
|
||||
import { ContextWindowSummary } from "./ContextWindowSummary"
|
||||
|
||||
@@ -22,7 +20,6 @@ interface ContextWindowProgressProps extends ContextWindowInfoProps {
|
||||
useAutoCondense: boolean
|
||||
lastApiReqTotalTokens?: number
|
||||
contextWindow?: number
|
||||
autoCondenseThreshold?: number
|
||||
onSendMessage?: (command: string, files: string[], images: string[]) => void
|
||||
}
|
||||
|
||||
@@ -58,7 +55,6 @@ ConfirmationDialog.displayName = "ConfirmationDialog"
|
||||
const ContextWindow: React.FC<ContextWindowProgressProps> = ({
|
||||
contextWindow = 0,
|
||||
lastApiReqTotalTokens = 0,
|
||||
autoCondenseThreshold = 0.75,
|
||||
onSendMessage,
|
||||
useAutoCondense,
|
||||
tokensIn,
|
||||
@@ -67,32 +63,8 @@ const ContextWindow: React.FC<ContextWindowProgressProps> = ({
|
||||
cacheReads,
|
||||
}) => {
|
||||
const [isOpened, setIsOpened] = useState(false)
|
||||
const [threshold, setThreshold] = useState(useAutoCondense ? autoCondenseThreshold : 0)
|
||||
const [confirmationNeeded, setConfirmationNeeded] = useState(false)
|
||||
const progressBarRef = useRef<HTMLDivElement>(null)
|
||||
const [shouldAnimateMarker, setShouldAnimateMarker] = useState(false)
|
||||
|
||||
// Trigger marker animation when component first mounts (TaskHeader expands)
|
||||
useEffect(() => {
|
||||
if (useAutoCondense && threshold > 0) {
|
||||
setShouldAnimateMarker(true)
|
||||
// Reset animation flag after animation completes
|
||||
const timer = setTimeout(() => {
|
||||
setShouldAnimateMarker(false)
|
||||
}, 1400) // Slightly longer than animation duration (1200ms + buffer)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, []) // Empty dependency array means this only runs on mount
|
||||
|
||||
const handleContextWindowBarClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const clickX = event.clientX - rect.left
|
||||
const percentage = Math.max(0, Math.min(1, clickX / rect.width))
|
||||
const newThreshold = Math.round(percentage * 100) / 100
|
||||
setConfirmationNeeded(false)
|
||||
setThreshold(newThreshold)
|
||||
updateSetting("autoCondenseThreshold", newThreshold)
|
||||
}, [])
|
||||
|
||||
const handleCompactClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -138,43 +110,6 @@ const ContextWindow: React.FC<ContextWindowProgressProps> = ({
|
||||
return showHover(false)
|
||||
}, [])
|
||||
|
||||
// Keyboard event handlers
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!useAutoCondense) {
|
||||
return
|
||||
}
|
||||
|
||||
const step = event.shiftKey ? 0.1 : 0.05 // Larger step with Shift
|
||||
let newThreshold = threshold
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowLeft":
|
||||
case "ArrowDown":
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setIsOpened(true) // Keep tooltip open on interaction
|
||||
newThreshold = Math.max(0, threshold - step)
|
||||
break
|
||||
case "ArrowRight":
|
||||
case "ArrowUp":
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setIsOpened(true) // Keep tooltip open on interaction
|
||||
newThreshold = Math.min(1, threshold + step)
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
if (newThreshold !== threshold) {
|
||||
setThreshold(newThreshold)
|
||||
updateSetting("autoCondenseThreshold", newThreshold)
|
||||
}
|
||||
},
|
||||
[threshold, useAutoCondense, setIsOpened],
|
||||
)
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsOpened(true)
|
||||
}, [])
|
||||
@@ -183,7 +118,7 @@ const ContextWindow: React.FC<ContextWindowProgressProps> = ({
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element
|
||||
const isInsideProgressBar = progressBarRef.current && progressBarRef.current.contains(target as Node)
|
||||
const isInsideProgressBar = progressBarRef.current?.contains(target as Node)
|
||||
|
||||
// Check if click is inside any tooltip content by looking for our custom class
|
||||
const isInsideTooltipContent = target.closest(".context-window-tooltip-content") !== null
|
||||
@@ -214,7 +149,6 @@ const ContextWindow: React.FC<ContextWindowProgressProps> = ({
|
||||
<HoverCard>
|
||||
<HoverCardContent className="bg-menu rounded-xs shadow-sm">
|
||||
<ContextWindowSummary
|
||||
autoCompactThreshold={useAutoCondense ? threshold : undefined}
|
||||
cacheReads={cacheReads}
|
||||
cacheWrites={cacheWrites}
|
||||
contextWindow={tokenData.max}
|
||||
@@ -225,32 +159,17 @@ const ContextWindow: React.FC<ContextWindowProgressProps> = ({
|
||||
/>
|
||||
</HoverCardContent>
|
||||
<HoverCardTrigger asChild>
|
||||
{/* TODO: Re-add role="slider", aria-value*, onKeyDown, onClick, and tabIndex
|
||||
when click-to-set-threshold is implemented. See PR #9348 for context. */}
|
||||
<div
|
||||
aria-label="Auto condense threshold"
|
||||
aria-valuemax={100}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={Math.round(threshold * 100)}
|
||||
aria-valuetext={`${Math.round(threshold * 100)}% threshold`}
|
||||
className="relative w-full text-foreground context-window-progress brightness-100"
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={progressBarRef}
|
||||
role="slider"
|
||||
tabIndex={useAutoCondense ? 0 : -1}>
|
||||
ref={progressBarRef}>
|
||||
<Progress
|
||||
aria-label="Context window usage progress"
|
||||
color="success"
|
||||
onClick={handleContextWindowBarClick}
|
||||
value={tokenData.percentage}
|
||||
/>
|
||||
{useAutoCondense && (
|
||||
<AutoCondenseMarker
|
||||
isContextWindowHoverOpen={isOpened}
|
||||
shouldAnimate={shouldAnimateMarker}
|
||||
threshold={threshold}
|
||||
usage={tokenData.percentage}
|
||||
/>
|
||||
)}
|
||||
{isOpened}
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { BannerAction, BannerCardData } from "@shared/cline/banner"
|
||||
import React from "react"
|
||||
import Markdown from "react-markdown"
|
||||
|
||||
interface WhatsNewItemsProps {
|
||||
welcomeBanners?: BannerCardData[]
|
||||
onBannerAction?: (action: BannerAction) => void
|
||||
onClose: () => void
|
||||
inlineCodeStyle: React.CSSProperties
|
||||
onNavigateToModelPicker: (initialModelTab: "recommended" | "free", modelId?: string) => void
|
||||
}
|
||||
|
||||
type InlineModelLinkProps = { pickerTab: "recommended" | "free"; modelId: string; label: string }
|
||||
|
||||
export const WhatsNewItems: React.FC<WhatsNewItemsProps> = ({
|
||||
welcomeBanners,
|
||||
onBannerAction,
|
||||
onClose,
|
||||
inlineCodeStyle,
|
||||
onNavigateToModelPicker,
|
||||
}) => {
|
||||
const InlineModelLink: React.FC<InlineModelLinkProps> = ({ pickerTab, modelId, label }) => (
|
||||
<span
|
||||
onClick={() => onNavigateToModelPicker(pickerTab, modelId)}
|
||||
style={{ color: "var(--vscode-textLink-foreground)", cursor: "pointer" }}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
|
||||
const hasWelcomeBanners = welcomeBanners && welcomeBanners.length > 0
|
||||
|
||||
return (
|
||||
<ul className="text-sm pl-3 list-disc" style={{ color: "var(--vscode-descriptionForeground)" }}>
|
||||
{hasWelcomeBanners ? (
|
||||
welcomeBanners.map((banner) => (
|
||||
<li className="mb-2" key={banner.id}>
|
||||
{banner.title && <strong>{banner.title}</strong>}{" "}
|
||||
{banner.description && (
|
||||
<Markdown
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: "var(--vscode-textLink-foreground)" }}
|
||||
target="_blank">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ children }) => <code style={inlineCodeStyle}>{children}</code>,
|
||||
p: ({ children }) => <p style={{ display: "inline", margin: 0 }}>{children}</p>,
|
||||
}}>
|
||||
{banner.description}
|
||||
</Markdown>
|
||||
)}
|
||||
{banner.actions && banner.actions.length > 0 && onBannerAction && (
|
||||
<span className="inline-flex gap-2 ml-2 align-middle">
|
||||
{banner.actions.map((action, idx) => (
|
||||
<a
|
||||
href="#"
|
||||
key={idx}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
onBannerAction(action)
|
||||
onClose()
|
||||
}}
|
||||
style={{
|
||||
color: "var(--vscode-textLink-foreground)",
|
||||
cursor: "pointer",
|
||||
}}>
|
||||
{action.title}
|
||||
</a>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{/* Hardcoded fallback items shown when remote welcome banners feature flag is off */}
|
||||
<li className="mb-2">
|
||||
<strong>Claude Sonnet 4.6 is here!</strong> Advanced reasoning and coding performance.{" "}
|
||||
<InlineModelLink label="Try now" modelId="anthropic/claude-sonnet-4.6" pickerTab="recommended" />
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
<strong>Gemini 3.1 Pro:</strong> Google's latest SOTA model across most reasoning, coding, and stem use
|
||||
cases <InlineModelLink label="Try now" modelId="google/gemini-3.1-pro-preview" pickerTab="recommended" />
|
||||
</li>{" "}
|
||||
<li className="mb-2">
|
||||
<strong>MiniMax M2.5 & Z.ai GLM 5:</strong> available in Cline with free promo, ends Friday Feb 20{" "}
|
||||
<InlineModelLink label="Try now" modelId="minimax/minimax-m2.5" pickerTab="free" />
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
<strong>Cline CLI 2.0:</strong> Major upgrade bringing interactive and autonomous agentic coding to your
|
||||
terminal. Install with <code style={inlineCodeStyle}>npm install -g cline</code>
|
||||
<a
|
||||
href="https://cline.bot/cli"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: "var(--vscode-textLink-foreground)" }}
|
||||
target="_blank">
|
||||
{" "}
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export default WhatsNewItems
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user