Compare commits

..

16 Commits

Author SHA1 Message Date
github-actions[bot] 81f5e632d0 v3.60.0 Release Notes
- Fixes for Minimax model family
- Fixes for Response chaining for OpenAI's Responses API
2026-02-13 08:55:15 -08:00
Bee 1a29d428ae refactor: BannerService initialization and cache management (#8969)
* increases banner cache duration to 24 hours so we make one api calls per day per user; implements a circuit breaker that stops retrying after 3 consecutive failures

* add new tests

* Clear banner cache when auth status changes

* revert 5898bc6e0e

* Fixing circuit breaker

* fix: reset circuitBreakerOpenedAt on failed half-open recovery

Previously, circuitBreakerOpenedAt was only set when consecutiveFailures
reached exactly MAX_CONSECUTIVE_FAILURES. This meant that after a failed
half-open recovery attempt, the timestamp wasn't updated, causing the
circuit breaker to immediately enter half-open state again on the next call.

Now circuitBreakerOpenedAt is updated on every failure once the circuit
breaker is tripped, ensuring proper timeout between recovery attempts.

* refactor: BannerService initialization and cache management

- Move BannerService initialization from common.ts to AuthService (which is initialized in controller)
- Re-initialize BannerService after auth state updates to ensure user context
- Add HostRegistryInfo to centralize host/platform information collection
- Improve rate limiting with exponential backoff (5min → 15min → 30min)
- Refactor error handling to better distinguish between rate limits and server errors
- Remove temporary disabled banner fetching comments

This change ensures banners are only fetched when user authentication is
available and implements more robust rate limiting to prevent API hammering.
The banner service now properly tracks user context and respects server
rate limits with progressive backoff delays.

* refactor(banner): simplify banner service initialization and usage

- Remove `getBanners()` wrapper method from Controller class
- Call `BannerService.get().getActiveBanners()` directly in Controller
- Change `BannerService.initialize()` to synchronous, returns instance immediately
- Make banner fetching non-blocking by moving to background
- Remove unused `BannerCardData` import from Controller
- Update tests to handle asynchronous background fetching with timeouts
- Clean up AuthService banner service initialization comment

This change simplifies the banner service API by removing unnecessary abstraction layers and making initialization non-blocking. The service now fetches banners in the background rather than blocking on initialization, improving application startup performance.

* clean up

* apply feedback

* un-skip unit test

* mock

* mock env

* clean up and add debounce fetch

* log fetch time

* revert

* feature flag: remote-banners

* fix loop in authService on auth update

Co-authored-by: Tomás Barreiro <BarreiroT@users.noreply.github.com>

* Fix tests

* small fixes

* use .? for banner

* moves initializeDistinctId to StateManager

* initializeDistinctId

* use v2 endpoint

---------

Co-authored-by: Zhongying Qiao <cryptoque@users.noreply.github.com>
Co-authored-by: Arafatkatze <arafat.da.khan@gmail.com>
Co-authored-by: Tomás Barreiro <BarreiroT@users.noreply.github.com>
Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>
Co-authored-by: BarreiroT <tomasmbarreiroi@gmail.com>
2026-02-12 18:28:12 -08:00
Saoud Rizwan 92cf03e42c feat(subagents): simplify research output guidance and command workflow (#9284) 2026-02-12 16:00:02 -08:00
Bee 3ea393a5e9 fix: openai native provider token usage mapping (#9272)
* fix: openai native provider token usage mapping

- Add `store` parameter support to OpenAI native provider options to allow persisting completions.
- Fix incorrect mapping of `cached_tokens` and `reasoning_tokens` in usage statistics.
- Include `thoughtsTokenCount` in the final usage report to track reasoning model performance and costs.

* Update src/core/api/providers/openai-native.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-02-12 14:26:35 -08:00
Max 9829e7d49e restore yolo mode to what it was before cline cli started (#9205)
Apply suggestions from code review

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-12 11:10:24 -08:00
Max 56de96e5ff fix oca auth (#9145)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-02-12 10:20:28 -08:00
github-actions[bot] ecde79cf08 v3.59.0 Release Notes (#9263)
- Added Minimax 2.5 Free Promo
- Fixed Response chaining for OpenAI's Responses API

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-12 10:20:19 -08:00
Bee ff36cbcb87 feat: implement response chaining for Responses API (#9270)
* feat: implement response chaining for Responses API

Implement response chaining by tracking and passing previous_response_id
to continue conversations from the last assistant message. This enables
the Responses API to maintain context across multiple turns.

Key changes:
- Search backwards through messages to find last assistant message with ID
- Only send new messages after the chained response
- Track function call metadata (call_id, name, id) across chunks
- Include call_id in tool_call events for proper correlation
- Clean up debug logging and remove commented code
- Remove redundant "Ran out of tokens" log message

This improves conversation continuity and ensures function calls are
properly tracked with their associated IDs throughout the streaming
response lifecycle.

* clean up

* update oca

* codex
2026-02-12 10:01:34 -08:00
shey-cline 5a75f08118 Prevent Parent Container Scrolling In Dropdowns (#9146)
* init

* missed some dropdowns & make scroll behavior work for scrolling nested elements

* add changeset

* add combobox roles
2026-02-12 10:00:40 -08:00
shey-cline 120754c2fe Allow Custom AWS Regions in Bedrock (Extension) (#9104)
* init

* changeset

* addressed comments – add onBlur, aria attributes and redundant useMemo
2026-02-12 09:57:04 -08:00
Saoud Rizwan 5d048d09f8 fix(subagents): retry initial stream bootstrap failures (#9264)
* fix(subagents): retry initial stream bootstrap failures

* fix(subagents): align initial retry classification with main loop

* fix(subagents): compact context on window limit during startup

* fix(subagents): proactively compact context at token thresholds

* feat(subagents): optimize file reads before context truncation
2026-02-12 06:34:43 -08:00
Saoud Rizwan 36580ce086 chore(codex): update environment to use launch script and simplify reinstall
Point the VS Code action at the new run-extension-host.sh script and
drop the git checkout of lock files from the reinstall action.
2026-02-12 05:53:25 -08:00
Saoud Rizwan c584bf4185 feat(dev): add tmux-based extension host launch script
Replaces the inline VS Code launch command with a proper dev script that:
- Builds protos and webview upfront
- Runs esbuild, tsc, and webview watchers in parallel tmux panes
- Waits for dist/extension.js before launching the extension host
- Cleans up all processes and closes the dev window on Ctrl+C
2026-02-12 05:53:18 -08:00
Saoud Rizwan 8133babf41 fix(chat): keep focus chain placeholder visible to prevent layout jump (#9266)
* fix(webview): stabilize focus chain header space and placeholder

* fix(chat): add follow-up bottom scroll to avoid short scroll

* style(chat): refine markdown spacing and tool group summary tone

* fix(chat): retry auto-scroll at 40ms and 70ms

* fix(chat): keep focus chain placeholder visible until checklist exists
2026-02-12 03:50:01 -08:00
Saoud Rizwan 741f524da7 chore(deps): upgrade openai sdk to 6.21.0 for xhigh reasoning (#9267) 2026-02-12 03:48:13 -08:00
Robin Newhouse d3918dd7df fix(task): canonicalize attempt_completion result parameter (#9262) 2026-02-12 00:37:27 -06:00
86 changed files with 2843 additions and 1401 deletions
+1 -9
View File
@@ -14,14 +14,7 @@ fi
[[actions]]
name = "VS Code"
icon = "run"
command = '''
npm run compile && IS_DEV=true DEV_WORKSPACE_FOLDER="$(pwd)" CLINE_ENVIRONMENT=production code \
--extensionDevelopmentPath="$(pwd)" \
--disable-workspace-trust \
--disable-extension saoudrizwan.claude-dev \
--disable-extension saoudrizwan.cline-nightly \
"$(pwd)"
'''
command = "chmod +x ./scripts/run-extension-host.sh && ./scripts/run-extension-host.sh production"
[[actions]]
name = "CLI"
@@ -38,5 +31,4 @@ command = '''
rm node_modules
rm webview-ui/node_modules
npm run install:all
git checkout package-lock.json webview-ui/package-lock.json
'''
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## [3.60.0]
- Fixes for Minimax model family
## [3.59.0]
- Added Minimax 2.5 Free Promo
- Fixed Response chaining for OpenAI's Responses API
## [3.58.0]
### Added
+13
View File
@@ -1,8 +1,19 @@
# cline
## [2.2.2]
- Allows users to enter custom aws region when selecting bedrock as a provider
- Prevent Parent Container Scrolling In Dropdowns
## [2.2.1]
- Added Minimax 2.5 Free Promo
- Fixed Response chaining for OpenAI's Responses API
## [2.2.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
@@ -13,6 +24,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)
@@ -24,6 +36,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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cline",
"version": "2.2.0",
"version": "2.2.2",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"main": "dist/cli.mjs",
"bin": {
+19 -8
View File
@@ -5,12 +5,13 @@
import { Box, Text, useApp, useInput } from "ink"
import Spinner from "ink-spinner"
// biome-ignore lint/style/useImportType: React is used as a value by JSX (jsx: "react" in tsconfig)
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { refreshOcaModels } from "@/core/controller/models/refreshOcaModels"
import { StateManager } from "@/core/storage/StateManager"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { AuthService } from "@/services/auth/AuthService"
import { liteLlmDefaultModelId, openAiCodexDefaultModelId, openRouterDefaultModelId } from "@/shared/api"
import { openAiCodexDefaultModelId, openRouterDefaultModelId } from "@/shared/api"
import { StringRequest } from "@/shared/proto/cline/common"
import { openExternal } from "@/utils/env"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
@@ -31,6 +32,7 @@ import {
} from "./FeaturedModelPicker"
import { ImportView } from "./ImportView"
import { getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
import { OcaEmployeeCheck } from "./OcaEmployeeCheck"
import { getProviderLabel } from "./ProviderPicker"
type AuthStep =
@@ -43,6 +45,7 @@ type AuthStep =
| "success"
| "error"
| "cline_auth"
| "oca_employee_check"
| "oca_auth"
| "cline_model"
| "openai_codex_auth"
@@ -160,7 +163,6 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
const [modelId, setModelId] = useState("")
const [baseUrl, setBaseUrl] = useState("")
const [errorMessage, setErrorMessage] = useState("")
const [authStatus, setAuthStatus] = useState<string>("")
const [providerSearch, setProviderSearch] = useState("")
const [providerIndex, setProviderIndex] = useState(0)
const [clineModelIndex, setClineModelIndex] = useState(0)
@@ -171,11 +173,14 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
// OCA auth hook - enabled when step is oca_auth
const handleOcaAuthSuccess = useCallback(async () => {
await applyProviderConfig({ providerId: "oca", controller })
// Fetch OCA models from the API - this sets actModeOcaModelId/planModeOcaModelId in state
await refreshOcaModels(controller, StringRequest.create({ value: "" }))
const stateManager = StateManager.get()
stateManager.setGlobalState("welcomeViewCompleted", true)
await stateManager.flushPendingState()
setSelectedProvider("oca")
setModelId(liteLlmDefaultModelId)
const actModelId = stateManager.getGlobalSettingsKey("actModeOcaModelId") || ""
setModelId(actModelId)
setStep("success")
}, [controller])
@@ -317,7 +322,6 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
const startClineAuth = useCallback(async () => {
try {
setStep("cline_auth")
setAuthStatus("Starting authentication...")
await AuthService.getInstance(controller).createAuthRequest()
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : String(error))
@@ -327,7 +331,6 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
const startOcaAuth = useCallback(() => {
setStep("oca_auth")
setAuthStatus("Starting authentication...")
initiateOcaAuth()
}, [initiateOcaAuth])
@@ -358,7 +361,8 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
(value: string) => {
setSelectedProvider(value)
if (value === "oca") {
startOcaAuth()
// Show employee check screen before starting auth
setStep("oca_employee_check")
} else if (value === "openai-codex") {
setStep("openai_codex_auth")
startOpenAiCodexAuth()
@@ -534,9 +538,12 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
setBaseUrl("")
setStep("modelid")
break
case "oca_auth":
case "oca_employee_check":
setStep("provider")
break
case "oca_auth":
setStep("oca_employee_check")
break
case "cline_auth":
setStep("menu")
break
@@ -675,6 +682,9 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
</Box>
)
case "oca_employee_check":
return <OcaEmployeeCheck isActive={step === "oca_employee_check"} onCancel={goBack} onSignIn={startOcaAuth} />
case "oca_auth":
case "cline_auth":
return (
@@ -760,6 +770,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
const [menuIndex, setMenuIndex] = useState(0)
// Steps that allow going back with escape (apikey handled by ApiKeyInput component)
// OcaEmployeeCheck handles its own escape key, so oca_employee_check is not in this list
const canGoBack = [
"provider",
"modelid",
+18 -4
View File
@@ -6,6 +6,7 @@
import { Box, Text } from "ink"
import Spinner from "ink-spinner"
import React, { useEffect, useMemo, useState } from "react"
import { refreshOcaModels } from "@/core/controller/models/refreshOcaModels"
import { refreshOpenRouterModels } from "@/core/controller/models/refreshOpenRouterModels"
import {
type ApiProvider,
@@ -64,6 +65,7 @@ import {
xaiDefaultModelId,
xaiModels,
} from "@/shared/api"
import { StringRequest } from "@/shared/proto/cline/common"
import { filterOpenRouterModelIds } from "@/shared/utils/model-filters"
import { COLORS } from "../constants/colors"
import { getOpenRouterDefaultModelId, usesOpenRouterModels } from "../utils/openrouter-models"
@@ -105,7 +107,7 @@ export function hasStaticModels(provider: string): boolean {
}
export function hasModelPicker(provider: string): boolean {
return hasStaticModels(provider) || usesOpenRouterModels(provider)
return hasStaticModels(provider) || usesOpenRouterModels(provider) || provider === "oca"
}
export function getDefaultModelId(provider: string): string {
@@ -132,7 +134,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, controller,
const [isLoading, setIsLoading] = useState(false)
const [asyncModels, setAsyncModels] = useState<string[]>([])
// Fetch OpenRouter models when needed using shared core function
// Fetch async models (OpenRouter or OCA) when needed
useEffect(() => {
if (usesOpenRouterModels(provider)) {
setIsLoading(true)
@@ -145,11 +147,23 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, controller,
.finally(() => {
setIsLoading(false)
})
} else if (provider === "oca") {
setIsLoading(true)
refreshOcaModels(controller, StringRequest.create({ value: "" }))
.then((result) => {
if (result.models) {
const modelIds = Object.keys(result.models).sort((a, b) => a.localeCompare(b))
setAsyncModels(modelIds)
}
})
.finally(() => {
setIsLoading(false)
})
}
}, [provider, controller])
const modelList = useMemo(() => {
if (usesOpenRouterModels(provider)) {
if (usesOpenRouterModels(provider) || provider === "oca") {
return asyncModels
}
return getModelList(provider)
@@ -180,7 +194,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, controller,
}
// If async fetch returned no models, render nothing
if (usesOpenRouterModels(provider) && modelList.length === 0) {
if ((usesOpenRouterModels(provider) || provider === "oca") && modelList.length === 0) {
return null
}
+88
View File
@@ -0,0 +1,88 @@
/**
* OCA (Oracle Cloud Assist) employee check component.
* Shows a checkbox for "I'm an Oracle Employee" and a sign-in button.
* Sets ocaMode in state before triggering the OAuth flow.
*/
import { Box, Text, useInput } from "ink"
// biome-ignore lint/style/useImportType: React is used as a value by JSX (jsx: "react" in tsconfig)
import React, { useCallback, useState } from "react"
import { StateManager } from "@/core/storage/StateManager"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
interface OcaEmployeeCheckProps {
/** Whether this component is active and should handle input */
isActive: boolean
/** Called when user confirms and wants to proceed with sign-in */
onSignIn: () => void
/** Called when user presses Escape to go back */
onCancel: () => void
}
export const OcaEmployeeCheck: React.FC<OcaEmployeeCheckProps> = ({ isActive, onSignIn, onCancel }) => {
const { isRawModeSupported } = useStdinContext()
const [isEmployee, setIsEmployee] = useState(true) // Default to checked (internal), matching extension behavior
const [selectedIndex, setSelectedIndex] = useState(0) // 0 = checkbox, 1 = sign in button
const ITEM_COUNT = 2
const handleSignIn = useCallback(async () => {
// Persist ocaMode to state before starting auth
const stateManager = StateManager.get()
stateManager.setGlobalState("ocaMode", isEmployee ? "internal" : "external")
await stateManager.flushPendingState()
onSignIn()
}, [isEmployee, onSignIn])
useInput(
(_input, key) => {
if (key.escape) {
onCancel()
return
}
if (key.upArrow) {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : ITEM_COUNT - 1))
} else if (key.downArrow) {
setSelectedIndex((prev) => (prev < ITEM_COUNT - 1 ? prev + 1 : 0))
} else if (key.tab || (key.return && selectedIndex === 0)) {
// Toggle checkbox when Tab is pressed or Enter on checkbox item
if (selectedIndex === 0) {
setIsEmployee((prev) => !prev)
}
} else if (key.return && selectedIndex === 1) {
// Sign in button
handleSignIn()
}
},
{ isActive: isRawModeSupported && isActive },
)
return (
<Box flexDirection="column">
<Text color="white">Oracle Code Assist</Text>
<Text> </Text>
{/* Checkbox: I'm an Oracle Employee */}
<Text>
<Text bold color={selectedIndex === 0 ? COLORS.primaryBlue : undefined}>
{selectedIndex === 0 ? "" : " "}{" "}
</Text>
<Text color={selectedIndex === 0 || isEmployee ? COLORS.primaryBlue : "gray"}>{isEmployee ? "[✓]" : "[ ]"}</Text>
<Text color={selectedIndex === 0 ? COLORS.primaryBlue : "white"}> I'm an Oracle Employee</Text>
{selectedIndex === 0 && <Text color="gray"> (Tab to toggle)</Text>}
</Text>
{/* Sign in button */}
<Text>
<Text bold color={selectedIndex === 1 ? COLORS.primaryBlue : undefined}>
{selectedIndex === 1 ? "" : " "}{" "}
</Text>
<Text color={selectedIndex === 1 ? COLORS.primaryBlue : "white"}>Sign in with Oracle Code Assist</Text>
{selectedIndex === 1 && <Text color="gray"> (Enter)</Text>}
</Text>
<Text> </Text>
<Text color="gray">Please ask your IT administrator to set up Oracle Code Assist as a model provider.</Text>
<Text> </Text>
<Text color="gray">Arrows to navigate, Tab to toggle, Enter to continue, Esc to go back</Text>
</Box>
)
}
+23 -3
View File
@@ -14,10 +14,12 @@ import Spinner from "ink-spinner"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { buildApiHandler } from "@/core/api"
import type { Controller } from "@/core/controller"
import { refreshOcaModels } from "@/core/controller/models/refreshOcaModels"
import { StateManager } from "@/core/storage/StateManager"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService, ClineAccountOrganization } from "@/services/auth/AuthService"
import { StringRequest } from "@/shared/proto/cline/common"
import { openExternal } from "@/utils/env"
import { supportsReasoningEffortForModel } from "@/utils/model-utils"
import { version as CLI_VERSION } from "../../package.json"
@@ -37,6 +39,7 @@ import {
} from "./FeaturedModelPicker"
import { LanguagePicker } from "./LanguagePicker"
import { hasModelPicker, ModelPicker } from "./ModelPicker"
import { OcaEmployeeCheck } from "./OcaEmployeeCheck"
import { OrganizationPicker } from "./OrganizationPicker"
import { Panel, PanelTab } from "./Panel"
import { getProviderLabel, ProviderPicker } from "./ProviderPicker"
@@ -162,6 +165,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
const [isEnteringApiKey, setIsEnteringApiKey] = useState(false)
const [isConfiguringBedrock, setIsConfiguringBedrock] = useState(false)
const [isWaitingForCodexAuth, setIsWaitingForCodexAuth] = useState(false)
const [isShowingOcaEmployeeCheck, setIsShowingOcaEmployeeCheck] = useState(false)
const [codexAuthError, setCodexAuthError] = useState<string | null>(null)
const [pendingProvider, setPendingProvider] = useState<string | null>(null)
const [apiKeyValue, setApiKeyValue] = useState("")
@@ -235,6 +239,8 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
// OCA auth hook
const handleOcaAuthSuccess = useCallback(async () => {
await applyProviderConfig({ providerId: "oca", controller })
// Fetch OCA models from the API - this sets actModeOcaModelId/planModeOcaModelId in state
await refreshOcaModels(controller!, StringRequest.create({ value: "" }))
setProvider("oca")
refreshModelIds()
}, [controller, refreshModelIds])
@@ -1078,8 +1084,8 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
setProvider("oca")
refreshModelIds()
} else {
// Not logged in - trigger OAuth
startOcaAuth()
// Not logged in - show employee check before auth
setIsShowingOcaEmployeeCheck(true)
}
return
}
@@ -1370,7 +1376,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
return
}
},
{ isActive: isRawModeSupported && !isEnteringApiKey && !isConfiguringBedrock },
{ isActive: isRawModeSupported && !isEnteringApiKey && !isConfiguringBedrock && !isShowingOcaEmployeeCheck },
)
// Render content
@@ -1546,6 +1552,19 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
)
}
if (isShowingOcaEmployeeCheck) {
return (
<OcaEmployeeCheck
isActive={isShowingOcaEmployeeCheck}
onCancel={() => setIsShowingOcaEmployeeCheck(false)}
onSignIn={() => {
setIsShowingOcaEmployeeCheck(false)
startOcaAuth()
}}
/>
)
}
if (isWaitingForOcaAuth) {
return (
<Box flexDirection="column">
@@ -1727,6 +1746,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
!!codexAuthError ||
isPickingOrganization ||
isWaitingForClineAuth ||
isShowingOcaEmployeeCheck ||
isWaitingForOcaAuth ||
isEditing
+4 -13
View File
@@ -33,27 +33,18 @@ export const FEATURED_MODELS = {
] as FeaturedModel[],
free: [
{
id: "minimax/minimax-m2.1",
name: "MiniMax M2.1",
description: "Exceptional Multi-Programming Language Capabilities",
labels: ["FREE"],
},
{
id: "moonshotai/kimi-k2.5",
name: "Kimi K2.5",
description: "State-of-the-art model topping benchmarks",
id: "minimax/minimax-m2.5",
description: "MiniMax-M2.5 is a lightweight, state-of-the-art LLM optimized for coding and agentic workflows",
labels: ["FREE"],
},
{
id: "kwaipilot/kat-coder-pro",
name: "KAT Coder Pro",
description: "Advanced agentic coding model",
description: "KwaiKAT's most advanced agentic coding model in the KAT-Coder series",
labels: ["FREE"],
},
{
id: "arcee-ai/trinity-large-preview:free",
name: "Trinity Large Preview",
description: "US built open source coding model",
description: "Arcee AI's advanced large preview model in the Trinity series",
labels: ["FREE"],
},
] as FeaturedModel[],
+36 -7
View File
@@ -15,9 +15,7 @@ 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 { BannerService } from "@/services/banner/BannerService"
import { ErrorService } from "@/services/error/ErrorService"
import { initializeDistinctId } from "@/services/logging/distinctId"
import { telemetryService } from "@/services/telemetry"
import { PostHogClientProvider } from "@/services/telemetry/providers/posthog/PostHogClientProvider"
import { HistoryItem } from "@/shared/HistoryItem"
@@ -76,7 +74,24 @@ async function disposeTelemetryServices(): Promise<void> {
await Promise.allSettled([telemetryService.dispose(), PostHogClientProvider.getInstance().dispose()])
}
/**
* Restore yoloModeToggled to its original value from before this CLI session.
* This ensures the --yolo flag is session-only and doesn't leak into future runs.
* Must be called before flushPendingState so the restored value gets persisted.
*/
function restoreYoloState(): void {
if (savedYoloModeToggled !== null) {
try {
StateManager.get().setGlobalState("yoloModeToggled", savedYoloModeToggled)
savedYoloModeToggled = null
} catch {
// StateManager may not be initialized (e.g., early exit before init)
}
}
}
async function disposeCliContext(ctx: CliContext): Promise<void> {
restoreYoloState()
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
@@ -189,9 +204,12 @@ function applyTaskOptions(options: TaskOptions): void {
telemetryService.captureHostEvent("max_consecutive_mistakes_flag", String(maxConsecutiveMistakes))
}
// Set yolo mode based on --yolo flag
// Override yolo mode only if --yolo flag is explicitly passed.
// The original value is saved in initializeCli and restored on exit.
if (options.yolo) {
StateManager.get().setGlobalState("yoloModeToggled", true)
const state = StateManager.get()
savedYoloModeToggled = state.getGlobalSettingsKey("yoloModeToggled") ?? false
state.setGlobalState("yoloModeToggled", true)
telemetryService.captureHostEvent("yolo_flag", "true")
}
@@ -296,6 +314,9 @@ let activeContext: CliContext | null = null
let isShuttingDown = false
// Track if we're in plain text mode (no Ink UI) - set by runTask when piped stdin detected
let isPlainTextMode = false
// Track the original yoloModeToggled value from before this CLI session so we can restore it on exit.
// The --yolo flag should only affect the current invocation, not persist across runs.
let savedYoloModeToggled: boolean | null = null
/**
* Wait for stdout to fully drain before exiting.
@@ -337,6 +358,10 @@ function setupSignalHandlers() {
printWarning(`${signal} received, shutting down...`)
try {
// Restore yolo state before any cleanup - this is idempotent and safe
// even if disposeCliContext also calls it (restoreYoloState checks savedYoloModeToggled !== null)
restoreYoloState()
if (activeContext) {
const task = activeContext.controller.task
if (task) {
@@ -344,6 +369,12 @@ function setupSignalHandlers() {
}
await disposeCliContext(activeContext)
} else {
// Best-effort flush of restored yolo state when no active context
try {
await StateManager.get().flushPendingState()
} catch {
// StateManager may not be initialized yet
}
await ErrorService.get().dispose()
await disposeTelemetryServices()
}
@@ -407,7 +438,6 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
Logger.subscribe(logToChannel)
await ClineEndpoint.initialize(EXTENSION_DIR)
await initializeDistinctId(extensionContext)
// Auto-update check (after endpoints initialized, so we can detect bundled configs)
autoUpdateOnStartup(CLI_VERSION)
@@ -437,6 +467,7 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
)
await StateManager.initialize(extensionContext as any)
await ErrorService.initialize()
// Initialize OpenAI Codex OAuth manager with extension context for secrets storage
@@ -445,8 +476,6 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
const webview = HostProvider.get().createWebviewProvider() as CliWebviewProvider
const controller = webview.controller
BannerService.initialize(webview.controller)
await telemetryService.captureExtensionActivated()
await telemetryService.captureHostEvent("cline_cli", "initialized")
+1
View File
@@ -13,6 +13,7 @@ export default defineConfig({
},
resolve: {
alias: {
vscode: path.resolve(__dirname, "src/vscode-shim.ts"),
// Match tsconfig paths - baseUrl is parent directory
"@": path.resolve(__dirname, "../src"),
"@api": path.resolve(__dirname, "../src/core/api"),
+3 -1
View File
@@ -18,7 +18,7 @@ When Cline uses the `use_subagents` tool, it launches independent agents simulta
- Runs with a separate context window and token budget
- Can read files, search code, list directories, run read-only commands, and use skills
- Cannot edit files, use the browser, access MCP servers, or spawn nested subagents
- Returns a result that includes file paths, line numbers, and recommended files for the main agent to read next
- Returns a result focused on the most relevant file paths for the main agent to read next
Subagent costs (tokens and API spend) are tracked separately per subagent and rolled into the task's total cost. You can see per-subagent stats (tool calls, tokens, cost) in the chat UI as they run.
@@ -43,6 +43,7 @@ Example prompts:
- "I'm new to this codebase. Use subagents to map out the main entry points, the routing layer, and the data access patterns"
Each subagent prompt should describe a focused research question. Cline will run them in parallel and synthesize the results.
You can also run only one subagent when the task is small enough that parallel discovery would be unnecessary overhead.
## Auto-Approve Behavior
@@ -69,6 +70,7 @@ Subagents cannot write files, apply patches, use the browser, access MCP servers
<Note>
Commands run by subagents execute in the background and are restricted to read-only operations. Subagents will not run commands that modify files or system state.
Subagents also benefit from command pipelines and filters to narrow output quickly before reading files, for example `rg ... | sort | uniq`.
</Note>
## When to Use Subagents
+85 -50
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.58.0",
"version": "3.60.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.58.0",
"version": "3.60.0",
"license": "Apache-2.0",
"workspaces": [
"cli"
@@ -82,7 +82,7 @@
"ollama": "^0.5.13",
"open": "^10.1.2",
"open-graph-scraper": "^6.9.0",
"openai": "^6.9.0",
"openai": "^6.21.0",
"os-name": "^6.0.0",
"p-mutex": "^1.0.0",
"p-timeout": "^6.1.4",
@@ -161,7 +161,7 @@
},
"cli": {
"name": "cline",
"version": "2.0.5",
"version": "2.2.0",
"cpu": [
"x64",
"arm64"
@@ -3128,7 +3128,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"
@@ -4031,7 +4030,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",
@@ -4107,7 +4105,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"
}
@@ -5798,7 +5795,8 @@
"optional": true,
"os": [
"android"
]
],
"peer": true
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.57.1",
@@ -5811,7 +5809,8 @@
"optional": true,
"os": [
"android"
]
],
"peer": true
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.57.1",
@@ -5824,7 +5823,8 @@
"optional": true,
"os": [
"darwin"
]
],
"peer": true
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.57.1",
@@ -5837,7 +5837,8 @@
"optional": true,
"os": [
"darwin"
]
],
"peer": true
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.57.1",
@@ -5850,7 +5851,8 @@
"optional": true,
"os": [
"freebsd"
]
],
"peer": true
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.57.1",
@@ -5863,7 +5865,8 @@
"optional": true,
"os": [
"freebsd"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.57.1",
@@ -5876,7 +5879,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.57.1",
@@ -5889,7 +5893,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.57.1",
@@ -5902,7 +5907,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.57.1",
@@ -5915,7 +5921,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.57.1",
@@ -5928,7 +5935,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.57.1",
@@ -5941,7 +5949,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.57.1",
@@ -5954,7 +5963,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.57.1",
@@ -5967,7 +5977,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.57.1",
@@ -5980,7 +5991,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.57.1",
@@ -5993,7 +6005,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.57.1",
@@ -6006,7 +6019,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.1",
@@ -6019,7 +6033,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.1",
@@ -6032,7 +6047,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.57.1",
@@ -6045,7 +6061,8 @@
"optional": true,
"os": [
"openbsd"
]
],
"peer": true
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.57.1",
@@ -6058,7 +6075,8 @@
"optional": true,
"os": [
"openharmony"
]
],
"peer": true
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.57.1",
@@ -6071,7 +6089,8 @@
"optional": true,
"os": [
"win32"
]
],
"peer": true
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.57.1",
@@ -6084,7 +6103,8 @@
"optional": true,
"os": [
"win32"
]
],
"peer": true
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.57.1",
@@ -6097,7 +6117,8 @@
"optional": true,
"os": [
"win32"
]
],
"peer": true
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.57.1",
@@ -6110,7 +6131,8 @@
"optional": true,
"os": [
"win32"
]
],
"peer": true
},
"node_modules/@sap-ai-sdk/ai-api": {
"version": "2.6.0",
@@ -7782,7 +7804,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"
}
@@ -7845,7 +7866,6 @@
"integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -8780,7 +8800,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"
},
@@ -9745,7 +9764,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -11144,8 +11162,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",
@@ -12162,7 +12179,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",
@@ -13470,7 +13486,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"
}
@@ -13749,7 +13764,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",
@@ -15003,7 +15017,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"
}
@@ -15353,7 +15366,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"
},
@@ -17834,9 +17846,9 @@
}
},
"node_modules/openai": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.18.0.tgz",
"integrity": "sha512-odLRYyz9rlzz6g8gKn61RM2oP5UUm428sE2zOxZqS9MzVfD5/XW8UoEjpnRkzTuScXP7ZbP/m7fC+bl8jCOZZw==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.21.0.tgz",
"integrity": "sha512-26dQFi76dB8IiN/WKGQOV+yKKTTlRCxQjoi2WLt0kMcH8pvxVyvfdBDkld5GTl7W1qvBpwVOtFcsqktj3fBRpA==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
@@ -19090,7 +19102,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"
}
@@ -21798,7 +21809,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -22120,7 +22130,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",
@@ -22202,6 +22211,7 @@
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22218,6 +22228,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22234,6 +22245,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22250,6 +22262,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22266,6 +22279,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22282,6 +22296,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22298,6 +22313,7 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22314,6 +22330,7 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22330,6 +22347,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22346,6 +22364,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22362,6 +22381,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22378,6 +22398,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22394,6 +22415,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22410,6 +22432,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22426,6 +22449,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22442,6 +22466,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22458,6 +22483,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22474,6 +22500,7 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22490,6 +22517,7 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22506,6 +22534,7 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22522,6 +22551,7 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22538,6 +22568,7 @@
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22554,6 +22585,7 @@
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22570,6 +22602,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22586,6 +22619,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22602,6 +22636,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -22657,6 +22692,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
@@ -23591,7 +23627,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"
}
+2 -2
View File
@@ -2,7 +2,7 @@
"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.58.0",
"version": "3.60.0",
"icon": "assets/icons/icon.png",
"workspaces": [
"cli"
@@ -571,7 +571,7 @@
"ollama": "^0.5.13",
"open": "^10.1.2",
"open-graph-scraper": "^6.9.0",
"openai": "^6.9.0",
"openai": "^6.21.0",
"os-name": "^6.0.0",
"p-mutex": "^1.0.0",
"p-timeout": "^6.1.4",
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
SESSION="cline-dev"
WORKSPACE="${CLINE_WORKSPACE:-$(cd "$(dirname "$0")/.." && pwd)}"
ENVIRONMENT="${CLINE_ENVIRONMENT:-production}"
cd "$WORKSPACE"
# Export env vars -- tmux inherits them automatically
export IS_DEV=true
export DEV_WORKSPACE_FOLDER="$WORKSPACE"
export CLINE_ENVIRONMENT="$ENVIRONMENT"
if [ -f .env ]; then
set -a
source .env
set +a
fi
# Step 1: Build protos (everything depends on this)
echo "Building protos..."
npm run protos || { echo "Protos build failed"; exit 1; }
# Step 2: Build webview once
echo "Building webview..."
npm run build:webview || { echo "Webview build failed"; exit 1; }
# Step 3: Kill existing session if one is running
tmux kill-session -t "$SESSION" 2>/dev/null
true
# Step 4: Create tmux session with 4 vertical panes
#
# ┌────────────┬────────────┬────────────┬────────────┐
# │ esbuild │ tsc │ webview │ ext host │
# └────────────┴────────────┴────────────┴────────────┘
echo "Starting tmux session..."
tmux new-session -d -s "$SESSION" -c "$WORKSPACE"
tmux split-window -h -t "$SESSION" -c "$WORKSPACE"
tmux split-window -h -t "$SESSION:0.0" -c "$WORKSPACE"
tmux split-window -h -t "$SESSION:0.2" -c "$WORKSPACE"
tmux select-layout -t "$SESSION" even-horizontal
# Ctrl+C kills the whole session
tmux bind-key -T root C-c kill-session
tmux send-keys -t "$SESSION:0.0" "npm run watch:esbuild" Enter
tmux send-keys -t "$SESSION:0.1" "npm run watch:tsc" Enter
tmux send-keys -t "$SESSION:0.2" "npm run dev:webview" Enter
tmux send-keys -t "$SESSION:0.3" "while [ ! -f '$WORKSPACE/dist/extension.js' ]; do sleep 0.5; done && echo 'Launching Extension Host...' && code --extensionDevelopmentPath='$WORKSPACE' --disable-workspace-trust --disable-extension saoudrizwan.claude-dev --disable-extension saoudrizwan.cline-nightly '$WORKSPACE' && echo 'Extension Host launched.'" Enter
# Attach to the session
tmux attach-session -t "$SESSION"
# Session ended -- run full cleanup
tmux unbind-key -T root C-c 2>/dev/null
# Kill watcher processes and their node children
pkill -f "watch:esbuild|watch:tsc|dev:webview" 2>/dev/null
pkill -f "esbuild.mjs --watch" 2>/dev/null
pkill -f "tsc --noEmit --watch" 2>/dev/null
pkill -f "vite.*/webview-ui" 2>/dev/null
# Close the Extension Development Host window
osascript -e '
tell application "System Events"
tell process "Electron"
set windowList to every window whose title contains "Extension Development Host"
repeat with w in windowList
click button 1 of w
end repeat
end tell
end tell' 2>/dev/null
echo "Stopped"
+1 -7
View File
@@ -11,11 +11,10 @@ import { HookProcessRegistry } from "./core/hooks/HookProcessRegistry"
import { StateManager } from "./core/storage/StateManager"
import { openAiCodexOAuthManager } from "./integrations/openai-codex/oauth"
import { ExtensionRegistryInfo } from "./registry"
import { BannerService } from "./services/banner/BannerService"
import { audioRecordingService } from "./services/dictation/AudioRecordingService"
import { ErrorService } from "./services/error"
import { featureFlagsService } from "./services/feature-flags"
import { getDistinctId, initializeDistinctId } from "./services/logging/distinctId"
import { getDistinctId } from "./services/logging/distinctId"
import { telemetryService } from "./services/telemetry"
import { PostHogClientProvider } from "./services/telemetry/providers/posthog/PostHogClientProvider"
import { ClineTempManager } from "./services/temp"
@@ -44,9 +43,6 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
const { ClineEndpoint } = await import("./config")
await ClineEndpoint.initialize(HostProvider.get().extensionFsPath)
// Set the distinct ID for logging and telemetry
await initializeDistinctId(context)
try {
await StateManager.initialize(context)
} catch (error) {
@@ -68,8 +64,6 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
// =============== Webview services ===============
const webview = HostProvider.get().createWebviewProvider()
// Initialize banner service (TEMPORARILY DISABLED - not fetching banners to prevent API hammering)
BannerService.initialize(webview.controller)
const stateManager = StateManager.get()
// Non-blocking announcement check and display
+2 -4
View File
@@ -198,9 +198,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", "moonshotai/kimi-k2.5", "minimax/minimax-m2.1"].includes(
modelId,
)
const isFreeModel = ["kwaipilot/kat-coder-pro", "minimax/minimax-m2.5"].includes(modelId)
if (isFreeModel) {
totalCost = 0
@@ -254,7 +252,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", "moonshotai/kimi-k2.5"].includes(modelId)
const isFreeModel = ["kwaipilot/kat-coder-pro", "minimax/minimax-m2.5"].includes(modelId)
if (isFreeModel) {
totalCost = 0
+2 -5
View File
@@ -307,12 +307,9 @@ export class OcaHandler implements ApiHandler {
async *createMessageResponsesApi(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const inputMessages = convertToOpenAIResponsesInput(messages).input
// Convert messages to Responses API input format
const input: OpenAI.Responses.ResponseInputItem[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAIResponsesInput(messages),
]
const input: OpenAI.Responses.ResponseInputItem[] = [{ role: "system", content: systemPrompt }, ...inputMessages]
// Convert ChatCompletion tools to Responses API format if provided
const responseTools = tools
+1 -1
View File
@@ -103,7 +103,7 @@ export class OpenAiCodexHandler implements ApiHandler {
}
// Format conversation for Responses API
const formattedInput = convertToOpenAIResponsesInput(messages)
const formattedInput = convertToOpenAIResponsesInput(messages).input
// Build request body
const requestBody = this.buildRequestBody(model, formattedInput, systemPrompt, tools)
+33 -17
View File
@@ -26,6 +26,7 @@ interface OpenAiNativeHandlerOptions extends CommonApiHandlerOptions {
reasoningEffort?: string
thinkingBudgetTokens?: number
apiModelId?: string
store?: boolean
}
export class OpenAiNativeHandler implements ApiHandler {
@@ -45,8 +46,8 @@ export class OpenAiNativeHandler implements ApiHandler {
this.client = createOpenAIClient({
apiKey: this.options.openAiNativeApiKey,
})
} catch (error: any) {
throw new Error(`Error creating OpenAI client: ${error.message}`)
} catch (error) {
throw new Error(`Error creating OpenAI client: ${error instanceof Error ? error.message : String(error)}`)
}
}
return this.client
@@ -155,7 +156,7 @@ export class OpenAiNativeHandler implements ApiHandler {
const model = this.getModel()
// Convert messages to Responses API input format
const input = convertToOpenAIResponsesInput(messages)
const { input, previousResponseId } = convertToOpenAIResponsesInput(messages)
// Convert ChatCompletion tools to Responses API format if provided
const responseTools = tools
@@ -168,10 +169,7 @@ export class OpenAiNativeHandler implements ApiHandler {
strict: tool.function.strict ?? true, // Responses API defaults to strict mode
}))
Logger.debug("OpenAI Responses Input: " + JSON.stringify(input))
// const lastAssistantMessage = [...messages].reverse().find((msg) => msg.role === "assistant" && msg.id)
// const previous_response_id = lastAssistantMessage?.id
Logger.debug(`OpenAI Responses Input: ${JSON.stringify(input)}`)
// Create the response using Responses API
const requestedEffort = normalizeOpenaiReasoningEffort(this.options.reasoningEffort)
@@ -189,20 +187,23 @@ export class OpenAiNativeHandler implements ApiHandler {
input,
stream: true,
tools: responseTools,
// previous_response_id,
// store: true,
store: this.options.store ?? false,
...(previousResponseId ? { previous_response_id: previousResponseId } : {}),
...(reasoning ? { reasoning } : {}),
// include: ["reasoning.encrypted_content"],
})
const functionCallByItemId = new Map<string, { call_id?: string; name?: string; id?: string }>()
// Process the response stream
for await (const chunk of stream) {
Logger.debug("OpenAI Responses Chunk: " + JSON.stringify(chunk))
Logger.debug(`OpenAI Responses Chunk: ${JSON.stringify(chunk)}`)
// Handle different event types from Responses API
if (chunk.type === "response.output_item.added") {
const item = chunk.item
if (item.type === "function_call" && item.id) {
functionCallByItemId.set(item.id, { call_id: item.call_id, name: item.name, id: item.id })
yield {
type: "tool_calls",
id: item.id,
@@ -228,6 +229,9 @@ export class OpenAiNativeHandler implements ApiHandler {
if (chunk.type === "response.output_item.done") {
const item = chunk.item
if (item.type === "function_call") {
if (item.id) {
functionCallByItemId.set(item.id, { call_id: item.call_id, name: item.name, id: item.id })
}
yield {
type: "tool_calls",
id: item.id || item.call_id,
@@ -293,12 +297,18 @@ export class OpenAiNativeHandler implements ApiHandler {
}
}
if (chunk.type === "response.function_call_arguments.delta") {
const pendingCall = functionCallByItemId.get(chunk.item_id)
const callId = pendingCall?.call_id
const functionName = pendingCall?.name
const functionId = pendingCall?.id || chunk.item_id
yield {
type: "tool_calls",
tool_call: {
call_id: callId,
function: {
id: chunk.item_id,
name: chunk.item_id,
id: functionId,
name: functionName,
arguments: chunk.delta,
},
},
@@ -307,11 +317,16 @@ export class OpenAiNativeHandler implements ApiHandler {
if (chunk.type === "response.function_call_arguments.done") {
// Handle completed function call
if (chunk.item_id && chunk.name && chunk.arguments) {
const pendingCall = functionCallByItemId.get(chunk.item_id)
const callId = pendingCall?.call_id
const functionId = pendingCall?.id || chunk.item_id
yield {
type: "tool_calls",
tool_call: {
call_id: callId,
function: {
id: chunk.item_id,
id: functionId,
name: chunk.name,
arguments: chunk.arguments,
},
@@ -325,7 +340,6 @@ export class OpenAiNativeHandler implements ApiHandler {
chunk.response?.status === "incomplete" &&
chunk.response?.incomplete_details?.reason === "max_output_tokens"
) {
Logger.log("Ran out of tokens")
if (chunk.response?.output_text?.length > 0) {
Logger.log("Partial output:", chunk.response.output_text)
} else {
@@ -338,11 +352,12 @@ export class OpenAiNativeHandler implements ApiHandler {
const usage = chunk.response.usage
const inputTokens = usage.input_tokens || 0
const outputTokens = usage.output_tokens || 0
const cacheReadTokens = usage.output_tokens_details?.reasoning_tokens || 0
const cacheWriteTokens = usage.input_tokens_details?.cached_tokens || 0
const cacheReadTokens = usage.input_tokens_details?.cached_tokens || 0
const cacheWriteTokens = 0
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens || 0
const totalTokens = usage.total_tokens || 0
Logger.log(`Total tokens from Responses API usage: ${totalTokens}`)
const totalCost = calculateApiCostOpenAI(model.info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
const totalCost = calculateApiCostOpenAI(model.info, inputTokens, outputTokens + reasoningTokens, cacheWriteTokens, cacheReadTokens)
const nonCachedInputTokens = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens)
yield {
type: "usage",
@@ -350,6 +365,7 @@ export class OpenAiNativeHandler implements ApiHandler {
outputTokens: outputTokens,
cacheWriteTokens: cacheWriteTokens,
cacheReadTokens: cacheReadTokens,
thoughtsTokenCount: reasoningTokens,
totalCost: totalCost,
id: chunk.response.id,
}
@@ -71,7 +71,23 @@ import { ClineStorageMessage } from "@/shared/messages/content"
* @param messages - Array of ClineStorageMessage objects to be converted
* @returns ResponseInput array containing the transformed messages with proper reasoning pairing
*/
export function convertToOpenAIResponsesInput(messages: ClineStorageMessage[]): ResponseInput {
export function convertToOpenAIResponsesInput(_messages: ClineStorageMessage[]): {
input: ResponseInput
previousResponseId?: string
} {
// Chain from the latest stored Responses API assistant message when available.
// When chaining, only send new items after that assistant turn.
let previousResponseId: string | undefined
let messages = _messages
for (let i = _messages.length - 1; i >= 0; i--) {
const msg = _messages[i]
if (msg.role === "assistant" && msg.id) {
previousResponseId = msg.id
messages = _messages.slice(i + 1)
break
}
}
const allItems: any[] = []
const toolUseIdToCallId = new Map<string, string>()
@@ -221,5 +237,5 @@ export function convertToOpenAIResponsesInput(messages: ClineStorageMessage[]):
}
}
return allItems
return { input: allItems, previousResponseId }
}
@@ -684,6 +684,39 @@ export class ContextManager {
return needToTruncate
}
/**
* Public helper that attempts file read optimization in memory without persisting context history.
*/
public attemptFileReadOptimizationInMemory(
apiConversationHistory: Anthropic.Messages.MessageParam[],
conversationHistoryDeletedRange: [number, number] | undefined,
timestamp: number,
): {
anyContextUpdates: boolean
needToTruncate: boolean
optimizedConversationHistory: Anthropic.Messages.MessageParam[]
} {
const { anyContextUpdates, needToTruncate } = this.attemptFileReadOptimizationCore(
apiConversationHistory,
conversationHistoryDeletedRange,
timestamp,
)
if (!anyContextUpdates) {
return {
anyContextUpdates: false,
needToTruncate: true,
optimizedConversationHistory: apiConversationHistory,
}
}
return {
anyContextUpdates: true,
needToTruncate,
optimizedConversationHistory: this.getTruncatedMessages(apiConversationHistory, conversationHistoryDeletedRange),
}
}
/**
* Public function for triggering potentially setting the truncation message
* If the truncation message already exists, does nothing, otherwise adds the message
+1 -11
View File
@@ -35,7 +35,6 @@ import { BannerService } from "@/services/banner/BannerService"
import { featureFlagsService } from "@/services/feature-flags"
import { getDistinctId } from "@/services/logging/distinctId"
import { telemetryService } from "@/services/telemetry"
import { BannerCardData } from "@/shared/cline/banner"
import { getAxiosSettings } from "@/shared/net"
import { ShowMessageType } from "@/shared/proto/host/window"
import { Logger } from "@/shared/services/Logger"
@@ -913,7 +912,7 @@ export class Controller {
const version = ExtensionRegistryInfo.version
const clineConfig = ClineEnv.config()
const environment = clineConfig.environment
const banners = await this.getBanners()
const banners = BannerService.get()?.getActiveBanners() ?? []
// Check OpenAI Codex authentication status
const { openAiCodexOAuthManager } = await import("@/integrations/openai-codex/oauth")
@@ -1049,13 +1048,4 @@ export class Controller {
this.stateManager.setGlobalState("taskHistory", history)
return history
}
async getBanners(): Promise<BannerCardData[]> {
try {
return BannerService.get().getActiveBanners()
} catch (err) {
Logger.log(err)
return []
}
}
}
@@ -25,7 +25,7 @@ import { Controller } from ".."
export async function refreshOcaModels(controller: Controller, request: StringRequest): Promise<OcaCompatibleModelInfo> {
const parsePrice = (price: any) => {
if (price) {
return parseFloat(price) * 1_000_000
return Number.parseFloat(price) * 1_000_000
}
return undefined
}
@@ -63,10 +63,9 @@ export async function refreshOcaModels(controller: Controller, request: StringRe
}
const modelInfo = model.model_info
const supportedApiList = modelInfo.supported_api_list ?? [CHAT_COMPLETIONS_API]
const apiFormat: ApiFormat =
supportedApiList.includes(RESPONSES_API) && !supportedApiList.includes(CHAT_COMPLETIONS_API)
? ApiFormat.OPENAI_RESPONSES
: ApiFormat.OPENAI_CHAT
const apiFormat: ApiFormat = supportedApiList.includes(RESPONSES_API)
? ApiFormat.OPENAI_RESPONSES
: ApiFormat.OPENAI_CHAT
models[modelId] = OcaModelInfo.create({
maxTokens: model.litellm_params?.max_tokens || -1,
contextWindow: modelInfo.context_window,
+1 -1
View File
@@ -17,7 +17,7 @@ export async function dismissBanner(controller: Controller, request: StringReque
return {}
}
try {
await BannerService.get().dismissBanner(bannerId)
await BannerService.get()?.dismissBanner(bannerId)
await controller.postStateToWebview()
} catch (error) {
Logger.error("Failed to dismiss banner:", error)
@@ -20,7 +20,7 @@ export async function trackBannerEvent(_controller: Controller, request: TrackBa
return {}
}
try {
await BannerService.get().sendBannerEvent(bannerId, eventType)
await BannerService.get()?.sendBannerEvent(bannerId, eventType)
} catch (error) {
Logger.error("Failed to track banner event:", error)
}
@@ -267,7 +267,7 @@ Usage:
</generate_explanation>
## use_subagents
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.
Parameters:
- prompt_1: (required) First subagent prompt.
- prompt_2: (optional) Optional second subagent prompt.
@@ -233,7 +233,7 @@ Usage:
</generate_explanation>
## use_subagents
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.
Parameters:
- prompt_1: (required) First subagent prompt.
- prompt_2: (optional) Optional second subagent prompt.
@@ -241,7 +241,7 @@ Usage:
</generate_explanation>
## use_subagents
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.
Parameters:
- prompt_1: (required) First subagent prompt.
- prompt_2: (optional) Optional second subagent prompt.
@@ -267,7 +267,7 @@ Usage:
</generate_explanation>
## use_subagents
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.
Parameters:
- prompt_1: (required) First subagent prompt.
- prompt_2: (optional) Optional second subagent prompt.
@@ -298,7 +298,7 @@ Usage:
</load_mcp_documentation>
## use_subagents
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.
Parameters:
- prompt_1: (required) First subagent prompt.
- prompt_2: (optional) Optional second subagent prompt.
@@ -264,7 +264,7 @@ Usage:
</load_mcp_documentation>
## use_subagents
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.
Parameters:
- prompt_1: (required) First subagent prompt.
- prompt_2: (optional) Optional second subagent prompt.
@@ -268,7 +268,7 @@ Usage:
</load_mcp_documentation>
## use_subagents
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.
Parameters:
- prompt_1: (required) First subagent prompt.
- prompt_2: (optional) Optional second subagent prompt.
@@ -298,7 +298,7 @@ Usage:
</load_mcp_documentation>
## use_subagents
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.
Parameters:
- prompt_1: (required) First subagent prompt.
- prompt_2: (optional) Optional second subagent prompt.
@@ -470,7 +470,7 @@
"type": "function",
"function": {
"name": "use_subagents",
"description": "Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.",
"description": "Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.",
"strict": false,
"parameters": {
"type": "object",
@@ -267,7 +267,7 @@ Usage:
</generate_explanation>
## use_subagents
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.
Parameters:
- prompt_1: (required) First subagent prompt.
- prompt_2: (optional) Optional second subagent prompt.
@@ -233,7 +233,7 @@ Usage:
</generate_explanation>
## use_subagents
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.
Parameters:
- prompt_1: (required) First subagent prompt.
- prompt_2: (optional) Optional second subagent prompt.
@@ -241,7 +241,7 @@ Usage:
</generate_explanation>
## use_subagents
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.
Parameters:
- prompt_1: (required) First subagent prompt.
- prompt_2: (optional) Optional second subagent prompt.
@@ -267,7 +267,7 @@ Usage:
</generate_explanation>
## use_subagents
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.
Parameters:
- prompt_1: (required) First subagent prompt.
- prompt_2: (optional) Optional second subagent prompt.
@@ -267,7 +267,7 @@ Usage:
</generate_explanation>
## use_subagents
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.
Parameters:
- prompt_1: (required) First subagent prompt.
- prompt_2: (optional) Optional second subagent prompt.
@@ -233,7 +233,7 @@ Usage:
</generate_explanation>
## use_subagents
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.
Parameters:
- prompt_1: (required) First subagent prompt.
- prompt_2: (optional) Optional second subagent prompt.
@@ -241,7 +241,7 @@ Usage:
</generate_explanation>
## use_subagents
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.
Parameters:
- prompt_1: (required) First subagent prompt.
- prompt_2: (optional) Optional second subagent prompt.
@@ -267,7 +267,7 @@ Usage:
</generate_explanation>
## use_subagents
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.
Parameters:
- prompt_1: (required) First subagent prompt.
- prompt_2: (optional) Optional second subagent prompt.
@@ -421,7 +421,7 @@
"type": "function",
"function": {
"name": "use_subagents",
"description": "Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.",
"description": "Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.",
"strict": false,
"parameters": {
"type": "object",
@@ -372,7 +372,7 @@
"type": "function",
"function": {
"name": "use_subagents",
"description": "Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.",
"description": "Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.",
"strict": false,
"parameters": {
"type": "object",
@@ -276,7 +276,7 @@ Usage:
</generate_explanation>
## use_subagents
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.
Parameters:
- prompt_1: (required) First subagent prompt.
- prompt_2: (optional) Optional second subagent prompt.
@@ -242,7 +242,7 @@ Usage:
</generate_explanation>
## use_subagents
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.
Parameters:
- prompt_1: (required) First subagent prompt.
- prompt_2: (optional) Optional second subagent prompt.
@@ -250,7 +250,7 @@ Usage:
</generate_explanation>
## use_subagents
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.
Parameters:
- prompt_1: (required) First subagent prompt.
- prompt_2: (optional) Optional second subagent prompt.
@@ -276,7 +276,7 @@ Usage:
</generate_explanation>
## use_subagents
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.
Description: Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.
Parameters:
- prompt_1: (required) First subagent prompt.
- prompt_2: (optional) Optional second subagent prompt.
@@ -354,7 +354,7 @@
},
{
"name": "use_subagents",
"description": "Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.",
"description": "Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.",
"parameters": {
"type": "OBJECT",
"properties": {
@@ -9,7 +9,7 @@ const generic: ClineToolSpec = {
id,
name: "use_subagents",
description:
"Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window.",
"Run up to five focused in-process subagents in parallel. Each subagent gets its own prompt and returns a comprehensive research result with tool and token stats. Use this for broad exploration when reading many files would consume the main agent's context window. You do not need to launch multiple subagents every time; using one subagent is valid when it avoids unnecessary context usage for light discovery work.",
contextRequirements: (context) => context.subagentsEnabled === true && !context.isSubagentRun,
parameters: [
{
+3 -2
View File
@@ -17,6 +17,7 @@ import {
} from "@shared/storage/state-keys"
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 {
@@ -124,6 +125,7 @@ export class StateManager {
}
try {
await initializeDistinctId(context)
// Load all extension state from disk
const globalState = await readGlobalStateFromDisk(context)
const secrets = await readSecretsFromDisk()
@@ -820,9 +822,8 @@ export class StateManager {
const value = this.secretsCache[key]
if (value) {
return this.context.secrets.store(key, value)
} else {
return this.context.secrets.delete(key)
}
return this.context.secrets.delete(key)
}),
)
} catch (error) {
+10
View File
@@ -54,6 +54,15 @@ import { createUIHelpers } from "./tools/types/UIHelpers"
import { ToolDisplayUtils } from "./tools/utils/ToolDisplayUtils"
import { ToolResultUtils } from "./tools/utils/ToolResultUtils"
export function canonicalizeAttemptCompletionParams(block: ToolUse): boolean {
if (block.name === ClineDefaultTool.ATTEMPT && !block.params?.result && typeof block.params?.response === "string") {
block.params.result = block.params.response
return true
}
return false
}
export class ToolExecutor {
private autoApprover: AutoApprove
private coordinator: ToolExecutorCoordinator
@@ -360,6 +369,7 @@ export class ToolExecutor {
if (!this.coordinator.has(block.name)) {
return false // Tool not handled by coordinator
}
canonicalizeAttemptCompletionParams(block)
const config = this.asToolConfig()
@@ -0,0 +1,58 @@
import { strict as assert } from "node:assert"
import { ClineDefaultTool } from "@shared/tools"
import { describe, it } from "mocha"
import type { ToolUse } from "../../assistant-message"
import { canonicalizeAttemptCompletionParams } from "../ToolExecutor"
describe("ToolExecutor canonicalization", () => {
it("canonicalizes attempt_completion response into result", () => {
const block: ToolUse = {
type: "tool_use",
name: ClineDefaultTool.ATTEMPT,
params: {
response: "final answer from response field",
task_progress: "- [x] done",
},
partial: false,
}
const didCanonicalize = canonicalizeAttemptCompletionParams(block)
assert.equal(didCanonicalize, true)
assert.equal(block.params.result, "final answer from response field")
assert.equal(block.params.response, "final answer from response field")
})
it("does not canonicalize when attempt_completion already has result", () => {
const block: ToolUse = {
type: "tool_use",
name: ClineDefaultTool.ATTEMPT,
params: {
result: "already canonical",
response: "extra text",
},
partial: false,
}
const didCanonicalize = canonicalizeAttemptCompletionParams(block)
assert.equal(didCanonicalize, false)
assert.equal(block.params.result, "already canonical")
})
it("does not canonicalize non-attempt tools", () => {
const block: ToolUse = {
type: "tool_use",
name: ClineDefaultTool.ACT_MODE,
params: {
response: "act mode response",
},
partial: false,
}
const didCanonicalize = canonicalizeAttemptCompletionParams(block)
assert.equal(didCanonicalize, false)
assert.equal(block.params.result, undefined)
})
})
+160 -6
View File
@@ -1,6 +1,9 @@
import { setTimeout as delay } from "node:timers/promises"
import { buildApiHandler } from "@core/api"
import { parseAssistantMessageV2, ToolUse } from "@core/assistant-message"
import { ContextManager } from "@core/context/context-management/ContextManager"
import { checkContextWindowExceededError } from "@core/context/context-management/context-error-handling"
import { getContextWindowInfo } from "@core/context/context-management/context-window-utils"
import { discoverSkills, getAvailableSkills } from "@core/context/instructions/user-instructions/skills"
import { formatResponse } from "@core/prompts/responses"
import { PromptRegistry } from "@core/prompts/system-prompt"
@@ -9,9 +12,12 @@ import type { SystemPromptContext } from "@core/prompts/system-prompt/types"
import { StreamResponseHandler } from "@core/task/StreamResponseHandler"
import { ClineAssistantToolUseBlock, ClineStorageMessage, ClineTextContentBlock } from "@shared/messages"
import { Logger } from "@shared/services/Logger"
import type { ClineTool } from "@shared/tools"
import { ClineDefaultTool } from "@shared/tools"
import { isNextGenModelFamily } from "@utils/model-utils"
import * as path from "path"
import { HostProvider } from "@/hosts/host-provider"
import { ClineError, ClineErrorType } from "@/services/error/ClineError"
import { ApiFormat } from "@/shared/proto/cline/models"
import { calculateApiCostAnthropic } from "@/utils/cost"
import { TaskState } from "../../TaskState"
@@ -27,6 +33,8 @@ const SUBAGENT_ALLOWED_TOOLS: ClineDefaultTool[] = [
ClineDefaultTool.ATTEMPT,
]
const MAX_EMPTY_ASSISTANT_RETRIES = 3
const MAX_INITIAL_STREAM_ATTEMPTS = 3
const INITIAL_STREAM_RETRY_BASE_DELAY_MS = 250
export type SubagentRunStatus = "completed" | "failed"
@@ -68,15 +76,17 @@ interface SubagentToolCall {
}
const SUBAGENT_SYSTEM_SUFFIX = `\n\n# Subagent Execution Mode
You are running as a research subagent. Your job is to thoroughly explore the codebase and gather comprehensive information to answer the question.
Explore broadly, read related files, trace through call chains, and build a complete picture before reporting back.
You are running as a research subagent. Your job is to explore the codebase and gather information to answer the question.
Explore, read related files, trace through call chains, and build a complete picture before reporting back.
You can read files, list directories, search for patterns, list code definitions, and run commands.
Only use execute_command for readonly operations like ls, grep, git log, git diff, gh, etc.
When it makes sense, be clever about chaining commands or in-command scripting in execute_command to quickly get relevant context - and using pipes / filters to help narrow results.
Do not run commands that modify files or system state.
When you have a comprehensive answer, call the attempt_completion tool.
The attempt_completion result field is sent directly to the main agent, so put your full final findings there.
Include file paths and line numbers in that result field.
Also include a section titled "Recommended files for main agent" with a list of the highest-value files the main agent should read next, and a one-line reason for each file.
Unless the subagent prompt explicitly asks for detailed analysis, keep the result concise and focus on the files the main agent should read next.
Include a section titled "Relevant file paths" and list only file paths, one per line.
Do not include line numbers, summaries, or per-file explanations unless explicitly requested.
`
function serializeToolResult(result: unknown): string {
@@ -270,6 +280,7 @@ export class SubagentRunner {
this.abortRequested = false
const state = new TaskState()
let emptyAssistantResponseRetries = 0
let previousRequestTotalTokens: number | undefined
const stats: SubagentRunStats = {
toolCalls: 0,
inputTokens: 0,
@@ -367,6 +378,18 @@ export class SubagentRunner {
]
while (true) {
if (
previousRequestTotalTokens !== undefined &&
this.shouldCompactBeforeNextRequest(previousRequestTotalTokens, api, providerInfo.model.id)
) {
const didCompact = this.compactConversationForContextWindow(conversation)
if (didCompact) {
Logger.warn("[SubagentRunner] Proactively compacted context before next subagent request.")
}
// Prevent repeated compaction attempts off the same token sample.
previousRequestTotalTokens = undefined
}
const streamHandler = new StreamResponseHandler()
const { toolUseHandler } = streamHandler.getHandlers()
let requestInputTokens = 0
@@ -379,7 +402,14 @@ export class SubagentRunner {
let assistantTextSignature: string | undefined
let requestId: string | undefined
const stream = api.createMessage(systemPrompt, conversation, nativeTools)
const stream = this.createMessageWithInitialChunkRetry(
api,
systemPrompt,
conversation,
nativeTools,
providerInfo.providerId,
providerInfo.model.id,
)
for await (const chunk of stream) {
switch (chunk.type) {
@@ -441,6 +471,8 @@ export class SubagentRunner {
requestCacheReadTokens,
)
stats.totalCost += calculatedRequestCost || 0
previousRequestTotalTokens =
requestInputTokens + requestOutputTokens + requestCacheWriteTokens + requestCacheReadTokens
const nativeFinalizedToolCalls = toolUseHandler.getAllFinalizedToolUses().map((toolCall, index) => ({
toolUseId: resolveToolUseId(toolCall, index),
@@ -641,7 +673,129 @@ export class SubagentRunner {
}
}
private buildNativeTools(context: SystemPromptContext) {
private shouldRetryInitialStreamError(error: unknown, providerId: string, modelId: string): boolean {
// Mirror main loop behavior: do not auto-retry auth/balance failures.
const parsedError = ClineError.transform(error, modelId, providerId)
const isAuthError = parsedError.isErrorType(ClineErrorType.Auth)
const isBalanceError = parsedError.isErrorType(ClineErrorType.Balance)
if (isAuthError || isBalanceError) {
return false
}
return true
}
private compactConversationForContextWindow(conversation: ClineStorageMessage[]): boolean {
const contextManager = new ContextManager()
const optimizationResult = this.optimizeConversationForContextWindow(contextManager, conversation)
if (optimizationResult.didOptimize && !optimizationResult.needToTruncate) {
return true
}
const deletedRange = contextManager.getNextTruncationRange(conversation, undefined, "quarter")
if (deletedRange[1] < deletedRange[0]) {
return optimizationResult.didOptimize
}
const truncated = contextManager
.getTruncatedMessages(conversation, deletedRange)
.map((message) => message as ClineStorageMessage)
if (truncated.length >= conversation.length) {
return optimizationResult.didOptimize
}
conversation.splice(0, conversation.length, ...truncated)
return true
}
private optimizeConversationForContextWindow(
contextManager: ContextManager,
conversation: ClineStorageMessage[],
): {
didOptimize: boolean
needToTruncate: boolean
} {
const timestamp = Date.now()
const optimizationResult = contextManager.attemptFileReadOptimizationInMemory(conversation, undefined, timestamp)
if (!optimizationResult.anyContextUpdates) {
return { didOptimize: false, needToTruncate: true }
}
const optimizedConversation = optimizationResult.optimizedConversationHistory.map(
(message) => message as ClineStorageMessage,
)
conversation.splice(0, conversation.length, ...optimizedConversation)
return { didOptimize: true, needToTruncate: optimizationResult.needToTruncate }
}
private shouldCompactBeforeNextRequest(
previousRequestTotalTokens: number,
api: ReturnType<typeof buildApiHandler>,
modelId: string,
): boolean {
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 roundedThreshold = autoCondenseThreshold ? Math.floor(contextWindow * autoCondenseThreshold) : maxAllowedSize
const thresholdTokens = Math.min(roundedThreshold, maxAllowedSize)
return previousRequestTotalTokens >= thresholdTokens
}
return previousRequestTotalTokens >= maxAllowedSize
}
private async *createMessageWithInitialChunkRetry(
api: ReturnType<typeof buildApiHandler>,
systemPrompt: string,
conversation: ClineStorageMessage[],
nativeTools: ClineTool[] | undefined,
providerId: string,
modelId: string,
) {
for (let attempt = 1; attempt <= MAX_INITIAL_STREAM_ATTEMPTS; attempt += 1) {
const stream = api.createMessage(systemPrompt, conversation, nativeTools)
const iterator = stream[Symbol.asyncIterator]()
try {
const firstChunk = await iterator.next()
if (!firstChunk.done) {
yield firstChunk.value
}
yield* iterator
return
} catch (error) {
if (checkContextWindowExceededError(error)) {
const didCompact = this.compactConversationForContextWindow(conversation)
if (!didCompact || this.shouldAbort() || attempt >= MAX_INITIAL_STREAM_ATTEMPTS) {
throw error
}
Logger.warn(
`[SubagentRunner] Context window exceeded on initial stream attempt ${attempt}; compacted conversation and retrying.`,
)
continue
}
const shouldRetry =
!this.shouldAbort() &&
attempt < MAX_INITIAL_STREAM_ATTEMPTS &&
this.shouldRetryInitialStreamError(error, providerId, modelId)
if (!shouldRetry) {
throw error
}
const delayMs = INITIAL_STREAM_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1)
Logger.warn(`[SubagentRunner] Initial stream failed. Retrying attempt ${attempt + 1}.`, error)
await delay(delayMs)
}
}
}
private buildNativeTools(context: SystemPromptContext): ClineTool[] {
const family = PromptRegistry.getInstance().getModelFamily(context)
const toolSets = ClineToolSet.getToolsForVariantWithFallback(family, SUBAGENT_ALLOWED_TOOLS)
const filteredToolSpecs = toolSets
@@ -1,5 +1,6 @@
import { strict as assert } from "node:assert"
import * as coreApi from "@core/api"
import { ContextManager } from "@core/context/context-management/ContextManager"
import * as skills from "@core/context/instructions/user-instructions/skills"
import { PromptRegistry } from "@core/prompts/system-prompt"
import type { TaskConfig } from "@core/task/tools/types/TaskConfig"
@@ -34,7 +35,16 @@ function initializeHostProvider() {
)
}
function createTaskConfig(nativeToolCallEnabled: boolean): TaskConfig {
function createTaskConfig(
nativeToolCallEnabled: boolean,
options?: {
useAutoCondense?: boolean
autoCondenseThreshold?: number
},
): TaskConfig {
const useAutoCondense = options?.useAutoCondense ?? false
const autoCondenseThreshold = options?.autoCondenseThreshold ?? 0.75
return {
taskId: "task-1",
ulid: "ulid-1",
@@ -67,6 +77,12 @@ function createTaskConfig(nativeToolCallEnabled: boolean): TaskConfig {
if (key === "customPrompt") {
return undefined
}
if (key === "useAutoCondense") {
return useAutoCondense
}
if (key === "autoCondenseThreshold") {
return autoCondenseThreshold
}
return undefined
},
getGlobalStateKey: (key: string) => (key === "nativeToolCallEnabled" ? nativeToolCallEnabled : undefined),
@@ -333,6 +349,291 @@ describe("SubagentRunner", () => {
assert.equal(createMessage.callCount, 2)
})
it("retries initial stream failures before failing the subagent", async () => {
const createMessage = sinon.stub()
createMessage.onFirstCall().callsFake(async function* () {
yield* []
throw new Error(
'{"code":"stream_initialization_failed","message":"Failed to create stream: failed to generate stream from Vercel: failed to send request"}',
)
})
createMessage.onSecondCall().callsFake(async function* () {
yield {
type: "text",
text: "<attempt_completion><result>done</result></attempt_completion>",
}
})
const promptRegistry = PromptRegistry.getInstance()
sinon.stub(promptRegistry, "get").callsFake(async () => {
promptRegistry.nativeTools = undefined
return "system prompt"
})
sinon.stub(coreApi, "buildApiHandler").returns({
abort: sinon.stub(),
getModel: () => ({
id: "anthropic/claude-sonnet-4.5",
info: {
contextWindow: 200_000,
apiFormat: ApiFormat.ANTHROPIC_CHAT,
supportsPromptCache: true,
},
}),
createMessage,
})
sinon.stub(skills, "discoverSkills").resolves([])
sinon.stub(skills, "getAvailableSkills").returns([])
initializeHostProvider()
const config = createTaskConfig(false)
const runner = new SubagentRunner(config)
const result = await runner.run("List files", () => {})
assert.equal(result.status, "completed")
assert.equal(result.result, "done")
assert.equal(createMessage.callCount, 2)
})
it("compacts context and retries when initial stream fails with context window exceeded", async () => {
const createMessage = sinon.stub()
let compactedConversation: unknown[] | undefined
let preCompactionLength = 0
createMessage.onCall(0).callsFake(async function* () {
yield {
type: "tool_calls",
tool_call: {
function: {
id: "toolu_subagent_ctx_1",
name: ClineDefaultTool.LIST_FILES,
arguments: JSON.stringify({ path: ".", recursive: false }),
},
},
}
})
createMessage.onCall(1).callsFake(async function* () {
yield {
type: "tool_calls",
tool_call: {
function: {
id: "toolu_subagent_ctx_2",
name: ClineDefaultTool.LIST_FILES,
arguments: JSON.stringify({ path: ".", recursive: false }),
},
},
}
})
createMessage.onCall(2).callsFake(async function* (_systemPrompt: string, conversation: unknown[]) {
preCompactionLength = conversation.length
yield* []
const contextError = new Error("context length exceeded")
;(contextError as Error & { status: number }).status = 400
throw contextError
})
createMessage.onCall(3).callsFake(async function* (_systemPrompt: string, conversation: unknown[]) {
compactedConversation = conversation
yield {
type: "tool_calls",
tool_call: {
function: {
id: "toolu_subagent_ctx_complete",
name: ClineDefaultTool.ATTEMPT,
arguments: JSON.stringify({ result: "done" }),
},
},
}
})
const promptRegistry = PromptRegistry.getInstance()
sinon.stub(promptRegistry, "get").callsFake(async () => {
promptRegistry.nativeTools = [{ name: "list_files" } as any]
return "system prompt"
})
sinon.stub(coreApi, "buildApiHandler").returns({
abort: sinon.stub(),
getModel: () => ({
id: "anthropic/claude-sonnet-4.5",
info: {
contextWindow: 200_000,
apiFormat: ApiFormat.ANTHROPIC_CHAT,
supportsPromptCache: true,
},
}),
createMessage,
})
sinon.stub(skills, "discoverSkills").resolves([])
sinon.stub(skills, "getAvailableSkills").returns([])
initializeHostProvider()
const config = createTaskConfig(true)
const runner = new SubagentRunner(config)
sinon
.stub(runner as unknown as { buildNativeTools: () => unknown[] }, "buildNativeTools")
.returns([{ name: "list_files" }])
const result = await runner.run("List files", () => {})
assert.equal(result.status, "completed")
assert.equal(result.result, "done")
assert.equal(createMessage.callCount, 4)
assert.ok(compactedConversation)
assert.ok(compactedConversation.length < preCompactionLength)
})
it("fails context window errors when there is no compactable subagent context", async () => {
const createMessage = sinon.stub()
createMessage.onFirstCall().callsFake(async function* () {
yield* []
const contextError = new Error("context length exceeded")
;(contextError as Error & { status: number }).status = 400
throw contextError
})
const promptRegistry = PromptRegistry.getInstance()
sinon.stub(promptRegistry, "get").callsFake(async () => {
promptRegistry.nativeTools = undefined
return "system prompt"
})
sinon.stub(coreApi, "buildApiHandler").returns({
abort: sinon.stub(),
getModel: () => ({
id: "anthropic/claude-sonnet-4.5",
info: {
contextWindow: 200_000,
apiFormat: ApiFormat.ANTHROPIC_CHAT,
supportsPromptCache: true,
},
}),
createMessage,
})
sinon.stub(skills, "discoverSkills").resolves([])
sinon.stub(skills, "getAvailableSkills").returns([])
initializeHostProvider()
const config = createTaskConfig(false)
const runner = new SubagentRunner(config)
const result = await runner.run("Huge prompt", () => {})
assert.equal(result.status, "failed")
assert.equal(createMessage.callCount, 1)
})
it("proactively compacts before next request when prior usage exceeds threshold", async () => {
const createMessage = sinon.stub()
let postCompactionConversationLength = 0
createMessage.onCall(0).callsFake(async function* () {
yield {
type: "usage",
inputTokens: 160_000,
outputTokens: 0,
cacheWriteTokens: 0,
cacheReadTokens: 0,
}
yield {
type: "tool_calls",
tool_call: {
function: {
id: "toolu_subagent_threshold_1",
name: ClineDefaultTool.LIST_FILES,
arguments: JSON.stringify({ path: ".", recursive: false }),
},
},
}
})
createMessage.onCall(1).callsFake(async function* () {
yield {
type: "usage",
inputTokens: 160_000,
outputTokens: 0,
cacheWriteTokens: 0,
cacheReadTokens: 0,
}
yield {
type: "tool_calls",
tool_call: {
function: {
id: "toolu_subagent_threshold_2",
name: ClineDefaultTool.LIST_FILES,
arguments: JSON.stringify({ path: ".", recursive: false }),
},
},
}
})
createMessage.onCall(2).callsFake(async function* (_systemPrompt: string, conversation: unknown[]) {
postCompactionConversationLength = conversation.length
yield {
type: "tool_calls",
tool_call: {
function: {
id: "toolu_subagent_threshold_complete",
name: ClineDefaultTool.ATTEMPT,
arguments: JSON.stringify({ result: "done" }),
},
},
}
})
const promptRegistry = PromptRegistry.getInstance()
sinon.stub(promptRegistry, "get").callsFake(async () => {
promptRegistry.nativeTools = undefined
return "system prompt"
})
sinon.stub(coreApi, "buildApiHandler").returns({
abort: sinon.stub(),
getModel: () => ({
id: "anthropic/claude-sonnet-4.5",
info: {
contextWindow: 200_000,
apiFormat: ApiFormat.ANTHROPIC_CHAT,
supportsPromptCache: true,
},
}),
createMessage,
})
sinon.stub(skills, "discoverSkills").resolves([])
sinon.stub(skills, "getAvailableSkills").returns([])
initializeHostProvider()
const config = createTaskConfig(false, { useAutoCondense: true, autoCondenseThreshold: 0.75 })
const runner = new SubagentRunner(config)
const result = await runner.run("List files", () => {})
assert.equal(result.status, "completed")
assert.equal(result.result, "done")
assert.equal(createMessage.callCount, 3)
assert.equal(postCompactionConversationLength, 3)
})
it("skips truncation when file-read optimization is sufficient", () => {
const config = createTaskConfig(false)
const runner = new SubagentRunner(config)
const conversation = [{ role: "user", content: [{ type: "text", text: "hello" }] }] as any[]
const optimizeStub = sinon
.stub(
runner as unknown as {
optimizeConversationForContextWindow: () => { didOptimize: boolean; needToTruncate: boolean }
},
"optimizeConversationForContextWindow",
)
.returns({ didOptimize: true, needToTruncate: false })
const getNextTruncationRangeSpy = sinon.spy(ContextManager.prototype, "getNextTruncationRange")
const didCompact = (
runner as unknown as { compactConversationForContextWindow: (value: unknown[]) => boolean }
).compactConversationForContextWindow(conversation)
assert.equal(didCompact, true)
assert.equal(optimizeStub.calledOnce, true)
assert.equal(getNextTruncationRangeSpy.called, false)
})
it("falls back to non-native mode when native settings are enabled but variant has no native tools", async () => {
const createMessage = sinon.stub()
createMessage.onFirstCall().callsFake(async function* () {
+45
View File
@@ -1,4 +1,5 @@
import { name, publisher, version } from "../package.json"
import { HostProvider } from "./hosts/host-provider"
const prefix = name === "claude-dev" ? "cline" : name
@@ -51,3 +52,47 @@ export const ExtensionRegistryInfo = {
commands: ClineCommands,
views: ClineViewIds,
}
export interface HostInfo {
/**
* The name of the host platform, e.g VSCode, IntelliJ Ultimate Edition, etc.
*/
platform: string
/**
* The operating system platform, e.g. linux, darwin, win32
*/
os: string
/**
* The type of the cline host environment, e.g. 'VSCode Extension', 'Cline for JetBrains', 'CLI'
* This is different from the platform because there are many JetBrains IDEs, but they all use the same
* plugin.
*/
ide: string
/**
* A distinct ID for this installation of the host client
*/
distinctId: string
/**
* The version of the host platform, e.g. 1.103.0 for VSCode, or 2025.1.1.1 for JetBrains IDEs.
*/
hostVersion?: string
/**
* The version of Cline that the host client is running
*/
extensionVersion: string
}
let hostInfo = null as HostInfo | null
export const HostRegistryInfo = {
init: async (distinctId: string) => {
const host = await HostProvider.env.getHostVersion({})
const hostVersion = host.version
const extensionVersion = host.clineVersion || ExtensionRegistryInfo.version
const platform = host.platform || "unknown"
const os = process.platform || "unknown"
const ide = host.clineType || "unknown"
hostInfo = { hostVersion, extensionVersion, platform, os, ide, distinctId }
},
get: () => hostInfo,
}
+9
View File
@@ -8,6 +8,7 @@ import { HostProvider } from "@/hosts/host-provider"
import { telemetryService } from "@/services/telemetry"
import { Logger } from "@/shared/services/Logger"
import { openExternal } from "@/utils/env"
import { BannerService } from "../banner/BannerService"
import { AuthInvalidTokenError, AuthNetworkError } from "../error/ClineError"
import { featureFlagsService } from "../feature-flags"
import { ClineAuthProvider } from "./providers/ClineAuthProvider"
@@ -99,6 +100,8 @@ export class AuthService {
} else {
AuthService.instance = new AuthService(controller)
}
// Initialize BannerService after AuthService is created
BannerService.initialize()
}
if (controller !== undefined && AuthService.instance) {
AuthService.instance.controller = controller
@@ -403,6 +406,7 @@ export class AuthService {
})
await Promise.all(streamSends)
// Identify the user in telemetry if available
if (this._clineAuthInfo?.userInfo?.id) {
telemetryService.identifyAccount(this._clineAuthInfo.userInfo)
@@ -413,6 +417,11 @@ export class AuthService {
await featureFlagsService.poll(null)
}
// Update banners based on new auth token
BannerService.onAuthUpdate(this._clineAuthInfo?.userInfo?.id || null).catch((error) => {
Logger.error("[AuthService] Banner update failed", error)
})
// Update state in webviews once per unique controller
await Promise.all(Array.from(uniqueControllers).map((c) => c.postStateToWebview()))
}
+3
View File
@@ -6,6 +6,7 @@ import { WebviewProvider } from "@/core/webview"
import { CLINE_API_ENDPOINT } from "@/shared/cline/api"
import { fetch } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import { BannerService } from "../banner/BannerService"
import { buildBasicClineHeaders } from "../EnvUtils"
import { AuthService } from "./AuthService"
@@ -34,6 +35,8 @@ export class AuthServiceMock extends AuthService {
if (controller !== undefined) {
AuthServiceMock.instance.controller = controller
}
// Initialize BannerService after AuthService is created
BannerService.initialize()
return AuthServiceMock.instance
}
-755
View File
@@ -1,755 +0,0 @@
/**
* Tests for BannerService
* Tests API fetching, caching, and client-side provider filtering
*
* NOTE: Tests temporarily disabled while banner API fetching is disabled
* to prevent blocking the extension. Tests will be re-enabled when API is stable.
*/
import type { BannerRules } from "@shared/ClineBanner"
import axios from "axios"
import { expect } from "chai"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as sinon from "sinon"
import type { Controller } from "@/core/controller"
import { Logger } from "@/shared/services/Logger"
import { BannerService } from "./BannerService"
describe.skip("BannerService (TEMPORARILY DISABLED - Banner API fetch disabled)", () => {
let sandbox: sinon.SinonSandbox
let bannerService: BannerService
let axiosGetStub: sinon.SinonStub
let mockController: Partial<Controller>
beforeEach(() => {
sandbox = sinon.createSandbox()
sandbox.stub(Logger, "log")
sandbox.stub(Logger, "error")
mockController = {
stateManager: {
getApiConfiguration: () => ({}),
getGlobalSettingsKey: () => undefined,
getGlobalStateKey: () => [],
} as any,
}
// Reset singleton and initialize with mock controller
BannerService.reset()
bannerService = BannerService.initialize(mockController as Controller)
bannerService.clearCache()
axiosGetStub = sandbox.stub(axios, "get")
})
afterEach(() => {
bannerService.clearCache()
BannerService.reset()
sandbox.restore()
})
describe("API Fetching", () => {
it("should fetch banners from API successfully", async () => {
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_test1",
titleMd: "Test Banner",
bodyMd: "This is a test",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.getActiveBanners()
expect(axiosGetStub.calledOnce).to.be.true
expect(banners).to.have.lengthOf(1)
expect(banners[0].id).to.equal("bnr_test1")
expect(banners[0].title).to.equal("Test Banner")
expect(banners[0].description).to.equal("This is a test")
})
it("should handle API errors gracefully", async () => {
axiosGetStub.rejects(new Error("Network error"))
const banners = await bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(0)
})
it("should cache banners for 5 minutes", async () => {
const clock = sandbox.useFakeTimers()
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_cached",
titleMd: "Cached Banner",
bodyMd: "Test",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
// First call fetches from API
await bannerService.getActiveBanners()
expect(axiosGetStub.callCount).to.equal(1)
// Second call within cache window uses cache (no new API call)
await bannerService.getActiveBanners()
expect(axiosGetStub.callCount).to.equal(1)
// After 4 minutes, still uses cache
clock.tick(4 * 60 * 1000)
await bannerService.getActiveBanners()
expect(axiosGetStub.callCount).to.equal(1)
// After 6 minutes total, cache expired, makes new API call
clock.tick(2 * 60 * 1000)
await bannerService.getActiveBanners()
expect(axiosGetStub.callCount).to.equal(2)
// Force refresh always bypasses cache
await bannerService.getActiveBanners(true)
expect(axiosGetStub.callCount).to.equal(3)
})
})
describe("API Provider Rule Evaluation (Client-Side)", () => {
it("should show banner when user has selected the required API provider in act mode", async () => {
const controllerWithOpenAI: Partial<Controller> = {
stateManager: {
getApiConfiguration: () => ({
actModeApiProvider: "openai",
}),
getGlobalSettingsKey: (key: string) => (key === "mode" ? "act" : undefined),
getGlobalStateKey: () => [],
} as any,
}
// Reinitialize with new controller
BannerService.reset()
bannerService = BannerService.initialize(controllerWithOpenAI as Controller)
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_openai",
titleMd: "OpenAI Users",
bodyMd: "For OpenAI API",
severity: "info" as const,
placement: "top" as const,
rulesJson: JSON.stringify({ providers: ["openai"] } as BannerRules),
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(1)
expect(banners[0].id).to.equal("bnr_openai")
})
it("should show banner when user has selected the required API provider in plan mode", async () => {
const controllerWithAnthropic: Partial<Controller> = {
stateManager: {
getApiConfiguration: () => ({
planModeApiProvider: "anthropic",
}),
getGlobalSettingsKey: (key: string) => (key === "mode" ? "plan" : undefined),
getGlobalStateKey: () => [],
} as any,
}
// Reinitialize with new controller
BannerService.reset()
bannerService = BannerService.initialize(controllerWithAnthropic as Controller)
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_anthropic",
titleMd: "Anthropic Users",
bodyMd: "For Anthropic API",
severity: "info" as const,
placement: "top" as const,
rulesJson: JSON.stringify({ providers: ["anthropic"] } as BannerRules),
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(1)
expect(banners[0].id).to.equal("bnr_anthropic")
})
it("should NOT show banner when user has selected a different API provider", async () => {
const controllerWithAnthropic: Partial<Controller> = {
stateManager: {
getApiConfiguration: () => ({
actModeApiProvider: "anthropic",
}),
getGlobalSettingsKey: (key: string) => (key === "mode" ? "act" : undefined),
getGlobalStateKey: () => [],
} as any,
}
// Reinitialize with new controller
BannerService.reset()
bannerService = BannerService.initialize(controllerWithAnthropic as Controller)
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_openai",
titleMd: "OpenAI Users",
bodyMd: "For OpenAI API",
severity: "info" as const,
placement: "top" as const,
rulesJson: JSON.stringify({ providers: ["openai"] } as BannerRules),
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(0)
})
it("should show banner if user has selected ANY of multiple specified providers", async () => {
const controllerWithAnthropic: Partial<Controller> = {
stateManager: {
getApiConfiguration: () => ({
actModeApiProvider: "anthropic",
}),
getGlobalSettingsKey: (key: string) => (key === "mode" ? "act" : undefined),
getGlobalStateKey: () => [],
} as any,
}
// Reinitialize with new controller
BannerService.reset()
bannerService = BannerService.initialize(controllerWithAnthropic as Controller)
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_multi",
titleMd: "Multiple Providers",
bodyMd: "For Anthropic or OpenAI users",
severity: "info" as const,
placement: "top" as const,
rulesJson: JSON.stringify({ providers: ["anthropic", "openai"] } as BannerRules),
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(1)
expect(banners[0].id).to.equal("bnr_multi")
})
it("should NOT show banner when no provider is selected", async () => {
const controllerWithNoProvider: Partial<Controller> = {
stateManager: {
getApiConfiguration: () => ({}),
getGlobalSettingsKey: (key: string) => (key === "mode" ? "act" : undefined),
getGlobalStateKey: () => [],
} as any,
}
// Reinitialize with new controller
BannerService.reset()
bannerService = BannerService.initialize(controllerWithNoProvider as Controller)
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_openai",
titleMd: "OpenAI Users",
bodyMd: "For OpenAI API",
severity: "info" as const,
placement: "top" as const,
rulesJson: JSON.stringify({ providers: ["openai"] } as BannerRules),
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(0)
})
})
describe("Invalid or No Banner Rules", () => {
it("should handle malformed rules gracefully (fail open)", async () => {
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_malformed",
titleMd: "Malformed",
bodyMd: "Test",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{ invalid json",
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(1)
})
it("should handle banners with no rules", async () => {
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_norules",
titleMd: "No Rules",
bodyMd: "Test",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(1)
expect(banners[0].id).to.equal("bnr_norules")
})
})
describe("Cache Management", () => {
it("should clear cache when requested", async () => {
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_test",
titleMd: "Test",
bodyMd: "Test",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
await bannerService.getActiveBanners()
expect(axiosGetStub.calledOnce).to.be.true
bannerService.clearCache()
await bannerService.getActiveBanners()
expect(axiosGetStub.calledTwice).to.be.true
})
})
describe("OS Parameter Integration", () => {
it("should send OS parameter in API request", async () => {
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_test",
titleMd: "Test Banner",
bodyMd: "This is a test",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
await bannerService.getActiveBanners()
expect(axiosGetStub.calledOnce).to.be.true
const call = axiosGetStub.getCall(0)
const url = call.args[0]
expect(url).to.include("os=")
})
it("should handle OS detection errors gracefully", async () => {
const originalPlatform = process.platform
Object.defineProperty(process, "platform", {
get: () => {
throw new Error("Platform access denied")
},
configurable: true,
})
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_test",
titleMd: "Test Banner",
bodyMd: "This is a test",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.getActiveBanners()
Object.defineProperty(process, "platform", {
value: originalPlatform,
configurable: true,
})
expect(banners).to.have.lengthOf(1)
expect(axiosGetStub.calledOnce).to.be.true
const call = axiosGetStub.getCall(0)
const url = call.args[0]
expect(url).to.include("os=unknown")
})
it("should detect different OS types correctly", async () => {
const testCases = [
{ platform: "win32", expected: "windows" },
{ platform: "darwin", expected: "macos" },
{ platform: "linux", expected: "linux" },
{ platform: "freebsd", expected: "unknown" },
]
for (const { platform, expected } of testCases) {
const originalPlatform = process.platform
Object.defineProperty(process, "platform", {
value: platform,
configurable: true,
})
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_test",
titleMd: "Test Banner",
bodyMd: "This is a test",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
// Clear cache to ensure fresh API call for each platform test
bannerService.clearCache()
await bannerService.getActiveBanners()
expect(axiosGetStub.called).to.be.true
const call = axiosGetStub.lastCall
expect(call).to.not.be.null
const url = call.args[0]
expect(url).to.include(`os=${expected}`)
Object.defineProperty(process, "platform", {
value: originalPlatform,
configurable: true,
})
axiosGetStub.resetHistory()
}
})
})
describe("Banner to BannerCardData Conversion", () => {
it("should convert banner with valid action types", async () => {
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_valid_actions",
titleMd: "Valid Actions Banner",
bodyMd: "Has valid actions",
icon: "lightbulb",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
actions: [
{ title: "Link", action: "link", arg: "https://example.com" },
{ title: "Settings", action: "show-api-settings" },
],
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(1)
expect(banners[0].id).to.equal("bnr_valid_actions")
expect(banners[0].title).to.equal("Valid Actions Banner")
expect(banners[0].description).to.equal("Has valid actions")
expect(banners[0].icon).to.equal("lightbulb")
expect(banners[0].actions).to.have.lengthOf(2)
expect(banners[0].actions![0].title).to.equal("Link")
expect(banners[0].actions![0].action).to.equal("link")
expect(banners[0].actions![0].arg).to.equal("https://example.com")
expect(banners[0].actions![1].title).to.equal("Settings")
expect(banners[0].actions![1].action).to.equal("show-api-settings")
})
it("should drop banner with invalid action type and log error", async () => {
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_invalid_action",
titleMd: "Invalid Action Banner",
bodyMd: "Has invalid action type",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
actions: [{ title: "Invalid", action: "unknown-action-type", arg: "test" }],
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(0)
})
it("should keep valid banners and drop only invalid ones", async () => {
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_valid",
titleMd: "Valid Banner",
bodyMd: "This one is valid",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
actions: [{ title: "Link", action: "link", arg: "https://example.com" }],
},
{
id: "bnr_invalid",
titleMd: "Invalid Banner",
bodyMd: "This one has invalid action",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
actions: [{ title: "Bad", action: "not-a-real-action" }],
},
{
id: "bnr_also_valid",
titleMd: "Also Valid Banner",
bodyMd: "This one is also valid",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(2)
expect(banners[0].id).to.equal("bnr_valid")
expect(banners[1].id).to.equal("bnr_also_valid")
})
it("should convert banner with no actions", async () => {
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_no_actions",
titleMd: "No Actions Banner",
bodyMd: "Has no actions",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(1)
expect(banners[0].id).to.equal("bnr_no_actions")
expect(banners[0].actions).to.have.lengthOf(0)
})
it("should convert banner with empty actions array", async () => {
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_empty_actions",
titleMd: "Empty Actions Banner",
bodyMd: "Has empty actions array",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
actions: [],
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(1)
expect(banners[0].id).to.equal("bnr_empty_actions")
expect(banners[0].actions).to.have.lengthOf(0)
})
it("should drop banner when action has undefined action type", async () => {
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_undefined_action",
titleMd: "Undefined Action Type",
bodyMd: "Action has no type defined",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
actions: [{ title: "Just a label" }],
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(0)
})
it("should accept all valid BannerActionType values", async () => {
const validActionTypes = [
"link",
"show-api-settings",
"show-feature-settings",
"show-account",
"set-model",
"install-cli",
]
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_all_valid_types",
titleMd: "All Valid Types",
bodyMd: "Has all valid action types",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
actions: validActionTypes.map((type, index) => ({
title: `Action ${index}`,
action: type,
})),
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(1)
expect(banners[0].actions).to.have.lengthOf(validActionTypes.length)
banners[0].actions!.forEach((action, index) => {
expect(action.action).to.equal(validActionTypes[index])
})
})
})
})
+313 -390
View File
@@ -1,467 +1,390 @@
import type { Banner, BannerRules, BannersResponse } from "@shared/ClineBanner"
import { BannerActionType, type BannerCardData } from "@shared/cline/banner"
import axios from "axios"
import { ClineEnv } from "@/config"
import type { Controller } from "@/core/controller"
import { HostProvider } from "@/hosts/host-provider"
import { getAxiosSettings } from "@/shared/net"
import { StateManager } from "@/core/storage/StateManager"
import { HostInfo, HostRegistryInfo } from "@/registry"
import { fetch } from "@/shared/net"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { Logger } from "@/shared/services/Logger"
import { AuthService } from "../auth/AuthService"
import { buildBasicClineHeaders } from "../EnvUtils"
import { getDistinctId } from "../logging/distinctId"
import { featureFlagsService } from "../feature-flags"
const CACHE_DURATION_MS = 24 * 60 * 60 * 1000 // 24 hours
const CIRCUIT_BREAKER_TIMEOUT_MS = 60 * 60 * 1000 // 1 hour
const SERVER_ERROR_BACKOFF_MS = 15 * 60 * 1000 // 15 minutes
const AUTH_DEBOUNCE_MS = 1000 // 1 second
const FETCH_TIMEOUT_MS = 10000 // 10 seconds
const MAX_CONSECUTIVE_FAILURES = 3
const OS_MAP: Record<string, string> = {
win32: "windows",
linux: "linux",
darwin: "macos",
}
const IDE_MAP: Record<string, string> = {
vscode: "vscode",
jetbrains: "jetbrains",
cli: "cli",
}
const PROVIDER_ALIASES: Record<string, string[]> = {
anthropic: ["anthropic", "claude-code"],
openai: ["openai", "openai-native"],
qwen: ["qwen", "qwen-code"],
}
/**
* Service for fetching and evaluating banner messages
*/
export class BannerService {
private static instance: BannerService | null = null
private _cachedBanners: Banner[] = []
private _lastFetchTime: number = 0
private readonly CACHE_DURATION_MS = 5 * 60 * 1000 // 5 minutes
private _controller: Controller
private _authService?: AuthService
private actionTypes: Set<string>
private _fetchPromise: Promise<Banner[]> | null = null
private get _baseUrl(): string {
return ClineEnv.config().apiBaseUrl
private cachedBanners: Banner[] = []
private lastFetchTime = 0
private backoffUntil = 0
private consecutiveFailures = 0
private userId: string | null = null
private fetchPromise: Promise<Banner[]> | null = null
private abortController: AbortController | null = null
private debounceTimer: ReturnType<typeof setTimeout> | null = null
private pendingDebounceResolve: (() => void) | null = null
private authFetchPending = false
private readonly validActionTypes: Set<string>
private constructor(private readonly hostInfo: HostInfo) {
this.validActionTypes = new Set(Object.values(BannerActionType))
Logger.log("[BannerService] initialized")
}
private constructor(controller: Controller) {
this._controller = controller
this.actionTypes = new Set<string>(Object.values(BannerActionType))
}
/**
* Initializes the BannerService singleton with required dependencies
* @param controller The controller instance for accessing state and services
* @returns The initialized BannerService instance
* @throws Error if already initialized
*/
public static initialize(controller: Controller): BannerService {
public static initialize(): BannerService {
if (BannerService.instance) {
throw new Error("BannerService has already been initialized.")
return BannerService.instance
}
BannerService.instance = new BannerService(controller)
const hostInfo = HostRegistryInfo.get()
if (!hostInfo) {
throw new Error("[BannerService] Ensure HostRegistryInfo is initialized before BannerService.")
}
BannerService.instance = new BannerService(hostInfo)
return BannerService.instance
}
/**
* Returns the singleton instance of BannerService
* @throws Error if not initialized
*/
public static get(): BannerService {
if (!BannerService.instance) {
throw new Error("BannerService not initialized. Call BannerService.initialize() first.")
}
return BannerService.instance
public static get(): BannerService | null {
if (!BannerService.instance && !HostRegistryInfo.get()) return null
return BannerService.instance ?? BannerService.initialize()
}
/**
* Checks if BannerService has been initialized
*/
public static isInitialized(): boolean {
return !!BannerService.instance
}
/**
* Resets the BannerService instance (primarily for testing)
*/
public static reset(): void {
const instance = BannerService.instance
if (instance) {
if (instance.debounceTimer) clearTimeout(instance.debounceTimer)
instance.abortController?.abort()
}
BannerService.instance = null
}
/**
* Sets the AuthService instance for testing purposes
* In production, AuthService is loaded dynamically when needed
*/
public setAuthService(authService: AuthService): void {
this._authService = authService
}
public static async onAuthUpdate(userId: string | null): Promise<void> {
const instance = BannerService.instance
/**
* Fetches active banners from the API
* Backend handles all filtering based on ide and user context
* Extension only filters by providers (API provider configuration)
* @param forceRefresh If true, bypasses cache and fetches fresh data
* @returns Array of banners that match current environment
*/
private async internalGetActiveBanners(forceRefresh = false): Promise<Banner[]> {
try {
// Return cached banners if still valid
const now = Date.now()
if (!forceRefresh && this._cachedBanners.length > 0 && now - this._lastFetchTime < this.CACHE_DURATION_MS) {
Logger.log("BannerService: Returning cached banners")
return this._cachedBanners
}
if (!instance || instance.userId === userId) return
if (this._fetchPromise && !forceRefresh) {
return this._fetchPromise
}
this._fetchPromise = this.fetchActiveBanners()
return this._fetchPromise
} catch (error) {
// Log error but don't throw - banner fetching shouldn't break the extension
Logger.error("BannerService: Error getting internal banners", error)
return []
// Clear existing debounce timer and resolve any pending promise
if (instance.debounceTimer) {
clearTimeout(instance.debounceTimer)
instance.debounceTimer = null
}
}
private async fetchActiveBanners(): Promise<Banner[]> {
try {
const now = Date.now()
const ideType = await this.getIdeType()
const extensionVersion = await this.getExtensionVersion()
const osType = await this.getOSType()
const urlObj = new URL("/banners/v1/messages", this._baseUrl)
urlObj.searchParams.set("ide", ideType)
if (extensionVersion) {
urlObj.searchParams.set("extension_version", extensionVersion)
}
urlObj.searchParams.set("os", osType)
const url = urlObj.toString()
Logger.log(`BannerService: Fetching banners from ${url}`)
const authService = this.getAuthServiceInstance()
const token: string | null = (await authService?.getAuthToken()) || null
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(await buildBasicClineHeaders()),
}
if (token) {
headers["Authorization"] = `Bearer ${token}`
}
const response = await axios.get<BannersResponse>(url, {
timeout: 10000,
headers,
...getAxiosSettings(),
})
if (!response.data?.data || !Array.isArray(response.data.data.items)) {
Logger.log("BannerService: Invalid response format - items array is missing or malformed")
return []
}
const backendFilteredBanners = response.data.data.items
Logger.log(`BannerService: Received ${backendFilteredBanners.length} banners from backend (already filtered)`)
// Client-side filtering: Only filter by providers
const matchingBanners = backendFilteredBanners.filter((banner) => this.matchesProviderRule(banner))
Logger.log(`BannerService: ${matchingBanners.length} banners match provider requirements`)
// Update cache
this._cachedBanners = matchingBanners
this._lastFetchTime = now
if (matchingBanners.length > 0) {
Logger.log(`BannerService: ${matchingBanners.length} active banner(s) fetched.`)
}
return matchingBanners
} catch (error) {
// Log error but don't throw - banner fetching shouldn't break the extension
Logger.error("BannerService: Error fetching banners", error)
return []
} finally {
this._fetchPromise = null
if (instance.pendingDebounceResolve) {
instance.pendingDebounceResolve()
instance.pendingDebounceResolve = null
}
}
/**
* Gets the current extension version
* @returns Extension version string (e.g., "3.39.2")
*/
private async getExtensionVersion(): Promise<string> {
try {
const hostVersion = await HostProvider.env.getHostVersion({})
return hostVersion.clineVersion || ""
} catch (error) {
Logger.error("BannerService: Error getting extension version", error)
return ""
}
}
// Cancel any in-progress fetch immediately - we'll fetch with the new token after debounce
instance.abortController?.abort()
instance.abortController = null
instance.fetchPromise = null
/**
* Client-side filtering by providers rule only
* Backend handles all other filtering (ide, employee_only, audience, org_type, version)
* @param banner Banner to check
* @returns true if banner matches provider requirements or has no provider restrictions
*/
private matchesProviderRule(banner: Banner): boolean {
try {
const rules: BannerRules = JSON.parse(banner.rulesJson || "{}")
// Set pending flag immediately to prevent getActiveBanners() from starting a fetch
// while we're waiting for the debounce to settle
instance.authFetchPending = true
instance.userId = userId
if (!rules.providers || rules.providers.length === 0) {
return true
}
return new Promise<void>((resolve) => {
instance.pendingDebounceResolve = resolve
instance.debounceTimer = setTimeout(async () => {
instance.debounceTimer = null
instance.pendingDebounceResolve = null
const apiConfiguration = this._controller.stateManager.getApiConfiguration()
const currentMode = this._controller.stateManager.getGlobalSettingsKey("mode")
const selectedProvider =
currentMode === "plan" ? apiConfiguration?.planModeApiProvider : apiConfiguration?.actModeApiProvider
instance.consecutiveFailures = 0
instance.backoffUntil = 0
if (!selectedProvider) {
Logger.log(`BannerService: Banner ${banner.id} filtered by client - no provider selected for ${currentMode} mode`)
return false
}
const hasMatchingProvider = rules.providers.some((provider) => {
// Normalize provider names for comparison
switch (provider) {
case "anthropic":
case "claude-code":
return selectedProvider === "anthropic"
case "openai":
case "openai-native":
return selectedProvider === "openai" || selectedProvider === "openai-native"
case "qwen":
case "qwen-code":
return selectedProvider === "qwen"
default:
// For any other providers, do a direct string comparison
return selectedProvider === provider
try {
await instance.doFetch()
Logger.info("[BannerService] Fetched")
} finally {
instance.authFetchPending = false
resolve()
}
}, AUTH_DEBOUNCE_MS)
})
}
public getActiveBanners(): BannerCardData[] {
const now = Date.now()
const shouldFetch =
featureFlagsService.getBooleanFlagEnabled(FeatureFlag.REMOTE_BANNERS) &&
now >= this.backoffUntil &&
now - this.lastFetchTime >= CACHE_DURATION_MS &&
!this.fetchPromise &&
!this.authFetchPending
if (shouldFetch) {
Logger.log("[BannerService] Cache expired, fetching new banners")
this.fetchPromise = this.doFetch()
this.fetchPromise.finally(() => {
this.fetchPromise = null
})
if (!hasMatchingProvider) {
Logger.log(
`BannerService: Banner ${banner.id} filtered by client - selected provider '${selectedProvider}' doesn't match any of these required providers: ${rules.providers.join(", ")}`,
)
}
return hasMatchingProvider
} catch (error) {
Logger.log(
`BannerService: Error parsing provider rules for banner ${banner.id}: ${error instanceof Error ? error.message : String(error)}`,
)
return true
}
return this.cachedBanners
.filter((b) => !this.isBannerDismissed(b.id))
.map((b) => this.toBannerCardData(b))
.filter((b): b is BannerCardData => b !== null)
}
/**
* Gets the current Operating System
* @returns OS type (windows, linux, macos or unknown)
*/
private async getOSType(): Promise<string> {
try {
switch (process.platform) {
case "win32":
return "windows"
case "linux":
return "linux"
case "darwin":
return "macos"
default:
return "unknown"
}
} catch (error) {
Logger.error("BannerService: Error getting OS type", error)
return "unknown"
}
}
/**
* Gets the current IDE type
* @returns IDE type (vscode, jetbrains, cli, or unknown)
*/
private async getIdeType(): Promise<string> {
try {
const hostVersion = await HostProvider.env.getHostVersion({})
// Use clineType field which contains values like "VSCode Extension", "Cline for JetBrains", "CLI", etc.
const clineType = hostVersion.clineType?.toLowerCase() || ""
if (clineType.includes("vscode")) {
return "vscode"
}
if (clineType.includes("jetbrains")) {
return "jetbrains"
}
if (clineType.includes("cli")) {
return "cli"
}
return "unknown"
} catch (error) {
Logger.error("BannerService: Error getting IDE type", error)
return "unknown"
}
}
/**
* Gets the AuthService instance
* @returns AuthService instance or undefined if not available
*/
private getAuthServiceInstance(): AuthService | undefined {
// Use injected instance if available (for testing)
if (this._authService) {
return this._authService
}
// Otherwise, get singleton instance
try {
return AuthService.getInstance(this._controller)
} catch {
return undefined
}
}
/**
* Clears the banner cache
*/
public clearCache(): void {
this._cachedBanners = []
this._lastFetchTime = 0
Logger.log("BannerService: Cache cleared")
this.abortController?.abort()
this.abortController = null
this.cachedBanners = []
this.lastFetchTime = 0
this.consecutiveFailures = 0
this.backoffUntil = 0
this.fetchPromise = null
Logger.log("BannerService: Cache cleared and circuit breaker reset")
}
public async dismissBanner(bannerId: string): Promise<void> {
try {
const dismissed = StateManager.get().getGlobalStateKey("dismissedBanners") || []
if (dismissed.some((b) => b.bannerId === bannerId)) return
StateManager.get().setGlobalState("dismissedBanners", [...dismissed, { bannerId, dismissedAt: Date.now() }])
await this.sendBannerEvent(bannerId, "dismiss")
this.clearCache()
} catch (error) {
Logger.error("[BannerService] Error dismissing banner", error)
}
}
/**
* Sends a banner event to the telemetry endpoint
* @param bannerId The ID of the banner
* @param eventType The type of event (now we only support dismiss, in the future we might want to support seen, click...)
*/
public async sendBannerEvent(bannerId: string, eventType: "dismiss"): Promise<void> {
try {
const url = new URL("/banners/v1/events", this._baseUrl).toString()
const url = new URL("/banners/v2/messages", ClineEnv.config().apiBaseUrl).toString()
const ideType = this.getIdeType()
const surface = ideType === "cli" ? "cli" : ideType === "jetbrains" ? "jetbrains" : "vscode"
// Get IDE type for surface
const ideType = await this.getIdeType()
let surface: string
if (ideType === "cli") {
surface = "cli"
} else if (ideType === "jetbrains") {
surface = "jetbrains"
} else {
surface = "vscode"
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
const instanceId = this.getInstanceDistinctId()
const payload = {
banner_id: bannerId,
instance_id: instanceId,
surface,
event_type: eventType,
}
await axios.post(url, payload, {
timeout: 10000,
await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(await buildBasicClineHeaders()),
},
...getAxiosSettings(),
body: JSON.stringify({
banner_id: bannerId,
instance_id: this.hostInfo.distinctId,
surface,
event_type: eventType,
}),
signal: controller.signal,
})
Logger.log(`BannerService: Sent ${eventType} event for banner ${bannerId}`)
clearTimeout(timeoutId)
Logger.log(`[BannerService] Sent ${eventType} event for banner ${bannerId}`)
} catch (error) {
Logger.error(`BannerService: Error sending banner event`, error)
Logger.error("[BannerService] Error sending banner event", error)
}
}
/**
* Marks a banner as dismissed and stores it in state
* @param bannerId The ID of the banner to dismiss
*/
public async dismissBanner(bannerId: string): Promise<void> {
try {
const dismissedBanners = this._controller.stateManager.getGlobalStateKey("dismissedBanners") || []
if (dismissedBanners.some((b) => b.bannerId === bannerId)) {
Logger.log(`BannerService: Banner ${bannerId} already dismissed`)
return
}
const newDismissal = {
bannerId,
dismissedAt: Date.now(),
}
this._controller.stateManager.setGlobalState("dismissedBanners", [...dismissedBanners, newDismissal])
await this.sendBannerEvent(bannerId, "dismiss")
this.clearCache()
Logger.log(`BannerService: Banner ${bannerId} dismissed`)
} catch (error) {
Logger.error(`BannerService: Error dismissing banner`, error)
}
}
/**
* Checks if a banner has been dismissed by the user
* @param bannerId The ID of the banner to check
* @returns true if the banner has been dismissed
*/
public isBannerDismissed(bannerId: string): boolean {
try {
const dismissedBanners = this._controller.stateManager.getGlobalStateKey("dismissedBanners") || []
return dismissedBanners.some((b) => b.bannerId === bannerId)
const dismissed = StateManager.get().getGlobalStateKey("dismissedBanners") || []
return dismissed.some((b) => b.bannerId === bannerId)
} catch (error) {
Logger.error(`BannerService: Error checking if banner is dismissed`, error)
Logger.error("[BannerService] Error checking dismissed banner", error)
return false
}
}
/**
* Converts a Banner (API response format) to BannerCardData (UI format)
* @param banner The banner from the API
* @returns BannerCardData suitable for the carousel, or null if banner is invalid.
*/
private convertToBannerCardData(banner: Banner): BannerCardData | null {
// Validate all action types before conversion
// Each action must have a valid action type - undefined is not allowed
for (const action of banner.actions || []) {
if (!action.action || !this.actionTypes.has(action.action)) {
Logger.error(`BannerService: ${banner.id} has invalid or missing action type '${action.action ?? "undefined"}'.`)
return null
private async doFetch(): Promise<Banner[]> {
// Do not fetch banners when feature flag is off
if (!featureFlagsService.getBooleanFlagEnabled(FeatureFlag.REMOTE_BANNERS)) {
return []
}
this.abortController = new AbortController()
const { signal } = this.abortController
const timeoutId = setTimeout(() => this.abortController?.abort(), FETCH_TIMEOUT_MS)
try {
const url = this.buildFetchUrl()
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(await buildBasicClineHeaders()),
}
if (!action.title) {
Logger.error(`BannerService: ${banner.id} is missing an action title: ${JSON.stringify(action)}`)
const authToken = await AuthService.getInstance().getAuthToken()
if (authToken) {
headers.Authorization = `Bearer ${authToken}`
}
const response = await fetch(url, { method: "GET", headers, signal })
clearTimeout(timeoutId)
if (!response.ok) {
throw Object.assign(new Error(`HTTP ${response.status}`), {
status: response.status,
headers: response.headers,
})
}
const data = (await response.json()) as BannersResponse
if (!data?.data?.items || !Array.isArray(data.data.items)) {
Logger.log("BannerService: Invalid response format")
return []
}
const banners = data.data.items.filter((b) => this.matchesProviderRule(b))
this.cachedBanners = banners
this.lastFetchTime = Date.now()
this.consecutiveFailures = 0
Logger.log(`[BannerService] Fetched ${banners.length} banner(s) at ${new Date(this.lastFetchTime).toISOString()}`)
return banners
} catch (error) {
clearTimeout(timeoutId)
if (error instanceof Error && error.name === "AbortError") {
return this.cachedBanners
}
this.handleFetchError(error)
return this.cachedBanners
} finally {
this.abortController = null
}
}
private handleFetchError(error: unknown): void {
this.consecutiveFailures++
const typedError = error as { status?: number; headers?: { get(name: string): string | null } }
const status = typedError.status
let backoffMs = CIRCUIT_BREAKER_TIMEOUT_MS
if (status === 429) {
const retryAfter = typedError.headers?.get("retry-after")
if (retryAfter) {
const seconds = Number.parseInt(retryAfter, 10)
if (!Number.isNaN(seconds)) {
backoffMs = seconds * 1000
} else {
const date = new Date(retryAfter)
if (!Number.isNaN(date.getTime())) {
backoffMs = Math.max(0, date.getTime() - Date.now())
}
}
}
} else if (status && status >= 500 && status < 600) {
backoffMs = SERVER_ERROR_BACKOFF_MS
}
this.backoffUntil = Date.now() + backoffMs
if (this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
this.backoffUntil = Date.now() + CIRCUIT_BREAKER_TIMEOUT_MS
const msg =
this.consecutiveFailures === MAX_CONSECUTIVE_FAILURES ? "Circuit breaker tripped" : "Half-open recovery failed"
Logger.log(`BannerService: ${msg}, will allow recovery attempt after 1 hour`)
}
Logger.error(
`[BannerService] Failed ${this.consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}. ` +
`Backing off for ${Math.ceil(backoffMs / 60000)} minutes`,
error,
)
}
private buildFetchUrl(): string {
const url = new URL("/banners/v2/messages", ClineEnv.config().apiBaseUrl)
url.searchParams.set("ide", this.getIdeType())
url.searchParams.set("extension_version", this.hostInfo.extensionVersion)
url.searchParams.set("os", OS_MAP[this.hostInfo.os] || "unknown")
return url.toString()
}
private getIdeType(): string {
const ide = this.hostInfo.ide
for (const [key, value] of Object.entries(IDE_MAP)) {
if (ide.includes(key)) return value
}
return "unknown"
}
private matchesProviderRule(banner: Banner): boolean {
try {
const rules: BannerRules = JSON.parse(banner.rulesJson || "{}")
if (!rules?.providers?.length) return true
const config = StateManager.get().getApiConfiguration()
const mode = StateManager.get().getGlobalSettingsKey("mode")
const provider = mode === "plan" ? config?.planModeApiProvider : config?.actModeApiProvider
return rules.providers.some((ruleProvider) => {
// Check if ruleProvider is an alias for the selected provider
for (const [_, aliases] of Object.entries(PROVIDER_ALIASES)) {
if (aliases.includes(ruleProvider)) {
return aliases.includes(provider as string)
}
}
return provider === ruleProvider
})
} catch (error) {
Logger.log(
`[BannerService] Error parsing provider rules for banner ${banner.id}: ` +
`${error instanceof Error ? error.message : String(error)}`,
)
return true // Fail open
}
}
private toBannerCardData(banner: Banner): BannerCardData | null {
const actions = banner.actions || []
// Validate all actions have valid types
for (const action of actions) {
if (!action.action || !this.validActionTypes.has(action.action) || !action.title) {
Logger.error(`[BannerService] Invalid action type (${action.action}) for banner ${banner.id}`)
return null
}
}
const actions = (banner.actions || []).map((action) => ({
title: action.title || "",
action: action.action as BannerActionType,
arg: action.arg,
}))
return {
id: banner.id,
title: banner.titleMd,
description: banner.bodyMd,
icon: banner.icon,
actions,
}
}
/**
* Gets banners that haven't been dismissed by the user
* @param forceRefresh If true, bypasses cache and fetches fresh data
* @returns Array of non-dismissed banners converted to BannerCardData format
*
* TEMPORARILY DISABLED: Returning empty array to prevent API calls
*/
public async getActiveBanners(forceRefresh = false): Promise<BannerCardData[]> {
// Disable all banner fetching to prevent blocking the extension
return []
}
/**
* Gets the distinct ID for the current user
* @returns distinct ID string
*/
private getInstanceDistinctId(): string {
try {
return getDistinctId()
} catch (error) {
Logger.error("BannerService: Error getting distinct ID", error)
return "unknown"
actions: actions.map((a) => ({
title: a.title || "",
action: a.action as BannerActionType,
arg: a.arg,
})),
}
}
}
File diff suppressed because it is too large Load Diff
+38
View File
@@ -3,12 +3,14 @@ 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"
describe("distinctId", () => {
let sandbox: sinon.SinonSandbox
let mockContext: vscode.ExtensionContext
let mockGlobalState: any
let hostProviderInitialized: boolean = false
const MOCK_GLOBAL_STATE_ID = "existing-distinct-id-123"
const MOCK_MACHINE_ID = "machine-id-456"
@@ -20,6 +22,36 @@ describe("distinctId", () => {
beforeEach(() => {
sandbox = sinon.createSandbox()
// Initialize HostProvider if not already done
if (!HostProvider.isInitialized()) {
const mockHostBridge: any = {
workspaceClient: {},
envClient: {
getHostVersion: sandbox.stub().resolves({
clineVersion: "1.0.0",
platform: "darwin",
clineType: "vscode",
}),
},
windowClient: {},
diffClient: {},
}
HostProvider.initialize(
() => null as any, // createWebviewProvider
() => null as any, // createDiffViewProvider
() => null as any, // createCommentReviewController
() => null as any, // createTerminalManager
mockHostBridge,
() => {}, // logToChannel
async () => "http://localhost", // getCallbackUrl
async () => "", // getBinaryLocation
"/test/extension", // extensionFsPath
"/test/storage", // globalStorageFsPath
)
hostProviderInitialized = true
}
// Mock global state
mockGlobalState = { get: sandbox.stub(), update: sandbox.stub() }
@@ -32,6 +64,12 @@ describe("distinctId", () => {
afterEach(() => {
sandbox.restore()
// Reset HostProvider if we initialized it
if (hostProviderInitialized) {
HostProvider.reset()
hostProviderInitialized = false
}
})
it("should use id from extension globalstate if it exists", async () => {
+3
View File
@@ -1,5 +1,6 @@
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"
@@ -32,6 +33,8 @@ export async function initializeDistinctId(context: ClineExtensionContext, uuid:
setDistinctId(distinctId)
await HostRegistryInfo.init(distinctId)
Logger.log("[DistinctId] initialized:", distinctId)
}
@@ -5,12 +5,15 @@ export enum FeatureFlag {
WORKTREES = "worktree-exp",
// Feature flag for showing the new onboarding flow or old welcome view.
ONBOARDING_MODELS = "onboarding_models",
// Feature flag for remote banner service
REMOTE_BANNERS = "remote-banners",
}
export const FeatureFlagDefaultValue: Partial<Record<FeatureFlag, FeatureFlagPayload>> = {
[FeatureFlag.WEBTOOLS]: false,
[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",
}
export const FEATURE_FLAGS = Object.values(FeatureFlag)
@@ -84,12 +84,18 @@ describe("Controller Marketplace Filtering", () => {
},
]
beforeEach(() => {
beforeEach(async () => {
// Initialize HostProvider if not already done
if (!HostProvider.isInitialized()) {
const mockHostBridge: any = {
workspaceClient: {},
envClient: {},
envClient: {
getHostVersion: sinon.stub().resolves({
clineVersion: "1.0.0",
platform: "darwin",
clineType: "vscode",
}),
},
windowClient: {},
diffClient: {},
}
@@ -109,6 +115,9 @@ describe("Controller Marketplace Filtering", () => {
hostProviderInitialized = true
}
// Initialize HostRegistryInfo before creating Controller
await require("@/registry").HostRegistryInfo.init()
// Mock VSCode context
mockContext = {
globalState: {
+12 -1
View File
@@ -52,6 +52,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
mode,
userInfo,
currentFocusChainChecklist,
focusChainSettings,
hooksEnabled,
} = useExtensionState()
const isProdHostedApp = userInfo?.apiBaseUrl === "https://app.cline.bot"
@@ -297,6 +298,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
}, [modifiedMessages])
const lastProgressMessageText = useMemo(() => {
if (!focusChainSettings.enabled) {
return undefined
}
// First check if we have a current focus chain list from the extension state
if (currentFocusChainChecklist) {
return currentFocusChainChecklist
@@ -305,7 +310,12 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// Fall back to the last task_progress message if no state focus chain list
const lastProgressMessage = [...modifiedMessages].reverse().find((message) => message.say === "task_progress")
return lastProgressMessage?.text
}, [modifiedMessages, currentFocusChainChecklist])
}, [focusChainSettings.enabled, modifiedMessages, currentFocusChainChecklist])
const showFocusChainPlaceholder = useMemo(() => {
// Show placeholder whenever focus chain is enabled and no checklist exists yet.
return focusChainSettings.enabled && !lastProgressMessageText
}, [focusChainSettings.enabled, lastProgressMessageText])
const groupedMessages = useMemo(() => {
return groupLowStakesTools(groupMessages(visibleMessages))
@@ -333,6 +343,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
supportsPromptCache: selectedModelInfo.supportsPromptCache,
supportsImages: selectedModelInfo.supportsImages || false,
}}
showFocusChainPlaceholder={showFocusChainPlaceholder}
task={task}
/>
) : (
@@ -3,7 +3,7 @@ import MarkdownBlock from "../common/MarkdownBlock"
export const MarkdownRow = memo(({ markdown, showCursor }: { markdown?: string; showCursor?: boolean }) => {
return (
<div className="wrap-anywhere overflow-hidden">
<div className="wrap-anywhere overflow-hidden [&_p]:mb-0">
<MarkdownBlock markdown={markdown} showCursor={showCursor} />
</div>
)
@@ -19,6 +19,7 @@ interface TaskSectionProps {
}
messageHandlers: MessageHandlers
lastProgressMessageText?: string
showFocusChainPlaceholder?: boolean
}
/**
@@ -32,6 +33,7 @@ export const TaskSection: React.FC<TaskSectionProps> = ({
selectedModelInfo,
messageHandlers,
lastProgressMessageText,
showFocusChainPlaceholder,
}) => {
return (
<TaskHeader
@@ -42,6 +44,7 @@ export const TaskSection: React.FC<TaskSectionProps> = ({
lastProgressMessageText={lastProgressMessageText}
onClose={messageHandlers.handleTaskCloseButtonClick}
onSendMessage={messageHandlers.handleSendMessage}
showFocusChainPlaceholder={showFocusChainPlaceholder}
task={task}
tokensIn={apiMetrics.totalTokensIn}
tokensOut={apiMetrics.totalTokensOut}
@@ -164,7 +164,7 @@ export const ToolGroupRenderer = memo(({ messages, allMessages, isLastGroup }: T
return (
<div className={cn("px-4 py-2 ml-1 text-description")}>
{/* Header */}
<div className="text-[13px] text-foreground mb-1">{summary}:</div>
<div className="text-[13px] text-description font-semibold mb-1">{summary}:</div>
{/* Content - unified list of completed + active tools */}
<div className="min-w-0">
@@ -246,7 +246,8 @@ export function useScrollBehavior(
scrollToBottomAuto()
}, 0)
return () => clearTimeout(timer)
} else if (isCollapsing && (isLast || isSecondToLast)) {
}
if (isCollapsing && (isLast || isSecondToLast)) {
if (isSecondToLast && !isLastCollapsedApiReq) {
return
}
@@ -277,12 +278,20 @@ export function useScrollBehavior(
useEffect(() => {
if (!disableAutoScrollRef.current) {
scrollToBottomSmooth()
setTimeout(() => {
scrollToBottomSmooth()
}, 50)
if (!disableAutoScrollRef.current) {
scrollToBottomAuto()
}
}, 40)
setTimeout(() => {
if (!disableAutoScrollRef.current) {
scrollToBottomAuto()
}
}, 70)
// return () => clearTimeout(timer) // dont cleanup since if visibleMessages.length changes it cancels.
}
}, [groupedMessages.length, scrollToBottomSmooth])
}, [groupedMessages.length, scrollToBottomSmooth, scrollToBottomAuto])
useEffect(() => {
if (pendingScrollToMessage !== null) {
@@ -19,6 +19,7 @@ interface TodoInfo {
interface FocusChainProps {
readonly lastProgressMessageText?: string
readonly currentTaskItemId?: string
readonly showPlaceholderWhenEmpty?: boolean
}
// Static strings to avoid recreating them
@@ -54,7 +55,7 @@ const ToDoListHeader = memo<{
width: `${progressPercentage}%`,
}}
/>
<div className="flex items-center justify-between gap-2 z-10 py-2 px-2.5">
<div className="flex items-center gap-2 z-10 py-2 px-2.5">
<div className="flex items-center gap-1.5 flex-1 min-w-0 text-sm">
<span
className={cn(
@@ -65,11 +66,11 @@ const ToDoListHeader = memo<{
)}>
{currentIndex}/{totalCount}
</span>
<div className="header-text text-sm font-medium break-words overflow-hidden text-ellipsis whitespace-nowrap max-w-[calc(100%-60px)]">
<div className="header-text flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium">
<LightMarkdown compact text={displayText} />
</div>
</div>
<div className="flex items-center justify-between text-foreground">
<div className="flex items-center text-foreground shrink-0">
{isExpanded ? <ChevronDownIcon className="ml-0.25" size="16" /> : <ChevronRightIcon size="16" />}
</div>
</div>
@@ -157,7 +158,7 @@ const parseCurrentTodoInfo = (text: string): TodoInfo | null => {
// Main component with aggressive optimization
export const FocusChain: React.FC<FocusChainProps> = memo(
({ currentTaskItemId, lastProgressMessageText }) => {
({ currentTaskItemId, lastProgressMessageText, showPlaceholderWhenEmpty }) => {
const [isExpanded, setIsExpanded] = useState(false)
// Parse todo info with caching
@@ -182,6 +183,23 @@ export const FocusChain: React.FC<FocusChainProps> = memo(
// Early return for no content
if (!todoInfo) {
if (!showPlaceholderWhenEmpty) {
return null
}
return (
<div
aria-hidden={true}
className="relative rounded-sm bg-toolbar-hover/65 flex items-center gap-2 select-none overflow-hidden opacity-80 px-2.5 py-2">
<span className="rounded-lg px-2 py-0.25 inline-block shrink-0 bg-badge-foreground/20 text-foreground text-sm">
0/0
</span>
<span className="text-sm text-foreground/80 truncate">TODOs</span>
</div>
)
}
if (isExpanded && !lastProgressMessageText) {
return null
}
@@ -217,7 +235,8 @@ export const FocusChain: React.FC<FocusChainProps> = memo(
// Custom comparison for better performance
return (
prevProps.lastProgressMessageText === nextProps.lastProgressMessageText &&
prevProps.currentTaskItemId === nextProps.currentTaskItemId
prevProps.currentTaskItemId === nextProps.currentTaskItemId &&
prevProps.showPlaceholderWhenEmpty === nextProps.showPlaceholderWhenEmpty
)
},
)
@@ -26,6 +26,7 @@ interface TaskHeaderProps {
totalCost: number
lastApiReqTotalTokens?: number
lastProgressMessageText?: string
showFocusChainPlaceholder?: boolean
onClose: () => void
onSendMessage?: (command: string, files: string[], images: string[]) => void
}
@@ -41,6 +42,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
totalCost,
lastApiReqTotalTokens,
lastProgressMessageText,
showFocusChainPlaceholder,
onClose,
onSendMessage,
}) => {
@@ -48,6 +50,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
apiConfiguration,
currentTaskItem,
checkpointManagerErrorMessage,
focusChainSettings,
navigateToSettings,
mode,
expandTaskHeader: isTaskExpanded,
@@ -221,7 +224,13 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
</div>
{/* Display Focus Chain To-Do List */}
<FocusChain currentTaskItemId={currentTaskItem?.id} lastProgressMessageText={lastProgressMessageText} />
{focusChainSettings.enabled && (
<FocusChain
currentTaskItemId={currentTaskItem?.id}
lastProgressMessageText={lastProgressMessageText}
showPlaceholderWhenEmpty={showFocusChainPlaceholder}
/>
)}
</div>
)
}
+2 -3
View File
@@ -18,9 +18,8 @@ export const TabContent = ({ className, children, ...props }: TabProps) => {
const onWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement
// Prevent scrolling if the target is a listbox or option
// (e.g. selects, dropdowns, etc).
if (target.role === "listbox" || target.role === "option") {
// Prevent scrolling if the target or any of its ancestors is a listbox or option
if (target.closest('[role="listbox"], [role="combobox"], [role="option"]')) {
return
}
@@ -80,12 +80,12 @@ export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ open, onClose, ver
{/* Description */}
<ul className="text-sm pl-3 list-disc" style={{ color: "var(--vscode-descriptionForeground)" }}>
<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>
<strong>Minimax M2.5 is now available with free promo!</strong> SOTA coding capability with lightning
fast inference. <InlineModelLink label="Try now" modelId="minimax/minimax-m2.5" pickerTab="free" />
</li>
<li className="mb-2">
<strong>Z AI's GLM 5 is now available!</strong> Built for complex systems engineering and long-horizon
agentic tasks. <InlineModelLink label="Try now" modelId="z-ai/glm-5" pickerTab="recommended" />
<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>
</li>
<li className="mb-2">
<strong> Subagents experimental feature</strong> available in VSCode and the CLI.{" "}
@@ -97,12 +97,6 @@ export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ open, onClose, ver
Learn more
</a>
</li>
<li className="mb-2">
<strong>🎉 Free promo: Minimax-2.1 and Kimi-k2.5!</strong> Available free for a limited time.{" "}
<InlineModelLink label="Minimax-2.1" modelId="minimax/minimax-m2.1" pickerTab="free" />
{" | "}
<InlineModelLink label="Kimi-k2.5" modelId="moonshotai/kimi-k2.5" pickerTab="free" />
</li>
</ul>
{/* Social Icons Section */}
@@ -307,6 +307,7 @@ const ApiOptions = ({
}}
onKeyDown={handleKeyDown}
placeholder="Search and select provider..."
role="combobox"
style={{
width: "100%",
zIndex: DROPDOWN_Z_INDEX,
@@ -333,7 +334,7 @@ const ApiOptions = ({
)}
</VSCodeTextField>
{isDropdownVisible && (
<ProviderDropdownList ref={dropdownListRef}>
<ProviderDropdownList ref={dropdownListRef} role="listbox">
{providerSearchResults.map((item, index) => (
<ProviderDropdownItem
data-testid={`provider-option-${item.value}`}
@@ -343,7 +344,8 @@ const ApiOptions = ({
onMouseEnter={() => setSelectedIndex(index)}
ref={(el) => {
itemRefs.current[index] = el
}}>
}}
role="option">
<span>{item.html}</span>
</ProviderDropdownItem>
))}
@@ -207,6 +207,7 @@ const BasetenModelPicker: React.FC<BasetenModelPickerProps> = ({ isPopup, curren
}}
onKeyDown={handleKeyDown}
placeholder="Search and select a model..."
role="combobox"
style={{
width: "100%",
zIndex: BASETEN_MODEL_PICKER_Z_INDEX,
@@ -229,6 +230,7 @@ const BasetenModelPicker: React.FC<BasetenModelPickerProps> = ({ isPopup, curren
<div
className="absolute top-[calc(100%-3px)] left-0 w-[calc(100%-2px)] max-h-[200px] overflow-y-auto border border-(--vscode-list-activeSelectionBackground) rounded-b-[3px]"
ref={dropdownListRef}
role="listbox"
style={{
backgroundColor: "var(--vscode-dropdown-background)",
zIndex: BASETEN_MODEL_PICKER_Z_INDEX - 1,
@@ -246,7 +248,8 @@ const BasetenModelPicker: React.FC<BasetenModelPickerProps> = ({ isPopup, curren
onMouseEnter={() => setSelectedIndex(index)}
ref={(el: HTMLDivElement | null) => {
itemRefs.current[index] = el
}}>
}}
role="option">
{parseHighlightedText(item.html)}
</div>
))}
@@ -204,6 +204,7 @@ const GroqModelPicker: React.FC<GroqModelPickerProps> = ({ isPopup, currentMode
}}
onKeyDown={handleKeyDown}
placeholder="Search and select a model..."
role="combobox"
style={{
width: "100%",
zIndex: GROQ_MODEL_PICKER_Z_INDEX,
@@ -226,6 +227,7 @@ const GroqModelPicker: React.FC<GroqModelPickerProps> = ({ isPopup, currentMode
<div
className="absolute top-[calc(100%-3px)] left-0 w-[calc(100%-2px)] max-h-[200px] overflow-y-auto border border-(--vscode-list-activeSelectionBackground) rounded-b-[3px]"
ref={dropdownListRef}
role="listbox"
style={{
backgroundColor: "var(--vscode-dropdown-background)",
zIndex: GROQ_MODEL_PICKER_Z_INDEX - 1,
@@ -245,6 +247,7 @@ const GroqModelPicker: React.FC<GroqModelPickerProps> = ({ isPopup, currentMode
}}
onMouseEnter={() => setSelectedIndex(index)}
ref={(el: HTMLDivElement | null) => (itemRefs.current[index] = el)}
role="option"
/>
))}
</div>
@@ -180,6 +180,7 @@ const HicapModelPicker: React.FC<HicapModelPickerProps> = ({ isPopup, currentMod
}}
onKeyDown={handleKeyDown}
placeholder="Search and select a model..."
role="combobox"
style={{ zIndex: HICAP_MODEL_PICKER_Z_INDEX }}
value={searchTerm}>
{searchTerm && (
@@ -201,6 +202,7 @@ const HicapModelPicker: React.FC<HicapModelPickerProps> = ({ isPopup, currentMod
border border-[var(--vscode-list-activeSelectionBackground)]
rounded-b-[3px]"
ref={dropdownListRef}
role="listbox"
style={{ zIndex: HICAP_MODEL_PICKER_Z_INDEX - 1 }}>
{modelSearchResults.map((item, index) => {
const isFavorite = (favoritedModelIds || []).includes(item.id)
@@ -215,7 +217,8 @@ const HicapModelPicker: React.FC<HicapModelPickerProps> = ({ isPopup, currentMod
setIsDropdownVisible(false)
}}
onMouseEnter={() => setSelectedIndex(index)}
ref={(el) => (itemRefs.current[index] = el)}>
ref={(el) => (itemRefs.current[index] = el)}
role="option">
<div className="flex justify-between items-center [&_.model-item-highlight]:bg-[var(--vscode-editor-findMatchHighlightBackground)] [&_.model-item-highlight]:text-inherit">
<span dangerouslySetInnerHTML={{ __html: item.html }} />
<StarIcon
@@ -181,6 +181,7 @@ const HuggingFaceModelPicker: React.FC<HuggingFaceModelPickerProps> = ({ isPopup
}}
onKeyDown={handleKeyDown}
placeholder="Search models..."
role="combobox"
value={searchTerm}>
{searchTerm && (
<div
@@ -205,7 +206,8 @@ const HuggingFaceModelPicker: React.FC<HuggingFaceModelPickerProps> = ({ isPopup
className={`absolute top-[calc(100%-3px)] left-0 w-[calc(100%-2px)] ${
isPopup ? "max-h-[90px]" : "max-h-[200px]"
} overflow-y-auto bg-(--vscode-dropdown-background) border border-(--vscode-list-activeSelectionBackground) z-999 rounded-b-[3px]`}
ref={dropdownListRef}>
ref={dropdownListRef}
role="listbox">
{modelSearchResults.map((result, index) => (
<div
className={`p-[5px_10px] cursor-pointer break-all whitespace-normal ${
@@ -217,7 +219,8 @@ const HuggingFaceModelPicker: React.FC<HuggingFaceModelPickerProps> = ({ isPopup
setIsDropdownVisible(false)
}}
onMouseEnter={() => setSelectedIndex(index)}
ref={(el: HTMLDivElement | null) => (itemRefs.current[index] = el)}>
ref={(el: HTMLDivElement | null) => (itemRefs.current[index] = el)}
role="option">
<div
className="[&_.model-item-highlight]:bg-(--vscode-editor-findMatchHighlightBackground) [&_.model-item-highlight]:text-inherit"
dangerouslySetInnerHTML={{ __html: result.html }}
@@ -139,6 +139,7 @@ const OllamaModelPicker: React.FC<OllamaModelPickerProps> = ({
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
role="combobox"
style={{
width: "100%",
zIndex: OLLAMA_MODEL_PICKER_Z_INDEX,
@@ -164,7 +165,7 @@ const OllamaModelPicker: React.FC<OllamaModelPickerProps> = ({
)}
</VSCodeTextField>
{isDropdownVisible && modelSearchResults.length > 0 && (
<DropdownList ref={dropdownListRef}>
<DropdownList ref={dropdownListRef} role="listbox">
{modelSearchResults.map((item, index) => (
<DropdownItem
isSelected={index === selectedIndex}
@@ -174,7 +175,8 @@ const OllamaModelPicker: React.FC<OllamaModelPickerProps> = ({
setIsDropdownVisible(false)
}}
onMouseEnter={() => setSelectedIndex(index)}
ref={(el) => (itemRefs.current[index] = el)}>
ref={(el) => (itemRefs.current[index] = el)}
role="option">
<span dangerouslySetInnerHTML={{ __html: item.html }} />
</DropdownItem>
))}
@@ -82,13 +82,8 @@ export const recommendedModels = [
export const freeModels = [
{
id: "minimax/minimax-m2.1",
description: "MiniMax-M2.1 is a lightweight, state-of-the-art LLM optimized for coding and agentic workflows",
label: "FREE",
},
{
id: "moonshotai/kimi-k2.5",
description: "Moonshot's SOTA Coding Model",
id: "minimax/minimax-m2.5",
description: "MiniMax-M2.5 is a lightweight, state-of-the-art LLM optimized for coding and agentic workflows",
label: "FREE",
},
{
@@ -101,11 +96,6 @@ export const freeModels = [
description: "Arcee AI's advanced large preview model in the Trinity series",
label: "FREE",
},
{
id: "stealth/giga-potato",
description: "A stealth model for coding(may underperform in quality and have longer latency)",
label: "FREE",
},
]
const FREE_CLINE_MODELS = freeModels.map((m) => m.id)
@@ -399,6 +389,7 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({
}}
onKeyDown={handleKeyDown}
placeholder="Search and select a model..."
role="combobox"
style={{
width: "100%",
zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX,
@@ -424,7 +415,7 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({
)}
</VSCodeTextField>
{isDropdownVisible && (
<DropdownList ref={dropdownListRef}>
<DropdownList ref={dropdownListRef} role="listbox">
{modelSearchResults.map((item, index) => {
const isFavorite = (favoritedModelIds || []).includes(item.id)
return (
@@ -436,7 +427,8 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({
setIsDropdownVisible(false)
}}
onMouseEnter={() => setSelectedIndex(index)}
ref={(el) => (itemRefs.current[index] = el)}>
ref={(el) => (itemRefs.current[index] = el)}
role="option">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span dangerouslySetInnerHTML={{ __html: item.html }} />
<StarIcon
@@ -199,6 +199,7 @@ const RequestyModelPicker: React.FC<RequestyModelPickerProps> = ({ isPopup, base
}}
onKeyDown={handleKeyDown}
placeholder="Search and select a model..."
role="combobox"
style={{
width: "100%",
zIndex: REQUESTY_MODEL_PICKER_Z_INDEX,
@@ -224,7 +225,7 @@ const RequestyModelPicker: React.FC<RequestyModelPickerProps> = ({ isPopup, base
)}
</VSCodeTextField>
{isDropdownVisible && (
<DropdownList ref={dropdownListRef}>
<DropdownList ref={dropdownListRef} role="listbox">
{modelSearchResults.map((item, index) => (
<DropdownItem
dangerouslySetInnerHTML={{
@@ -238,6 +239,7 @@ const RequestyModelPicker: React.FC<RequestyModelPickerProps> = ({ isPopup, base
}}
onMouseEnter={() => setSelectedIndex(index)}
ref={(el) => (itemRefs.current[index] = el)}
role="option"
/>
))}
</DropdownList>
@@ -211,6 +211,7 @@ const VercelModelPicker: React.FC<VercelModelPickerProps> = ({ isPopup, currentM
}}
onKeyDown={handleKeyDown}
placeholder="Search and select a model..."
role="combobox"
style={{
width: "100%",
zIndex: VERCEL_MODEL_PICKER_Z_INDEX,
@@ -236,7 +237,7 @@ const VercelModelPicker: React.FC<VercelModelPickerProps> = ({ isPopup, currentM
)}
</VSCodeTextField>
{isDropdownVisible && (
<DropdownList ref={dropdownListRef}>
<DropdownList ref={dropdownListRef} role="listbox">
{modelSearchResults.length > 0 ? (
modelSearchResults.map((item, index) => (
<DropdownItem
@@ -247,7 +248,8 @@ const VercelModelPicker: React.FC<VercelModelPickerProps> = ({ isPopup, currentM
setIsDropdownVisible(false)
}}
onMouseEnter={() => setSelectedIndex(index)}
ref={(el) => (itemRefs.current[index] = el)}>
ref={(el) => (itemRefs.current[index] = el)}
role="option">
<span dangerouslySetInnerHTML={{ __html: item.html }} />
</DropdownItem>
))
@@ -180,6 +180,7 @@ export const ModelAutocomplete = ({
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
role="combobox"
style={{
width: "100%",
zIndex: zIndex,
@@ -1,8 +1,17 @@
import { bedrockDefaultModelId, bedrockModels, CLAUDE_SONNET_1M_SUFFIX } from "@shared/api"
import BedrockData from "@shared/providers/bedrock.json"
import type { Mode } from "@shared/storage/types"
import { VSCodeCheckbox, VSCodeDropdown, VSCodeOption, VSCodeRadio, VSCodeRadioGroup } from "@vscode/webview-ui-toolkit/react"
import { useState } from "react"
import {
VSCodeCheckbox,
VSCodeDropdown,
VSCodeOption,
VSCodeRadio,
VSCodeRadioGroup,
VSCodeTextField,
} from "@vscode/webview-ui-toolkit/react"
import Fuse from "fuse.js"
import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"
import styled from "styled-components"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { DebouncedTextField } from "../common/DebouncedTextField"
@@ -45,6 +54,106 @@ export const BedrockProvider = ({ showModelOptions, isPopup, currentMode }: Bedr
const modeFields = getModeSpecificFields(apiConfiguration, currentMode)
const [awsEndpointSelected, setAwsEndpointSelected] = useState(!!apiConfiguration?.awsBedrockEndpoint)
// Region combobox state
const currentRegion = apiConfiguration?.awsRegion || ""
const [searchTerm, setSearchTerm] = useState("")
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const dropdownRef = useRef<HTMLDivElement>(null)
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
const dropdownListRef = useRef<HTMLDivElement>(null)
const isSelectingRef = useRef(false)
useEffect(() => {
setSearchTerm(currentRegion)
}, [currentRegion])
const fuse = useMemo(() => {
return new Fuse(AWS_REGIONS, {
threshold: 0.3,
shouldSort: true,
isCaseSensitive: false,
ignoreLocation: false,
includeMatches: true,
minMatchCharLength: 1,
})
}, [])
const regionSearchResults = useMemo(() => {
if (!searchTerm) {
return AWS_REGIONS
}
return fuse.search(searchTerm).map((r) => r.item)
}, [searchTerm, fuse])
const handleRegionChange = (newRegion: string) => {
setSearchTerm(newRegion)
handleFieldChange("awsRegion", newRegion)
}
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (!isDropdownVisible) {
return
}
switch (event.key) {
case "ArrowDown":
event.preventDefault()
setSelectedIndex((prev) => (prev < regionSearchResults.length - 1 ? prev + 1 : prev))
break
case "ArrowUp":
event.preventDefault()
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev))
break
case "Enter":
event.preventDefault()
if (selectedIndex >= 0 && selectedIndex < regionSearchResults.length) {
handleRegionChange(regionSearchResults[selectedIndex])
setIsDropdownVisible(false)
} else {
// User typed a custom region
handleRegionChange(searchTerm)
setIsDropdownVisible(false)
}
break
case "Escape":
setIsDropdownVisible(false)
setSelectedIndex(-1)
break
}
}
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownVisible(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [])
// Reset selection when search term changes
useEffect(() => {
setSelectedIndex(-1)
if (dropdownListRef.current) {
dropdownListRef.current.scrollTop = 0
}
}, [searchTerm])
// Scroll selected item into view
useEffect(() => {
if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) {
itemRefs.current[selectedIndex]?.scrollIntoView({
block: "nearest",
behavior: "smooth",
})
}
}, [selectedIndex])
return (
<div className="flex flex-col gap-1">
<VSCodeRadioGroup
@@ -115,27 +224,87 @@ export const BedrockProvider = ({ showModelOptions, isPopup, currentMode }: Bedr
<TooltipTrigger>
<DropdownContainer className="dropdown-container mb-2.5" zIndex={DROPDOWN_Z_INDEX - 1}>
<div className="flex items-center gap-2 mb-1">
<label htmlFor="aws-region-dropdown">
<label htmlFor="aws-region">
<span className="font-medium">AWS Region</span>
</label>
{remoteConfigSettings?.awsRegion !== undefined && (
<i className="codicon codicon-lock text-description text-sm flex items-center" />
)}
</div>
<VSCodeDropdown
className="w-full"
disabled={remoteConfigSettings?.awsRegion !== undefined}
id="aws-region-dropdown"
onChange={(e: any) => handleFieldChange("awsRegion", e.target.value)}
value={apiConfiguration?.awsRegion || ""}>
<VSCodeOption value="">Select a region...</VSCodeOption>
{/* The user will have to choose a region that supports the model they use, but this shouldn't be a problem since they'd have to request access for it in that region in the first place. */}
{AWS_REGIONS.map((region) => (
<VSCodeOption key={region} value={region}>
{region}
</VSCodeOption>
))}
</VSCodeDropdown>
<RegionDropdownWrapper ref={dropdownRef}>
<VSCodeTextField
aria-autocomplete="list"
aria-expanded={isDropdownVisible}
disabled={remoteConfigSettings?.awsRegion !== undefined}
id="aws-region"
onBlur={() => {
if (!isSelectingRef.current && searchTerm !== currentRegion) {
handleRegionChange(searchTerm || currentRegion)
}
isSelectingRef.current = false
}}
onFocus={() => {
setIsDropdownVisible(true)
setSearchTerm("")
}}
onInput={(e) => {
setSearchTerm((e.target as HTMLInputElement)?.value || "")
setIsDropdownVisible(true)
}}
onKeyDown={handleKeyDown}
placeholder="Search or enter custom region..."
role="combobox"
style={{
width: "100%",
zIndex: DROPDOWN_Z_INDEX - 1,
position: "relative",
minWidth: 130,
}}
value={searchTerm}>
{searchTerm && searchTerm !== currentRegion && (
<div
aria-label="Clear search"
className="input-icon-button codicon codicon-close"
onClick={() => {
setSearchTerm("")
setIsDropdownVisible(true)
}}
slot="end"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
}}
/>
)}
</VSCodeTextField>
{isDropdownVisible && regionSearchResults.length > 0 && (
<RegionDropdownList ref={dropdownListRef} role="listbox">
{regionSearchResults.map((region, index) => (
<RegionDropdownItem
aria-selected={index === selectedIndex}
isSelected={index === selectedIndex}
key={region}
onClick={() => {
handleRegionChange(region)
setIsDropdownVisible(false)
isSelectingRef.current = false
}}
onMouseDown={() => {
isSelectingRef.current = true
}}
onMouseEnter={() => setSelectedIndex(index)}
ref={(el) => {
itemRefs.current[index] = el
}}
role="option">
<span>{region}</span>
</RegionDropdownItem>
))}
</RegionDropdownList>
)}
</RegionDropdownWrapper>
</DropdownContainer>
</TooltipTrigger>
</Tooltip>
@@ -366,3 +535,36 @@ export const BedrockProvider = ({ showModelOptions, isPopup, currentMode }: Bedr
</div>
)
}
const RegionDropdownWrapper = styled.div`
position: relative;
width: 100%;
`
const RegionDropdownList = styled.div`
position: absolute;
top: calc(100% - 3px);
left: 0;
width: calc(100% - 2px);
max-height: 200px;
overflow-y: auto;
background-color: var(--vscode-dropdown-background);
border: 1px solid var(--vscode-list-activeSelectionBackground);
z-index: ${DROPDOWN_Z_INDEX - 1};
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
`
const RegionDropdownItem = styled.div<{ isSelected: boolean }>`
padding: 5px 10px;
cursor: pointer;
word-break: break-all;
white-space: normal;
text-align: left;
background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")};
&:hover {
background-color: var(--vscode-list-activeSelectionBackground);
}
`