mirror of
https://github.com/cline/cline.git
synced 2026-09-04 11:44:01 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81f5e632d0 | |||
| 1a29d428ae | |||
| 92cf03e42c | |||
| 3ea393a5e9 | |||
| 9829e7d49e | |||
| 56de96e5ff | |||
| ecde79cf08 | |||
| ff36cbcb87 | |||
| 5a75f08118 | |||
| 120754c2fe | |||
| 5d048d09f8 | |||
| 36580ce086 | |||
| c584bf4185 | |||
| 8133babf41 | |||
| 741f524da7 | |||
| d3918dd7df |
@@ -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
|
||||
'''
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+85
-50
@@ -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
@@ -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",
|
||||
|
||||
Executable
+72
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user