Compare commits

..
Author SHA1 Message Date
Saoud Rizwan 3c23f80a94 fix(sdk): deflake run_commands timeout telemetry tests 2026-06-19 14:59:46 -07:00
Saoud Rizwan 4be362892f chore(cli): release v3.0.28 2026-06-19 14:52:50 -07:00
Saoud Rizwan cdff084652 chore(sdk): release v0.0.50 2026-06-19 14:11:25 -07:00
Robin NewhouseandSaoud Rizwan 299a4a9520 fix(sdk): harden parallel tool guidance (#11598)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-19 12:25:20 -07:00
Saoud Rizwan c497698beb fix(cli): resolve Cline model display names by just model name (#11668)
* fix(cli): resolve Cline model display names by slug

* fix(cli): type model display metadata

* test(cli): cover model display fallback paths
2026-06-19 12:21:57 -07:00
Ara 292133989b fix(sdk): cap assistant text in provider messages (#11478) 2026-06-19 11:41:04 -07:00
AraandSaoud Rizwan f5aa035a6a fix(sdk): default-on tool result truncation, tightened limits + budget accounting in MessageBuilder (#11475)
* fix(sdk): default-on tool result truncation, name fallback, tool_use budget accounting

- Truncate every tool result (MCP/custom tools included), not just an allowlist
- Resolve tool names from tool_result.name when the paired tool_use is gone
- Count tool_use.input strings toward the aggregate provider request budget
- Protect any binary carrier block ({type, data}) from truncation, not just images
- Don't let failSession cleanup errors mask the original turn error

* fix(sdk): tighten MessageBuilder limits with named options and env overrides

Folds #11474 into the default-on truncation branch and addresses review
feedback from both PRs:

- per-result tool cap drops to 8k chars; MessageBuilderOptions constructor
  with CLINE_MESSAGE_BUILDER_* env overrides for A/B testing
- env parsing is positive-only so '=0' cannot silently disable a limit
  (greptile on #11474)
- aggregate budget stays at 6MB: budget truncation rewrites mid-transcript
  bytes and breaks provider prefix caching, so it must stay a rare overflow
  valve rather than the steady state (johnwschoi on #11474)
- user file attachments get a dedicated 50k cap instead of inheriting the
  aggressive tool-result cap (codex on #11474)
- isBinaryContentLike restricted to known binary block types so textual
  {type, data} payloads can no longer dodge every cap (codex/greptile)
- tool_use.input strings become last-resort budget truncation candidates,
  making the aggregate budget reclaimable when oversized model-generated
  arguments carry the overflow (robinnewhouse/greptile)

* fix(sdk): constrain binary tool result truncation

* style(sdk): remove disallowed comment formatting

* Revert "style(sdk): remove disallowed comment formatting"

This reverts commit 9d8b66726d.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-19 11:15:42 -07:00
Saoud Rizwan bbcae25542 fix(cli): refine marketplace primitive rows (#11659) 2026-06-19 10:57:37 -07:00
Tomás Barreiro 9b914912ac Rename Cline Pass to ClinePass (#11657) 2026-06-19 18:36:07 +02:00
Saoud Rizwan 8c369eeed9 docs(vscode): remove marketplace readme language links (#11658) 2026-06-18 18:09:02 -07:00
Saoud Rizwan 85223a07cf fix(cli): apply auto-approve toggles immediately (#11653) 2026-06-18 15:58:31 -07:00
John ChoiandBarreiroT eb2687677c feat(onboarding): add Cline Pass to the signup flow (#11609)
* Add Cline Pass provider to VS Code extension

* Fix the model picker

* ClinePass specific options

* Add feature flag to the api options

* Extend flag usage

* fix type error

* Use the right model info

* Fix tests

* Hide price info for clinePass models

* feat(onboarding): add Cline Pass as optional user-type in signup flow

Surface Cline Pass as a recommended-but-optional onboarding choice gated
behind the ext-cline-pass feature flag, alongside Free / Frontier / BYOK.
When selected, signup provisions the cline-pass provider and ClinePass
model fields; price info is hidden since cost is covered by the
subscription. Falls back cleanly to the existing flow when the flag is
off. Adds unit tests for the new helpers.

* feat(onboarding): nudge + Cline Pass subscribe link in signup flow

Strengthen the Cline Pass onboarding option with a 'Recommended' copy
nudge (text + ordering, no badge/paid-default), and add an additive
subscribe affordance on the post-signup 'Almost there!' step that links
to {appBaseUrl}/dashboard/plan. Client-only: reuses existing appBaseUrl
from useClineAuth and the existing dashboard subscribe page; no backend
change. Shown only when the user selects Cline Pass; existing flow and
other user-types are unchanged.

* refactor(onboarding): redirect Cline Pass signup to subscription page

Replace the manual 'Get Cline Pass' callout with an automatic redirect:
after a Cline Pass user completes account creation, open the dashboard
subscription signup page (/onboarding/individual-plan) in the browser.
Keep the free option first and default-selected, with Cline Pass shown
second and labeled '(Recommended)'.

* chore(onboarding): tighten inline comments

* fix(onboarding): constrain Cline Pass selection and clear stale subscribe redirect

Addresses review findings:
- Never save a non-Cline-Pass model id under the cline-pass provider:
  Cline Pass selection no longer falls back to a free model when the
  Cline Pass list is empty, the generic model search is hidden for Cline
  Pass, finishOnboarding guards on a cline-pass/ id, and the CTA is
  disabled (with an empty state) when no Cline Pass model is available.
- Clear the pending subscription redirect when the user backs out, signs
  in, or navigates away from Cline Pass, and re-check userType in the
  redirect effect so a late auth update can't force the paid flow.

* fix(onboarding): add userType to handleFooterAction deps

The signup handler reads userType (to set the pending Cline Pass
subscribe flag); make that dependency explicit rather than relying on
the transitive finishOnboarding dependency.

* fix(onboarding): gate Cline Pass model data by feature flag

* fix(onboarding): address Greptile review on Cline Pass signup

- Log when the Cline Pass provider write is skipped due to an unexpected
  (non cline-pass/) model id, so the otherwise-silent no-op is observable.
- Open the subscription page for already-authenticated users by invoking
  the redirect helper directly after accountLoginClicked resolves, not
  only via the auth effect (which never re-runs when clineUser is
  unchanged).

* fix(onboarding): preserve ClinePass selection through login + one-word branding

- handleAuthCallback no longer forces the provider back to 'cline' on
  login when the user picked Cline Pass during onboarding; it preserves a
  'cline-pass' selection per mode. Only 'cline-pass' configs are affected
  (which require the ext-cline-pass flag), so all other logins are
  unchanged.
- Open the subscription page solely from the clineUser auth effect; the
  signup action no longer also calls it directly (prevents the login
  browser and subscribe page opening at once / dead-ends for already-
  authenticated users). Signup flow is otherwise untouched.
- Rename user-facing 'Cline Pass' to one-word 'ClinePass' (card titles,
  step title, empty state) and the model group label to 'clinepass'.

* chore(onboarding): normalize ClinePass branding in comments, trim verbose comments

* fix(merge): resolve ApiOptions redeclare + apply biome semicolon formatting

- Remove duplicate CLINE_PASS_FEATURE_FLAG const in ApiOptions.tsx (the
  shared import from constants/featureFlags supersedes main's local const).
- Apply biome format (semicolons) to onboarding/controller files so they
  match main's current style and pass Quality Checks.

* fix: restore clean no-semicolon formatting, keep only real ClinePass changes

The previous merge-recovery commit ran a local biome that incorrectly added
semicolons across entire files, bloating the diff by ~2000 LOC. Restore the
files to their clean pre-format state (matching main's asNeeded style) so the
diff reflects only the actual ClinePass onboarding changes (~420 LOC). Keeps
the handleAuthCallback provider-preservation fix and the ApiOptions duplicate
const removal.

* chore(onboarding): trim redundant inline comments

* chore: match main in refreshClineRecommendedModels (drop snake_case cline_pass handling)

* chore: trim featureFlags.ts comment

* fix(onboarding): open ClinePass subscribe page from App, not OnboardingView

handleAuthCallback marks the welcome view completed (unmounting OnboardingView)
before it pushes the auth-status update that sets clineUser. The redirect effect
lived in OnboardingView, so the pending-subscribe intent was lost on unmount and
the subscription page never opened for new ClinePass users.

Move the pending intent to a module-level store (clinePassSubscribe.ts) and run
the redirect from an effect in App, which outlives the onboarding unmount.

---------

Co-authored-by: BarreiroT <tomasmbarreiroi@gmail.com>
2026-06-18 14:45:34 -07:00
Tomás Barreiro 6fe5acb2a4 Resolve feature flags using the user id on startup (#11652) 2026-06-18 23:24:42 +02:00
67 changed files with 2874 additions and 822 deletions
+10
View File
@@ -1,5 +1,15 @@
# Cline CLI Changelog
## 3.0.28
- Added a ClinePass onboarding flow with selectable ClinePass models, plus improved ClinePass error handling
- Added hub primitive catalogs and refreshed the hub dashboard design with a dedicated customizations breakout
- Auto-approve toggles now apply immediately when changed
- Feature flags now resolve using your user ID on startup
- Fixed Cline model display names so they resolve by model name
- Truncate large tool results by default (including MCP and custom tool output) to keep requests within context budget
- Hardened parallel tool-call guidance for faster, more reliable multi-tool execution
## 3.0.27
- Added a `cline skill` command to install and manage skills, matching `cline plugin install` and `cline mcp` (installs default to the Cline agent directory)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.27",
"version": "3.0.28",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+33
View File
@@ -114,6 +114,7 @@ const telemetryMocks = vi.hoisted(() => ({
}));
const featureFlagMocks = vi.hoisted(() => ({
getBooleanFlagEnabled: vi.fn(() => false),
setCliFeatureFlagsAccountContext: vi.fn(),
}));
function forcePromptModeInput() {
@@ -179,6 +180,8 @@ vi.mock("./utils/feature-flags", () => ({
getBooleanFlagEnabled: featureFlagMocks.getBooleanFlagEnabled,
}),
refreshCliFeatureFlagsInBackground: vi.fn(),
setCliFeatureFlagsAccountContext:
featureFlagMocks.setCliFeatureFlagsAccountContext,
}));
vi.mock("./runtime/prompt", () => ({
resolveSystemPrompt: promptMocks.resolveSystemPrompt,
@@ -252,6 +255,9 @@ describe("runCli lightweight command dispatch", () => {
providerSettingsMocks.getProviderSettings.mockReset();
providerSettingsMocks.getProviderSettings.mockReturnValue(undefined);
providerSettingsMocks.saveProviderSettings.mockReset();
featureFlagMocks.getBooleanFlagEnabled.mockReset();
featureFlagMocks.getBooleanFlagEnabled.mockReturnValue(false);
featureFlagMocks.setCliFeatureFlagsAccountContext.mockReset();
kanbanMocks.launchKanban.mockReset();
kanbanMocks.launchKanban.mockResolvedValue(0);
dashboardMocks.runDashboardCommand.mockReset();
@@ -912,6 +918,33 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("seeds feature flag identity from persisted Cline account id before checking flags", async () => {
const clineSettings = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
auth: {
accountId: "acct-startup",
accessToken: "workos:token",
refreshToken: "refresh-token",
},
};
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(
featureFlagMocks.setCliFeatureFlagsAccountContext,
).toHaveBeenCalledWith({ id: "acct-startup" });
expect(
featureFlagMocks.setCliFeatureFlagsAccountContext.mock
.invocationCallOrder[0],
).toBeLessThan(
featureFlagMocks.getBooleanFlagEnabled.mock.invocationCallOrder[0],
);
});
it("runs kanban before loading runtime modules", async () => {
process.argv = ["bun", "src/index.ts", "kanban"];
+7
View File
@@ -22,6 +22,7 @@ import {
import {
getCliFeatureFlagsService,
refreshCliFeatureFlagsInBackground,
setCliFeatureFlagsAccountContext,
} from "./utils/feature-flags";
import {
configureSandboxEnvironment,
@@ -919,6 +920,12 @@ export async function runCli(): Promise<void> {
};
registerDisposable(stopUserInstructionService);
try {
const persistedClineAccountId = providerSettingsManager
.getProviderSettings("cline")
?.auth?.accountId?.trim();
if (persistedClineAccountId) {
setCliFeatureFlagsAccountContext({ id: persistedClineAccountId });
}
refreshCliFeatureFlagsInBackground();
const lastUsedProviderSettings =
providerSettingsManager.getLastUsedProviderSettings({
@@ -61,6 +61,20 @@ describe("createInteractiveApprovalController", () => {
).resolves.toEqual({ approved: false, reason: "no" });
});
it("approves stale required-approval requests after auto-approve is enabled", async () => {
const controller = createInteractiveApprovalController(makeConfig(false));
controller.tuiToolApprover.current = async () => ({
approved: false,
reason: "stale prompt",
});
controller.setInteractiveAutoApprove(true);
await expect(
controller.requestToolApproval(makeRequest({ autoApprove: false })),
).resolves.toEqual({ approved: true });
});
it("denies approval-required requests when no TUI approver is available", async () => {
const controller = createInteractiveApprovalController(makeConfig(false));
@@ -77,6 +91,7 @@ describe("createInteractiveApprovalController", () => {
expect(controller.autoApproveAllRef.current).toBe(true);
expect(config.defaultToolAutoApprove).toBe(false);
expect(config.toolPolicies["*"]?.autoApprove).toBe(false);
expect(config.toolPolicies["*"]?.autoApprove).toBe(true);
expect(controller.resolveToolPolicy("run_commands").autoApprove).toBe(true);
});
});
@@ -3,6 +3,7 @@ import type { Config } from "../../utils/types";
import {
applyInteractiveAutoApproveOverride,
cloneToolPolicies,
resolveInteractiveAutoApprovePolicy,
} from "../tool-policies";
export interface InteractiveRuntimeRefs {
@@ -38,10 +39,10 @@ export function createInteractiveApprovalController(config: Config) {
const requestToolApproval = async (
request: ToolApprovalRequest,
): Promise<ToolApprovalResult> => {
if (request.policy?.autoApprove === true) {
if (autoApproveAllRef.current) {
return { approved: true };
}
if (autoApproveAllRef.current && request.policy?.autoApprove !== false) {
if (request.policy?.autoApprove === true) {
return { approved: true };
}
if (refs.tuiToolApprover.current) {
@@ -54,6 +55,12 @@ export function createInteractiveApprovalController(config: Config) {
autoApproveAllRef,
setInteractiveAutoApprove,
requestToolApproval,
resolveToolPolicy: (toolName: string) =>
resolveInteractiveAutoApprovePolicy({
toolName,
baselinePolicies: baselineToolPolicies,
enabled: autoApproveAllRef.current,
}),
...refs,
};
}
@@ -152,7 +152,10 @@ function deferred<T>() {
function makeRuntime(
manager: ReturnType<typeof makeManager>,
options: { resumeSessionId?: string } = {},
options: {
resumeSessionId?: string;
resolveToolPolicy?: (toolName: string) => Config["toolPolicies"][string];
} = {},
) {
mockCreateCliCore.mockResolvedValue(manager);
const config = makeConfig();
@@ -164,6 +167,8 @@ function makeRuntime(
requestToolApproval: async (
_request: ToolApprovalRequest,
): Promise<ToolApprovalResult> => ({ approved: true }),
resolveToolPolicy:
options.resolveToolPolicy ?? (() => ({ autoApprove: true })),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: makeSwitchToActModeTool(),
@@ -205,6 +210,68 @@ describe("createInteractiveSessionRuntime", () => {
expect(runtime.getActiveSessionId()).toBe("session-2");
});
it("adds a live interactive approval policy hook to started sessions", async () => {
const manager = makeManager();
const upstreamBeforeTool = vi.fn(async () => ({
input: { text: "updated" },
}));
mockCreateRuntimeHooks.mockReturnValueOnce({
hooks: {
beforeTool: upstreamBeforeTool,
},
shutdown: vi.fn(async () => {}),
});
const runtime = makeRuntime(manager, {
resolveToolPolicy: (toolName) => ({
autoApprove: toolName === "echo",
}),
});
await runtime.ensureReady();
const startInput = manager.start.mock.calls[0]?.[0] as
| { config?: Config }
| undefined;
const beforeTool = startInput?.config?.hooks?.beforeTool;
expect(beforeTool).toBeTypeOf("function");
const result = await beforeTool?.({
snapshot: {
agentId: "agent-1",
conversationId: "conversation-1",
status: "running",
iteration: 1,
messages: [],
pendingToolCalls: [],
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
},
},
tool: {
name: "echo",
description: "",
inputSchema: {},
execute: async () => "ok",
},
toolCall: {
type: "tool-call",
toolCallId: "call-1",
toolName: "echo",
input: { text: "original" },
},
input: { text: "original" },
});
expect(upstreamBeforeTool).toHaveBeenCalledOnce();
expect(result).toEqual({
input: { text: "updated" },
policy: { autoApprove: true },
});
});
it("starts fresh after resetting an initially resumed session", async () => {
const manager = makeManager();
const runtime = makeRuntime(manager, {
@@ -1,5 +1,6 @@
import {
type AgentEvent,
type AgentHooks,
type CheckpointEntry,
isSessionNotFoundError,
type PendingPromptMutationResult,
@@ -48,6 +49,32 @@ type CurrentTurnResult = Awaited<ReturnType<CliCore["send"]>>;
type AskQuestionRef = {
current: ((question: string, options: string[]) => Promise<string>) | null;
};
type ToolPolicyResolver = (
toolName: string,
) => NonNullable<Config["toolPolicies"]>[string];
function withInteractiveApprovalPolicyHook(
hooks: AgentHooks | undefined,
resolveToolPolicy: ToolPolicyResolver,
): AgentHooks {
return {
...hooks,
beforeTool: async (ctx) => {
const result = await hooks?.beforeTool?.(ctx);
if (result?.stop || result?.skip) {
return result;
}
const policy = resolveToolPolicy(ctx.toolCall.toolName);
return {
...result,
policy: {
...result?.policy,
autoApprove: policy.autoApprove,
},
};
},
};
}
export function createInteractiveSessionRuntime(input: {
config: Config;
@@ -58,6 +85,7 @@ export function createInteractiveSessionRuntime(input: {
requestToolApproval: (
request: ToolApprovalRequest,
) => Promise<ToolApprovalResult>;
resolveToolPolicy: ToolPolicyResolver;
askQuestionRef: AskQuestionRef;
resolveMistakeLimitDecision: Config["onConsecutiveMistakeLimitReached"];
switchToActModeTool: NonNullable<Config["extraTools"]>[number];
@@ -152,10 +180,14 @@ export function createInteractiveSessionRuntime(input: {
if (!runtimeHooks) {
throw new Error("interactive runtime hooks are unavailable");
}
const hooks = withInteractiveApprovalPolicyHook(
runtimeHooks.hooks,
input.resolveToolPolicy,
);
return buildInteractiveSessionConfig({
config: input.config,
chatCommandState: input.chatCommandState,
runtimeHooks,
runtimeHooks: { hooks },
onTeamEvent: input.onTeamEvent,
resolveMistakeLimitDecision: input.resolveMistakeLimitDecision,
});
+2
View File
@@ -121,6 +121,7 @@ export async function runInteractive(
autoApproveAllRef,
setInteractiveAutoApprove,
requestToolApproval,
resolveToolPolicy,
tuiToolApprover,
tuiAskQuestion,
} = createInteractiveApprovalController(config);
@@ -160,6 +161,7 @@ export async function runInteractive(
resumeSessionId,
chatCommandState,
requestToolApproval,
resolveToolPolicy,
askQuestionRef: tuiAskQuestion,
resolveMistakeLimitDecision,
switchToActModeTool,
+38 -3
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
applyInteractiveAutoApproveOverride,
cloneToolPolicies,
resolveInteractiveAutoApprovePolicy,
} from "./tool-policies";
describe("tool policy helpers", () => {
@@ -53,9 +54,9 @@ describe("tool policy helpers", () => {
});
});
it("restores the baseline policies when toggled back on", () => {
it("forces all baseline policies to auto-approve when toggled back on", () => {
const baseline = {
"*": { autoApprove: true },
"*": { autoApprove: false },
run_commands: { autoApprove: true, enabled: true },
editor: { autoApprove: false, enabled: true },
};
@@ -72,6 +73,40 @@ describe("tool policy helpers", () => {
enabled: true,
});
expect(target).toEqual(baseline);
expect(target).toEqual({
"*": { autoApprove: true },
run_commands: { autoApprove: true, enabled: true },
editor: { autoApprove: true, enabled: true },
});
});
it("resolves live per-tool policies from the interactive auto-approve state", () => {
const baseline = {
"*": { autoApprove: false },
read_files: { enabled: true },
editor: { autoApprove: false, enabled: true },
};
expect(
resolveInteractiveAutoApprovePolicy({
toolName: "editor",
baselinePolicies: baseline,
enabled: true,
}),
).toEqual({ autoApprove: true, enabled: true });
expect(
resolveInteractiveAutoApprovePolicy({
toolName: "run_commands",
baselinePolicies: baseline,
enabled: false,
}),
).toEqual({ autoApprove: false });
expect(
resolveInteractiveAutoApprovePolicy({
toolName: "read_files",
baselinePolicies: baseline,
enabled: false,
}),
).toEqual({ autoApprove: true, enabled: true });
});
});
+35 -7
View File
@@ -27,21 +27,51 @@ export function cloneToolPolicies(
);
}
export function resolveInteractiveAutoApprovePolicy(input: {
toolName: string;
baselinePolicies: Record<string, ToolPolicy>;
enabled: boolean;
}): ToolPolicy {
const toolPolicy = input.baselinePolicies[input.toolName] ?? {};
const baselinePolicy = {
...(input.baselinePolicies["*"] ?? {}),
...toolPolicy,
};
return {
...baselinePolicy,
autoApprove: input.enabled
? true
: SAFE_AUTO_APPROVE_TOOLS.has(input.toolName)
? (toolPolicy.autoApprove ?? true)
: false,
};
}
export function applyInteractiveAutoApproveOverride(input: {
targetPolicies: Record<string, ToolPolicy>;
baselinePolicies: Record<string, ToolPolicy>;
enabled: boolean;
}): void {
const nextPolicies: Record<string, ToolPolicy> = input.enabled
? cloneToolPolicies(input.baselinePolicies)
? Object.fromEntries(
Object.entries(input.baselinePolicies).map(([name, policy]) => [
name,
{
...policy,
autoApprove: true,
},
]),
)
: Object.fromEntries(
Object.entries(input.baselinePolicies).map(([name, policy]) => [
name,
{
...policy,
autoApprove: SAFE_AUTO_APPROVE_TOOLS.has(name)
? (policy.autoApprove ?? true)
: false,
autoApprove: resolveInteractiveAutoApprovePolicy({
toolName: name,
baselinePolicies: input.baselinePolicies,
enabled: false,
}).autoApprove,
},
]),
);
@@ -53,9 +83,7 @@ export function applyInteractiveAutoApproveOverride(input: {
}
const globalPolicy = clonePolicy(nextPolicies["*"]);
globalPolicy.autoApprove = input.enabled
? (input.baselinePolicies["*"]?.autoApprove ?? true)
: false;
globalPolicy.autoApprove = input.enabled;
nextPolicies["*"] = globalPolicy;
for (const key of Object.keys(input.targetPolicies)) {
@@ -9,6 +9,10 @@ import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import "opentui-spinner/react";
import { palette } from "../../palette";
import {
type KnownModels,
resolveModelDisplayName,
} from "./model-display-name";
export interface ClineModelPickerItem {
kind: "model";
@@ -30,23 +34,6 @@ function tagColor(tag: string): string {
return "cyan";
}
function resolveDisplayName(
modelId: string,
knownModels?: Record<string, unknown>,
): string {
if (knownModels) {
const candidates = [modelId, modelId.split("/").pop()];
for (const key of candidates) {
if (!key) continue;
const hit = knownModels[key] as { name?: string } | undefined;
if (hit?.name) return hit.name;
}
}
return modelId.includes("/")
? (modelId.split("/").pop() ?? modelId)
: modelId;
}
export function useClineRecommendedModels() {
const [data, setData] = useState<ClineRecommendedModelsData | null>(null);
const [loading, setLoading] = useState(true);
@@ -86,7 +73,7 @@ export function ClineModelPicker(props: {
entries: ClineModelPickerEntry[];
selected: number;
loading?: boolean;
knownModels?: Record<string, unknown>;
knownModels?: KnownModels;
currentModelId?: string;
}) {
const { entries, selected, loading, knownModels, currentModelId } = props;
@@ -126,7 +113,11 @@ export function ClineModelPicker(props: {
}
const tags = entry.model.tags;
const name = resolveDisplayName(entry.model.id, knownModels);
const name = resolveModelDisplayName(
entry.model.id,
knownModels,
entry.model.name,
);
const isCurrent = currentModelId === entry.model.id;
rows.push(
<box
@@ -4,6 +4,10 @@ import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { palette } from "../../palette";
import type { ClineModelPickerEntry } from "./cline-model-picker";
import {
type KnownModels,
resolveModelDisplayName,
} from "./model-display-name";
import { CHANGE_PROVIDER_ACTION } from "./model-selector";
import { ProviderRow } from "./provider-row";
@@ -20,28 +24,11 @@ function tagColor(tag: string): string {
return "cyan";
}
function resolveDisplayName(
modelId: string,
knownModels?: Record<string, unknown>,
): string {
if (knownModels) {
const candidates = [modelId, modelId.split("/").pop()];
for (const key of candidates) {
if (!key) continue;
const hit = knownModels[key] as { name?: string } | undefined;
if (hit?.name) return hit.name;
}
}
return modelId.includes("/")
? (modelId.split("/").pop() ?? modelId)
: modelId;
}
export function ClineModelSelectorContent(
props: ChoiceContext<string> & {
currentModel: string;
currentProviderName: string;
knownModels?: Record<string, unknown>;
knownModels?: KnownModels;
entries: ClineModelPickerEntry[];
},
) {
@@ -85,7 +72,11 @@ export function ClineModelSelectorContent(
rows.push({
key: entry.model.id,
kind: "model",
label: resolveDisplayName(entry.model.id, knownModels),
label: resolveModelDisplayName(
entry.model.id,
knownModels,
entry.model.name,
),
tags: entry.model.tags,
isCurrent: currentModel === entry.model.id,
entryIndex: i,
@@ -218,7 +209,7 @@ export function ClineModelSelectorDialogContent(
props: ChoiceContext<string> & {
currentModel: string;
currentProviderName: string;
knownModels?: Record<string, unknown>;
knownModels?: KnownModels;
loadEntries: () => Promise<ClineModelPickerEntry[]>;
},
) {
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { resolveModelDisplayName } from "./model-display-name";
describe("resolveModelDisplayName", () => {
it("resolves display names by exact model id", () => {
expect(
resolveModelDisplayName("claude-sonnet", {
"claude-sonnet": { name: "Claude Sonnet" },
}),
).toBe("Claude Sonnet");
});
it("resolves display names by model slug when provider prefixes differ", () => {
expect(
resolveModelDisplayName("zai/glm-5.2", {
"z-ai/glm-5.2": { id: "z-ai/glm-5.2", name: "GLM-5.2" },
}),
).toBe("GLM-5.2");
});
it("prefers exact model id matches over slug matches", () => {
expect(
resolveModelDisplayName("zai/glm-5.2", {
"z-ai/glm-5.2": { id: "z-ai/glm-5.2", name: "OpenRouter GLM" },
"zai/glm-5.2": { id: "zai/glm-5.2", name: "Vercel GLM" },
}),
).toBe("Vercel GLM");
});
it("uses the fallback name when no catalog entry matches", () => {
expect(resolveModelDisplayName("zai/glm-5.2", {}, "GLM 5.2")).toBe(
"GLM 5.2",
);
});
it("falls back to the model slug without known models", () => {
expect(resolveModelDisplayName("zai/glm-5.2")).toBe("glm-5.2");
});
});
@@ -0,0 +1,41 @@
export interface KnownModelInfo {
id?: string;
name?: string;
capabilities?: string[];
maxInputTokens?: number;
contextWindow?: number;
}
export type KnownModels = Record<string, KnownModelInfo>;
export function resolveKnownModelInfo(
modelId: string,
knownModels?: KnownModels,
): KnownModelInfo | undefined {
if (!knownModels) return undefined;
const exactMatch = knownModels[modelId];
if (exactMatch !== undefined) return exactMatch;
const modelSlug = modelId.split("/").pop();
return Object.entries(knownModels).find(([key, model]) => {
return (
key.split("/").pop() === modelSlug ||
model.id?.split("/").pop() === modelSlug
);
})?.[1];
}
export function resolveModelDisplayName(
modelId: string,
knownModels?: KnownModels,
fallbackName?: string,
): string {
const modelInfo = resolveKnownModelInfo(modelId, knownModels);
if (modelInfo?.name) return modelInfo.name;
if (fallbackName) return fallbackName;
return modelId.includes("/")
? (modelId.split("/").pop() ?? modelId)
: modelId;
}
@@ -3,6 +3,8 @@ import {
createContextBar,
formatStatusBarUsageText,
resolveContextBarFilledForeground,
resolveModelDisplayName,
resolveModelMaxInputTokens,
} from "./status-bar";
vi.mock("@opentui/react", () => ({
@@ -70,3 +72,31 @@ describe("formatStatusBarUsageText", () => {
).toBe("(12,345)");
});
});
describe("model display helpers", () => {
it("resolves status-bar model names by model slug when provider prefixes differ", () => {
expect(
resolveModelDisplayName({
modelId: "zai/glm-5.2",
knownModels: {
"z-ai/glm-5.2": { id: "z-ai/glm-5.2", name: "GLM-5.2" },
},
}),
).toBe("GLM-5.2");
});
it("resolves max input tokens by model slug when provider prefixes differ", () => {
expect(
resolveModelMaxInputTokens({
modelId: "zai/glm-5.2",
knownModels: {
"z-ai/glm-5.2": {
id: "z-ai/glm-5.2",
name: "GLM-5.2",
maxInputTokens: 262_144,
},
},
}),
).toBe(262_144);
});
});
+9 -25
View File
@@ -11,6 +11,11 @@ import {
getSuccessColor,
} from "../palette";
import { HOME_VIEW_MAX_WIDTH } from "../types";
import {
type KnownModels,
resolveModelDisplayName as resolveKnownModelDisplayName,
resolveKnownModelInfo,
} from "./model-selector/model-display-name";
export function createContextBar(
used: number,
@@ -56,31 +61,13 @@ export function formatStatusBarUsageText(input: {
return `${tokens} ${formatCost(input.totalCost)}`;
}
// knownModels keys are bare IDs ("claude-sonnet-4-6") but config.modelId
// may include a provider prefix ("anthropic/claude-sonnet-4-6"), so we
// try the full ID first, then strip the prefix and retry.
function lookupModelInfo(
modelId: string,
knownModels?: Record<string, unknown>,
): { name?: string } | undefined {
if (!knownModels) return undefined;
const candidates = [modelId, modelId.split("/").pop()];
for (const key of candidates) {
if (!key) continue;
const hit = knownModels[key] as { name?: string } | undefined;
if (hit) return hit;
}
return undefined;
}
export function resolveModelDisplayName(config: {
modelId: string;
knownModels?: Record<string, unknown>;
knownModels?: KnownModels;
thinking?: boolean;
reasoningEffort?: string;
}): string {
const info = lookupModelInfo(config.modelId, config.knownModels);
const name = info?.name ?? config.modelId.split("/").pop() ?? config.modelId;
const name = resolveKnownModelDisplayName(config.modelId, config.knownModels);
if (config.thinking && config.reasoningEffort) {
return `${name} (${config.reasoningEffort})`;
}
@@ -89,12 +76,9 @@ export function resolveModelDisplayName(config: {
export function resolveModelMaxInputTokens(config: {
modelId: string;
knownModels?: Record<string, unknown>;
knownModels?: KnownModels;
}): number | undefined {
const info = (lookupModelInfo(config.modelId, config.knownModels) ?? {}) as {
maxInputTokens?: number;
contextWindow?: number;
};
const info = resolveKnownModelInfo(config.modelId, config.knownModels) ?? {};
if (typeof info.maxInputTokens === "number" && info.maxInputTokens > 0) {
return info.maxInputTokens;
}
@@ -106,9 +106,9 @@ export function SessionProvider(props: {
const [uiMode, setUiMode] = useState<AgentMode>(
config.mode === "plan" ? "plan" : "act",
);
const [autoApproveAll, _setAutoApproveAll] = useState(
config.toolPolicies["*"]?.autoApprove !== false,
);
const initialAutoApproveAll = config.toolPolicies["*"]?.autoApprove !== false;
const autoApproveAllRef = useRef(initialAutoApproveAll);
const [autoApproveAll, _setAutoApproveAll] = useState(initialAutoApproveAll);
const [compactionMode, _setCompactionMode] = useState<CliCompactionMode>(() =>
getCliCompactionMode(config),
);
@@ -192,11 +192,10 @@ export function SessionProvider(props: {
}, []);
const toggleAutoApprove = useCallback(() => {
_setAutoApproveAll((prev) => {
const next = !prev;
onAutoApproveChange(next);
return next;
});
const next = !autoApproveAllRef.current;
autoApproveAllRef.current = next;
onAutoApproveChange(next);
_setAutoApproveAll(next);
}, [onAutoApproveChange]);
const setCompactionMode = useCallback(
@@ -299,7 +299,7 @@ export function useModelSelector(opts: {
{...ctx}
currentModel={config.modelId}
currentProviderName={providerDisplayName}
knownModels={config.knownModels as Record<string, unknown>}
knownModels={config.knownModels}
loadEntries={async () =>
buildClineModelEntries(await fetchClineRecommendedModels())
}
@@ -5,6 +5,7 @@ const hoisted = vi.hoisted(() => ({
startClineDeviceAuth: vi.fn(),
completeClineDeviceAuth: vi.fn(),
saveLocalProviderOAuthCredentials: vi.fn(),
identifyFeatureFlagsAccount: vi.fn(async () => {}),
openMock: vi.fn(() => Promise.resolve()),
}));
@@ -22,6 +23,10 @@ vi.mock("@cline/shared", () => ({
vi.mock("open", () => ({ default: hoisted.openMock }));
vi.mock("../../../utils/feature-flags", () => ({
identifyFeatureFlagsAccount: hoisted.identifyFeatureFlagsAccount,
}));
import { runDeviceCodeAuthFlow, runOAuthAuthFlow } from "./auth";
// Minimal stand-in for a telemetry service. The auth helpers must forward this
@@ -45,6 +50,8 @@ describe("onboarding auth telemetry forwarding", () => {
hoisted.startClineDeviceAuth.mockReset();
hoisted.completeClineDeviceAuth.mockReset();
hoisted.saveLocalProviderOAuthCredentials.mockReset();
hoisted.identifyFeatureFlagsAccount.mockReset();
hoisted.identifyFeatureFlagsAccount.mockResolvedValue(undefined);
hoisted.openMock.mockReset();
hoisted.openMock.mockResolvedValue(undefined);
});
@@ -85,6 +92,7 @@ describe("onboarding auth telemetry forwarding", () => {
// Identity, not deep-equal — we are validating the exact reference flows
// through so opt-out / common metadata stays consistent.
expect(telemetryArg).toBe(fakeTelemetry);
expect(hoisted.identifyFeatureFlagsAccount).not.toHaveBeenCalled();
});
it("does not pass telemetry when none is provided (back-compat)", () => {
@@ -121,6 +129,8 @@ describe("onboarding auth telemetry forwarding", () => {
access: "a",
refresh: "r",
expires: 0,
accountId: "acct-1",
email: "user@example.com",
});
runDeviceCodeAuthFlow({
@@ -146,6 +156,10 @@ describe("onboarding auth telemetry forwarding", () => {
// emitted by completeClineDeviceAuth, so passing telemetry to the start
// helper would double-emit the event.
expect(hoisted.startClineDeviceAuth).toHaveBeenCalledWith();
expect(hoisted.identifyFeatureFlagsAccount).toHaveBeenCalledWith({
id: "acct-1",
email: "user@example.com",
});
expect(hoisted.openMock).toHaveBeenCalledWith(
"https://verify?user_code=uc",
{ wait: false },
+17
View File
@@ -9,6 +9,7 @@ import {
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import open from "open";
import { identifyFeatureFlagsAccount } from "../../../utils/feature-flags";
export type OnboardingOAuthProviderId = string;
@@ -18,6 +19,10 @@ export function isOnboardingOAuthProviderId(
return isOAuthProvider(providerId);
}
function isClineAccountOAuthProvider(providerId: string): boolean {
return providerId === "cline" || providerId === "cline-pass";
}
export function runOAuthAuthFlow(input: {
providerId: OnboardingOAuthProviderId;
providerSettingsManager: ProviderSettingsManager;
@@ -56,6 +61,12 @@ export function runOAuthAuthFlow(input: {
existing,
credentials,
);
if (isClineAccountOAuthProvider(input.providerId)) {
void identifyFeatureFlagsAccount({
id: credentials.accountId,
email: credentials.email,
}).catch(() => {});
}
input.onComplete(input.providerId);
})
.catch((err: unknown) => {
@@ -118,6 +129,12 @@ export function runDeviceCodeAuthFlow(input: {
existing,
credentials,
);
if (isClineAccountOAuthProvider(input.providerId)) {
void identifyFeatureFlagsAccount({
id: credentials.accountId,
email: credentials.email,
}).catch(() => {});
}
input.onComplete(input.providerId);
})
.catch((err: unknown) => {
@@ -25,6 +25,7 @@ import {
type ClineModelPickerEntry,
useClineRecommendedModels,
} from "../../components/model-selector/cline-model-picker";
import type { KnownModels } from "../../components/model-selector/model-display-name";
import {
type SearchableItem,
useSearchableList,
@@ -194,7 +195,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
Set<string>
>(new Set());
const [clineKnownModels, setClineKnownModels] = useState<
Record<string, unknown> | undefined
KnownModels | undefined
>(undefined);
useEffect(() => {
@@ -8,6 +8,7 @@ import {
ClineModelPicker,
type ClineModelPickerEntry,
} from "../../components/model-selector/cline-model-picker";
import type { KnownModels } from "../../components/model-selector/model-display-name";
import {
type SearchableItem,
SearchableList,
@@ -433,7 +434,7 @@ export function OnboardingProviderPickerScreen(props: {
export function OnboardingClineModelScreen(props: {
clineEntries: ClineModelPickerEntry[];
clineKnownModels: Record<string, unknown> | undefined;
clineKnownModels: KnownModels | undefined;
clineModelSelected: number;
compact: boolean;
contentWidth: number;
+12 -5
View File
@@ -87,22 +87,29 @@ export async function disposeCliFeatureFlagsService(): Promise<void> {
await current.dispose();
}
export async function identifyFeatureFlagsAccount(
account: { id?: string; email?: string },
logger?: BasicLogger,
): Promise<void> {
export function setCliFeatureFlagsAccountContext(account: {
id?: string;
email?: string;
}): void {
const accountId = account.id?.trim();
cliFeatureFlagsContext = {
...cliFeatureFlagsContext,
...(accountId ? { distinctId: accountId, userId: accountId } : {}),
...(account.email?.trim() ? { email: account.email.trim() } : {}),
};
cliFeatureFlagsService?.setContext(getCliFeatureFlagsContext());
}
export async function identifyFeatureFlagsAccount(
account: { id?: string; email?: string },
logger?: BasicLogger,
): Promise<void> {
setCliFeatureFlagsAccountContext(account);
if (!cliFeatureFlagsService) {
return;
}
cliFeatureFlagsService.setContext(getCliFeatureFlagsContext());
try {
await cliFeatureFlagsService.poll();
} catch (error) {
@@ -3,6 +3,7 @@ import {
Puzzle,
Search,
Server,
Star,
Trash2,
Zap,
} from "lucide-react";
@@ -26,6 +27,7 @@ import {
type MarketplacePrimitiveType,
type MarketplaceTag,
} from "@/lib/marketplace";
import { cn } from "@/lib/utils";
import { CommandBadge, PageFrame, PageHeader } from "./page-layout";
type EntryActionState =
@@ -114,6 +116,13 @@ function entryKey(entry: Pick<MarketplaceEntry, "id" | "type">): string {
return `${entry.type}:${entry.id}`;
}
function compareFeaturedEntries(
left: MarketplaceEntry,
right: MarketplaceEntry,
): number {
return Number(Boolean(right.featured)) - Number(Boolean(left.featured));
}
function normalizeMatchValue(value: string): string {
return value.trim().toLowerCase();
}
@@ -257,6 +266,8 @@ function MarketplaceEntryCard({
onToggleExpanded,
onUninstall,
matchedLocalItems = [],
showFeatured = true,
showTags = true,
sourceLabel,
tagLabels,
}: {
@@ -269,6 +280,8 @@ function MarketplaceEntryCard({
onToggleExpanded: (entry: MarketplaceEntry) => void;
onUninstall: (entry: MarketplaceEntry) => void;
matchedLocalItems?: MarketplaceLocalInstalledItem[];
showFeatured?: boolean;
showTags?: boolean;
sourceLabel?: string;
tagLabels: Map<string, string>;
}) {
@@ -299,15 +312,63 @@ function MarketplaceEntryCard({
: installed
? "Uninstall"
: "Install";
const statusMessage = inlineMessage ? (
<output
className={cn(
"text-xs",
actionState?.status === "failed"
? "text-destructive"
: "text-muted-foreground",
)}
>
{inlineMessage}
</output>
) : setupNeeded ? (
<span className="text-xs text-amber-700 dark:text-amber-300">
Requires setup after install
</span>
) : null;
const actionButton = (
<Button
disabled={!installedStatusReady || busy}
onClick={handleActionClick}
size="sm"
type="button"
variant={installed ? "destructive" : "default"}
>
{busy || !installedStatusReady ? <Spinner /> : null}
{installed && !busy ? <Trash2 className="size-4" /> : null}
{actionLabel}
</Button>
);
const content = (
<>
{!installed ? (
<div
className="absolute top-4 right-4"
data-marketplace-entry-interactive
>
{actionButton}
</div>
) : null}
<div className="min-w-0">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div
className={cn(
"flex min-w-0 flex-1 items-center gap-2",
!installed && "pr-24",
)}
>
<EntryIcon className="h-4 w-4 shrink-0 text-primary" />
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
<h2 className="min-w-0 truncate text-sm font-semibold text-foreground">
{entry.name}
</h2>
{showFeatured && entry.featured ? (
<Badge className="border border-violet-500/20 bg-violet-500/10 text-violet-700 dark:text-violet-300">
<Star className="fill-current" />
Featured
</Badge>
) : null}
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{sourceLabel ? (
@@ -349,23 +410,34 @@ function MarketplaceEntryCard({
) : null}
</div>
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
{entry.description}
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-stretch sm:justify-between">
<div className="grid min-w-0 flex-1 gap-2">
{showTags && entry.tags.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{entry.tags.slice(0, 5).map((tag) => (
<Badge
key={tag}
variant="outline"
className="max-w-full text-muted-foreground"
>
<span className="truncate">{tagLabels.get(tag) ?? tag}</span>
</Badge>
))}
</div>
) : null}
{entry.tags.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{entry.tags.slice(0, 5).map((tag) => (
<Badge
key={tag}
variant="outline"
className="max-w-full text-muted-foreground"
>
<span className="truncate">{tagLabels.get(tag) ?? tag}</span>
</Badge>
))}
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
{entry.description}
</p>
{statusMessage}
</div>
) : null}
{installed ? (
<div className="flex shrink-0 flex-col items-start gap-1 sm:items-end sm:justify-end">
{actionButton}
</div>
) : null}
</div>
{matchedLocalItems.some((item) => item.renderMatchedDetails) ? (
<div className="grid gap-2" data-marketplace-entry-details>
@@ -377,36 +449,6 @@ function MarketplaceEntryCard({
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-h-5 text-xs text-muted-foreground">
{inlineMessage ? (
<output
className={
actionState?.status === "failed"
? "text-destructive"
: "text-muted-foreground"
}
>
{inlineMessage}
</output>
) : setupNeeded ? (
<span className="text-amber-700 dark:text-amber-300">
Requires setup after install
</span>
) : null}
</div>
<Button
disabled={!installedStatusReady || busy}
onClick={handleActionClick}
type="button"
variant={installed ? "destructive" : "default"}
>
{busy || !installedStatusReady ? <Spinner /> : null}
{installed && !busy ? <Trash2 className="size-4" /> : null}
{actionLabel}
</Button>
</div>
{expanded && hasExpandableDetails ? (
<EntryDetails actionState={actionState} entry={entry} />
) : null}
@@ -415,7 +457,7 @@ function MarketplaceEntryCard({
if (!hasExpandableDetails) {
return (
<div className="grid gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
<div className="relative grid gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
{content}
</div>
);
@@ -426,7 +468,7 @@ function MarketplaceEntryCard({
<div
aria-expanded={expanded}
aria-label={`${expanded ? "Collapse" : "Expand"} ${entry.name}`}
className="grid cursor-pointer gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
className="relative grid cursor-pointer gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
onClick={(event) => {
if (
event.target instanceof HTMLElement &&
@@ -487,6 +529,7 @@ function MarketplaceSection({
emptyMessage,
entries,
expandedEntryKey,
headerContent,
installedEntryKeys,
installedStatusReady,
localOnlyInstalledItems = [],
@@ -494,6 +537,8 @@ function MarketplaceSection({
onInstall,
onToggleExpanded,
onUninstall,
showFeaturedBadges = true,
showEntryTags = true,
sourceLabel,
tagLabels,
title,
@@ -502,6 +547,7 @@ function MarketplaceSection({
emptyMessage: string;
entries: MarketplaceEntry[];
expandedEntryKey: string | null;
headerContent?: ReactNode;
installedEntryKeys: Set<string>;
installedStatusReady: boolean;
localOnlyInstalledItems?: MarketplaceLocalInstalledItem[];
@@ -509,6 +555,8 @@ function MarketplaceSection({
onInstall: (entry: MarketplaceEntry) => void;
onToggleExpanded: (entry: MarketplaceEntry) => void;
onUninstall: (entry: MarketplaceEntry) => void;
showFeaturedBadges?: boolean;
showEntryTags?: boolean;
sourceLabel?: string;
tagLabels: Map<string, string>;
title: string;
@@ -520,6 +568,7 @@ function MarketplaceSection({
<h2 className="text-base font-semibold text-foreground">{title}</h2>
<span className="text-sm text-muted-foreground">{totalCount}</span>
</div>
{headerContent}
{totalCount > 0 ? (
<div className="grid gap-3">
{localOnlyInstalledItems.map((item) => item.render())}
@@ -537,6 +586,8 @@ function MarketplaceSection({
onToggleExpanded={onToggleExpanded}
onUninstall={onUninstall}
matchedLocalItems={matchedLocalItemsByEntryKey?.get(key) ?? []}
showFeatured={showFeaturedBadges}
showTags={showEntryTags}
sourceLabel={sourceLabel}
tagLabels={tagLabels}
/>
@@ -626,19 +677,48 @@ export function MarketplaceView({
);
const primitiveEntries = useMemo(
() => catalog?.entries.filter((entry) => entry.type === primitive) ?? [],
() =>
(catalog?.entries.filter((entry) => entry.type === primitive) ?? []).sort(
compareFeaturedEntries,
),
[catalog?.entries, primitive],
);
const queryFilteredEntries = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return primitiveEntries.filter((entry) => {
return (
normalizedQuery.length === 0 ||
entrySearchText(entry, tagLabels).includes(normalizedQuery)
);
});
}, [primitiveEntries, query, tagLabels]);
const installedEntries = useMemo(
() =>
queryFilteredEntries.filter((entry) =>
installedEntryKeys.has(entryKey(entry)),
),
[queryFilteredEntries, installedEntryKeys],
);
const marketplaceEntriesBeforeTag = useMemo(
() =>
queryFilteredEntries.filter(
(entry) => !installedEntryKeys.has(entryKey(entry)),
),
[queryFilteredEntries, installedEntryKeys],
);
const tagCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const entry of primitiveEntries) {
for (const entry of marketplaceEntriesBeforeTag) {
for (const tag of entry.tags) {
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
}
return counts;
}, [primitiveEntries]);
}, [marketplaceEntriesBeforeTag]);
const primitiveTags = useMemo(
() =>
@@ -646,26 +726,20 @@ export function MarketplaceView({
[catalog?.tags, tagCounts],
);
const filteredEntries = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return primitiveEntries.filter((entry) => {
const matchesTag = !selectedTag || entry.tags.includes(selectedTag);
const matchesQuery =
normalizedQuery.length === 0 ||
entrySearchText(entry, tagLabels).includes(normalizedQuery);
return matchesTag && matchesQuery;
});
}, [primitiveEntries, query, selectedTag, tagLabels]);
const catalogEntries = useMemo(
() =>
marketplaceEntriesBeforeTag.filter(
(entry) => !selectedTag || entry.tags.includes(selectedTag),
),
[marketplaceEntriesBeforeTag, selectedTag],
);
const matchedLocalItemsByEntryKey = useMemo(() => {
const matched = new Map<string, MarketplaceLocalInstalledItem[]>();
for (const item of installedItems ?? []) {
for (const entry of filteredEntries) {
for (const entry of installedEntries) {
const key = entryKey(entry);
if (
!installedEntryKeys.has(key) ||
!entryMatchesLocalItem(entry, item)
) {
if (!entryMatchesLocalItem(entry, item)) {
continue;
}
const items = matched.get(key) ?? [];
@@ -674,49 +748,78 @@ export function MarketplaceView({
}
}
return matched;
}, [filteredEntries, installedEntryKeys, installedItems]);
}, [installedEntries, installedItems]);
const matchedLocalItemKeys = useMemo(
() =>
new Set(
[...matchedLocalItemsByEntryKey.values()].flatMap((items) =>
items.map((item) => item.key),
),
),
[matchedLocalItemsByEntryKey],
);
const matchedLocalItemKeys = useMemo(() => {
const matched = new Set<string>();
const installedMarketplaceEntries = primitiveEntries.filter((entry) =>
installedEntryKeys.has(entryKey(entry)),
);
for (const item of installedItems ?? []) {
if (
installedMarketplaceEntries.some((entry) =>
entryMatchesLocalItem(entry, item),
)
) {
matched.add(item.key);
}
}
return matched;
}, [installedEntryKeys, installedItems, primitiveEntries]);
const localOnlyInstalledItems = useMemo(
() =>
(installedItems ?? []).filter(
(item) => !matchedLocalItemKeys.has(item.key),
),
[installedItems, matchedLocalItemKeys],
);
const localOnlyInstalledItems = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return (installedItems ?? []).filter((item) => {
if (matchedLocalItemKeys.has(item.key)) {
return false;
}
return (
normalizedQuery.length === 0 ||
item.matchValues
.map(normalizeMatchValue)
.some((value) => value.includes(normalizedQuery))
);
});
}, [installedItems, matchedLocalItemKeys, query]);
const installedEntries = useMemo(
() =>
filteredEntries.filter((entry) =>
installedEntryKeys.has(entryKey(entry)),
),
[filteredEntries, installedEntryKeys],
);
const catalogEntries = useMemo(
() =>
filteredEntries.filter(
(entry) => !installedEntryKeys.has(entryKey(entry)),
),
[filteredEntries, installedEntryKeys],
);
const activeFilters = query.trim().length > 0 || selectedTag !== null;
const installedStatusReady = installedStatusState === "ready";
const clearFilters = () => {
setQuery("");
setSelectedTag(null);
};
const marketplaceTagFilters =
primitiveTags.length > 0 ? (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex gap-2 overflow-x-auto pb-1">
{primitiveTags.map((tag) => (
<TagButton
active={selectedTag === tag.id}
count={tagCounts.get(tag.id) ?? 0}
key={tag.id}
onClick={() =>
setSelectedTag((current) =>
current === tag.id ? null : tag.id,
)
}
tag={tag}
/>
))}
</div>
<div className="flex min-h-8 shrink-0 items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{catalogEntries.length}
</span>
<span>{catalogEntries.length === 1 ? "result" : "results"}</span>
{selectedTag ? (
<Button
onClick={() => setSelectedTag(null)}
size="sm"
type="button"
variant="ghost"
>
Clear filters
</Button>
) : null}
</div>
</div>
) : null;
const setEntryState = (entry: MarketplaceEntry, state: EntryActionState) => {
const key = entryKey(entry);
@@ -863,43 +966,7 @@ export function MarketplaceView({
value={query}
/>
</div>
<div className="flex min-h-8 items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{filteredEntries.length}
</span>
<span>
{filteredEntries.length === 1 ? "result" : "results"}
</span>
{activeFilters ? (
<Button
onClick={clearFilters}
size="sm"
type="button"
variant="ghost"
>
Clear filters
</Button>
) : null}
</div>
</div>
{primitiveTags.length > 0 ? (
<div className="flex gap-2 overflow-x-auto pb-1">
{primitiveTags.map((tag) => (
<TagButton
active={selectedTag === tag.id}
count={tagCounts.get(tag.id) ?? 0}
key={tag.id}
onClick={() =>
setSelectedTag((current) =>
current === tag.id ? null : tag.id,
)
}
tag={tag}
/>
))}
</div>
) : null}
</div>
<MarketplaceSection
@@ -914,6 +981,8 @@ export function MarketplaceView({
onInstall={installEntry}
onToggleExpanded={toggleExpanded}
onUninstall={uninstallEntry}
showFeaturedBadges={false}
showEntryTags={false}
sourceLabel="Marketplace"
tagLabels={tagLabels}
title="Installed"
@@ -924,6 +993,7 @@ export function MarketplaceView({
emptyMessage={pageDetails.emptyCatalog}
entries={catalogEntries}
expandedEntryKey={expandedEntryKey}
headerContent={marketplaceTagFilters}
installedEntryKeys={installedEntryKeys}
installedStatusReady={installedStatusReady}
onInstall={installEntry}
@@ -39,10 +39,10 @@ export type CustomizationSection =
const sectionDescriptions: Record<CustomizationSection, string> = {
Rules: "Review project and global rule files that shape Cline behavior.",
Hooks: "Inspect hook configuration and recent execution status.",
MCP: "Manage installed MCP servers and add new servers from the catalog.",
Skills: "Manage installed skills and add new skills from the catalog.",
MCP: "Manage installed MCP servers and add new servers from the marketplace.",
Skills: "Manage installed skills and add new skills from the marketplace.",
Agents: "Review configured agents discovered from local settings.",
Plugins: "Manage installed plugins and add new plugins from the catalog.",
Plugins: "Manage installed plugins and add new plugins from the marketplace.",
Tools: "Inspect built-in tools and tools contributed by plugins.",
};
@@ -17,6 +17,7 @@ export type MarketplaceEntry = {
id: string;
type: MarketplacePrimitiveType;
name: string;
featured?: boolean;
tagline: string;
description: string;
tags: string[];
@@ -100,7 +101,7 @@ export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`Failed to fetch marketplace catalog: ${response.status}`);
throw new Error(`Failed to fetch marketplace: ${response.status}`);
}
const data = await response.json();
const baseUrl = typeof data?.baseUrl === "string" ? data.baseUrl : undefined;
@@ -152,6 +153,10 @@ export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
id: candidate.id,
type: candidate.type,
name: candidate.name,
featured:
typeof candidate.featured === "boolean"
? candidate.featured
: undefined,
tagline: candidate.tagline,
description: candidate.description,
tags: toStringArray(candidate.tags),
-4
View File
@@ -1,7 +1,3 @@
<div align="center"><sub>
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
</sub></div>
# Cline
<div align="center">
<table>
+12 -6
View File
@@ -513,8 +513,6 @@ export class Controller {
try {
await this.authService.handleAuthCallback(customToken, provider ? provider : "google")
const clineProvider: ApiProvider = "cline"
// Get current settings to determine how to update providers
const planActSeparateModelsSetting = this.stateManager.getGlobalSettingsKey("planActSeparateModelsSetting")
@@ -523,19 +521,27 @@ export class Controller {
// Get current API configuration from cache
const currentApiConfiguration = this.stateManager.getApiConfiguration()
// On login we route the user to the managed "cline" provider, but preserve a
// "cline-pass" selection made during onboarding (otherwise it would be clobbered).
// A "cline-pass" provider can only be set when the ext-cline-pass flag is on, so
// non-ClinePass logins are unaffected.
const planProvider: ApiProvider =
currentApiConfiguration.planModeApiProvider === "cline-pass" ? "cline-pass" : "cline"
const actProvider: ApiProvider = currentApiConfiguration.actModeApiProvider === "cline-pass" ? "cline-pass" : "cline"
const updatedConfig = { ...currentApiConfiguration }
if (planActSeparateModelsSetting) {
// Only update the current mode's provider
if (currentMode === "plan") {
updatedConfig.planModeApiProvider = clineProvider
updatedConfig.planModeApiProvider = planProvider
} else {
updatedConfig.actModeApiProvider = clineProvider
updatedConfig.actModeApiProvider = actProvider
}
} else {
// Update both modes to keep them in sync
updatedConfig.planModeApiProvider = clineProvider
updatedConfig.actModeApiProvider = clineProvider
updatedConfig.planModeApiProvider = planProvider
updatedConfig.actModeApiProvider = actProvider
}
// Update the API configuration through cache service
@@ -25,7 +25,7 @@ describe("clearOrganizationForClinePassProviderSelection", () => {
} as unknown as Controller;
}
it("does nothing when Cline Pass is not selected", async () => {
it("does nothing when ClinePass is not selected", async () => {
await clearOrganizationForClinePassProviderSelection(createController(), {
planModeApiProvider: "cline",
actModeApiProvider: "openrouter",
@@ -34,7 +34,7 @@ describe("clearOrganizationForClinePassProviderSelection", () => {
assert.strictEqual(switchAccount.callCount, 0);
});
it("switches to the personal account when Cline Pass is selected", async () => {
it("switches to the personal account when ClinePass is selected", async () => {
await clearOrganizationForClinePassProviderSelection(createController(), {
planModeApiProvider: "cline-pass",
actModeApiProvider: "openrouter",
@@ -48,7 +48,7 @@ describe("refreshClineRecommendedModels", () => {
clinePass: [
{
id: "cline-pass/glm-5",
description: "Remote Cline Pass",
description: "Remote ClinePass",
tags: ["CLINE_PASS"],
},
],
@@ -79,7 +79,7 @@ describe("refreshClineRecommendedModels", () => {
{
id: "cline-pass/glm-5",
name: "cline-pass/glm-5",
description: "Remote Cline Pass",
description: "Remote ClinePass",
tags: ["CLINE_PASS"],
},
],
@@ -114,7 +114,7 @@ describe("refreshClineRecommendedModels", () => {
clinePass: [
{
id: "cline-pass/glm-5",
description: "Remote Cline Pass",
description: "Remote ClinePass",
tags: ["CLINE_PASS"],
},
],
@@ -5,7 +5,7 @@ import type { Controller } from "../index"
export const CLINE_PASS_PROVIDER_ID = "cline-pass"
/**
* Cline Pass always uses the user's personal Cline account balance.
* ClinePass always uses the user's personal Cline account balance.
*
* This is intentionally best-effort: selecting the provider should still be
* saved even if the account switch fails.
@@ -24,6 +24,6 @@ export async function clearOrganizationForClinePassProviderSelection(
try {
await controller.accountService.switchAccount(null)
} catch (error) {
Logger.debug("Failed to switch Cline Pass to personal account", { error })
Logger.debug("Failed to switch ClinePass to personal account", { error })
}
}
@@ -60,7 +60,7 @@ describe("fetchRemoteConfig", () => {
sandbox.restore();
});
it("clears remote config and skips discovery when Cline Pass is selected", async () => {
it("clears remote config and skips discovery when ClinePass is selected", async () => {
const controller = createController({
stateManager: {
getApiConfiguration: sandbox.stub().returns({
@@ -6,7 +6,7 @@
},
{
"value": "cline-pass",
"label": "Cline Pass"
"label": "ClinePass"
},
{
"value": "openai-codex",
@@ -15,7 +15,7 @@ export enum FeatureFlag {
// Rollout flag for Cline provider model sourcing:
// off => OpenRouter model list, on => Cline endpoint model list.
EXTENSION_CLINE_MODELS_ENDPOINT = "extension_cline_models_endpoint",
// Enables Cline Pass provider/model list exposure.
// Enables ClinePass provider/model list exposure.
CLINE_PASS = "ext-cline-pass",
// Use the websocket mode for OpenAI native Responses API format
OPENAI_RESPONSES_WEBSOCKET_MODE = "openai-responses-websocket-mode",
@@ -23,7 +23,7 @@ describe("Provider key mapping", () => {
expect(getProviderModelIdKey("cline", "plan")).to.equal("planModeClineModelId")
})
it("uses separate model keys for Cline Pass", () => {
it("uses separate model keys for ClinePass", () => {
expect(getProviderModelIdKey("cline-pass", "act")).to.equal("actModeClinePassModelId")
expect(getProviderModelIdKey("cline-pass", "plan")).to.equal("planModeClinePassModelId")
})
+9
View File
@@ -4,6 +4,7 @@ import AccountView from "./components/account/AccountView"
import ChatView from "./components/chat/ChatView"
import HistoryView from "./components/history/HistoryView"
import McpView from "./components/mcp/configuration/McpConfigurationView"
import { openClinePassSubscriptionIfPending } from "./components/onboarding/clinePassSubscribe"
import OnboardingView from "./components/onboarding/OnboardingView"
import SettingsView from "./components/settings/SettingsView"
import WorktreesView from "./components/worktrees/WorktreesView"
@@ -56,6 +57,14 @@ const AppContent = () => {
showUpdateAnnouncementModal()
}, [didHydrateState, showWelcome, shouldShowAnnouncement, showAnnouncement, showUpdateAnnouncementModal])
// Open the ClinePass subscription page once auth completes. Lives here (not in OnboardingView)
// because handleAuthCallback unmounts onboarding before the clineUser update arrives.
useEffect(() => {
if (clineUser?.uid) {
openClinePassSubscriptionIfPending(clineUser.appBaseUrl)
}
}, [clineUser?.uid, clineUser?.appBaseUrl])
if (!didHydrateState) {
return null
}
@@ -1,4 +1,4 @@
import type { ModelInfo } from "@shared/api"
import { buildModelInfoNameMap, type ModelInfo, resolveClinePassModelInfo } from "@shared/api"
import type { OnboardingModel, OnboardingModelGroup, OpenRouterModelInfo } from "@shared/proto/index.cline"
import { AlertCircleIcon, CircleCheckIcon, CircleIcon, ListIcon, LoaderCircleIcon, ZapIcon } from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
@@ -7,9 +7,12 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Item, ItemContent, ItemDescription, ItemHeader, ItemMedia, ItemTitle } from "@/components/ui/item"
import { CLINE_PASS_FEATURE_FLAG } from "@/constants/featureFlags"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useHasFeatureFlag } from "@/hooks/useFeatureFlag"
import { cn } from "@/lib/utils"
import { AccountServiceClient, StateServiceClient } from "@/services/grpc-client"
import { setPendingClinePassSubscribe } from "./clinePassSubscribe"
import ApiConfigurationSection from "../settings/sections/ApiConfigurationSection"
import { useApiConfigurationHandlers } from "../settings/utils/useApiConfigurationHandlers"
import WelcomeView from "../welcome/WelcomeView"
@@ -20,11 +23,11 @@ import {
getSpeedLabel,
type OnboardingModelsByGroup,
} from "./data-models"
import { NEW_USER_TYPE, STEP_CONFIG, USER_TYPE_SELECTIONS } from "./data-steps"
import { getUserTypeSelections, NEW_USER_TYPE, STEP_CONFIG } from "./data-steps"
import { useOnboardingModels } from "./useOnboardingModels"
type ModelSelectionProps = {
userType: NEW_USER_TYPE.FREE | NEW_USER_TYPE.POWER
userType: NEW_USER_TYPE.FREE | NEW_USER_TYPE.POWER | NEW_USER_TYPE.CLINE_PASS
selectedModelId: string
onSelectModel: (modelId: string) => void
onboardingModels: OnboardingModelsByGroup
@@ -33,6 +36,13 @@ type ModelSelectionProps = {
setSearchTerm: (term: string) => void
}
function getModelGroupKey(userType: ModelSelectionProps["userType"]): keyof OnboardingModelsByGroup {
if (userType === NEW_USER_TYPE.CLINE_PASS) {
return "clinePass"
}
return userType === NEW_USER_TYPE.FREE ? "free" : "power"
}
const ModelSelection = ({
userType,
selectedModelId,
@@ -42,7 +52,10 @@ const ModelSelection = ({
setSearchTerm,
onboardingModels,
}: ModelSelectionProps) => {
const modelGroups = onboardingModels[userType === NEW_USER_TYPE.FREE ? "free" : "power"]
const isClinePass = userType === NEW_USER_TYPE.CLINE_PASS
const modelGroups = onboardingModels[getModelGroupKey(userType)]
// ClinePass costs are covered by the subscription, so prices are hidden.
const hidePrice = isClinePass
const searchedModels = useMemo(() => {
if (!models || !searchTerm) {
@@ -73,7 +86,7 @@ const ModelSelection = ({
<Badge className="capitalize" variant="info">
{model.badge}
</Badge>
) : model.info ? (
) : !hidePrice && model.info ? (
<Badge>{getPriceRange(model.info)}</Badge>
) : null}
</ItemTitle>
@@ -99,7 +112,7 @@ const ModelSelection = ({
<span>Context: </span>
<span className="text-foreground/70">{(model?.info.contextWindow || 0) / 1000}k</span>
</div>
<Badge>{getPriceRange(model.info)}</Badge>
{!hidePrice && <Badge>{getPriceRange(model.info)}</Badge>}
</div>
)}
</div>
@@ -109,6 +122,16 @@ const ModelSelection = ({
)
}
// No curated ClinePass models available: show an empty state rather than other models.
if (isClinePass && modelGroups.length === 0) {
return (
<div className="flex w-full max-w-lg flex-col items-center justify-center my-8 px-2 text-center">
<p className="text-foreground text-sm m-0">No ClinePass models are available right now.</p>
<p className="text-foreground/70 text-sm mt-1">Please choose another option or try again later.</p>
</div>
)
}
return (
<div className="flex flex-col w-full items-center px-2">
<div className="flex w-full max-w-lg flex-col gap-6 my-4">
@@ -122,67 +145,68 @@ const ModelSelection = ({
))}
</div>
{/* SEARCH MODEL */}
<div className="flex w-full max-w-lg flex-col gap-6 my-4 border-t border-muted-foreground">
<div className="flex flex-col gap-3 mt-6" key="search-results">
<h4 className="text-sm font-bold text-foreground/70 uppercase mb-2">other options</h4>
<Input
autoFocus={false}
className="focus-visible:border-button-background"
onChange={(e) => {
if (!e.target?.value) {
onSelectModel("")
}
setSearchTerm(e.target.value)
}}
onClick={() => onSelectModel("")}
placeholder="Search model..."
type="search"
value={searchTerm}
/>
<div className="w-full flex flex-col gap-3">
{searchTerm &&
searchedModels.map(([id, info]) => {
const isSelected = selectedModelId === id
// Convert ModelInfo to OpenRouterModelInfo for OnboardingModel
const modelInfo: OpenRouterModelInfo = {
name: info.name,
maxTokens: info.maxTokens,
contextWindow: info.contextWindow,
supportsImages: info.supportsImages,
supportsPromptCache: info.supportsPromptCache,
inputPrice: info.inputPrice,
outputPrice: info.outputPrice,
cacheWritesPrice: info.cacheWritesPrice,
cacheReadsPrice: info.cacheReadsPrice,
description: info.description,
supportsGlobalEndpoint: info.supportsGlobalEndpoint,
thinkingConfig: info.thinkingConfig
? {
maxBudget: info.thinkingConfig.maxBudget,
outputPrice: info.thinkingConfig.outputPrice,
outputPriceTiers: info.thinkingConfig.outputPriceTiers || [],
}
: undefined,
tiers: info.tiers || [],
{/* SEARCH MODEL — hidden for ClinePass, whose selection is constrained to the curated list. */}
{!isClinePass && (
<div className="flex w-full max-w-lg flex-col gap-6 my-4 border-t border-muted-foreground">
<div className="flex flex-col gap-3 mt-6" key="search-results">
<h4 className="text-sm font-bold text-foreground/70 uppercase mb-2">other options</h4>
<Input
autoFocus={false}
className="focus-visible:border-button-background"
onChange={(e) => {
if (!e.target?.value) {
onSelectModel("")
}
const onboardingModel: OnboardingModel = {
id,
name: info.name || id,
info: modelInfo,
score: 0,
latency: 0,
badge: "",
group: "",
}
return <ModelItem id={id} isSelected={isSelected} key={id} model={onboardingModel} />
})}
{searchTerm.length > 0 && searchedModels.length === 0 && (
<p className="px-1 mt-1 text-sm text-foreground/70">No result found for "{searchTerm}"</p>
)}
setSearchTerm(e.target.value)
}}
onClick={() => onSelectModel("")}
placeholder="Search model..."
type="search"
value={searchTerm}
/>
<div className="w-full flex flex-col gap-3">
{searchTerm &&
searchedModels.map(([id, info]) => {
const isSelected = selectedModelId === id
const modelInfo: OpenRouterModelInfo = {
name: info.name,
maxTokens: info.maxTokens,
contextWindow: info.contextWindow,
supportsImages: info.supportsImages,
supportsPromptCache: info.supportsPromptCache,
inputPrice: info.inputPrice,
outputPrice: info.outputPrice,
cacheWritesPrice: info.cacheWritesPrice,
cacheReadsPrice: info.cacheReadsPrice,
description: info.description,
supportsGlobalEndpoint: info.supportsGlobalEndpoint,
thinkingConfig: info.thinkingConfig
? {
maxBudget: info.thinkingConfig.maxBudget,
outputPrice: info.thinkingConfig.outputPrice,
outputPriceTiers: info.thinkingConfig.outputPriceTiers || [],
}
: undefined,
tiers: info.tiers || [],
}
const onboardingModel: OnboardingModel = {
id,
name: info.name || id,
info: modelInfo,
score: 0,
latency: 0,
badge: "",
group: "",
}
return <ModelItem id={id} isSelected={isSelected} key={id} model={onboardingModel} />
})}
{searchTerm.length > 0 && searchedModels.length === 0 && (
<p className="px-1 mt-1 text-sm text-foreground/70">No result found for "{searchTerm}"</p>
)}
</div>
</div>
</div>
</div>
)}
</div>
)
}
@@ -190,12 +214,13 @@ const ModelSelection = ({
type UserTypeSelectionProps = {
userType: NEW_USER_TYPE | undefined
onSelectUserType: (type: NEW_USER_TYPE) => void
userTypeSelections: ReturnType<typeof getUserTypeSelections>
}
const UserTypeSelectionStep = ({ userType, onSelectUserType }: UserTypeSelectionProps) => (
const UserTypeSelectionStep = ({ userType, onSelectUserType, userTypeSelections }: UserTypeSelectionProps) => (
<div className="flex flex-col w-full items-center">
<div className="flex w-full max-w-lg flex-col gap-3 my-2">
{USER_TYPE_SELECTIONS.map((option) => {
{userTypeSelections.map((option) => {
const isSelected = userType === option.type
return (
@@ -229,6 +254,7 @@ type OnboardingStepContentProps = {
setSearchTerm: (term: string) => void
models?: Record<string, ModelInfo>
onboardingModels: OnboardingModelsByGroup
userTypeSelections: ReturnType<typeof getUserTypeSelections>
}
const OnboardingStepContent = ({
@@ -241,14 +267,21 @@ const OnboardingStepContent = ({
setSearchTerm,
models,
onboardingModels,
userTypeSelections,
}: OnboardingStepContentProps) => {
if (step === 0) {
return <UserTypeSelectionStep onSelectUserType={onSelectUserType} userType={userType} />
return (
<UserTypeSelectionStep
onSelectUserType={onSelectUserType}
userType={userType}
userTypeSelections={userTypeSelections}
/>
)
}
if (step === 2) {
return null
}
if (userType === NEW_USER_TYPE.FREE || userType === NEW_USER_TYPE.POWER) {
if (userType === NEW_USER_TYPE.FREE || userType === NEW_USER_TYPE.POWER || userType === NEW_USER_TYPE.CLINE_PASS) {
return (
<ModelSelection
models={models}
@@ -268,6 +301,8 @@ const OnboardingStepContent = ({
const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: OnboardingModelGroup }) => {
const { handleFieldsChange } = useApiConfigurationHandlers()
const { openRouterModels, hideSettings, hideAccount, setShowWelcome } = useExtensionState()
const isClinePassEnabled = useHasFeatureFlag(CLINE_PASS_FEATURE_FLAG)
const userTypeSelections = useMemo(() => getUserTypeSelections(isClinePassEnabled), [isClinePassEnabled])
const [stepNumber, setStepNumber] = useState(0)
const [isActionLoading, setIsActionLoading] = useState(false)
@@ -277,23 +312,30 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
const [searchTerm, setSearchTerm] = useState("")
const models = useMemo(() => getClineUIOnboardingGroups(onboardingModels), [onboardingModels])
// ClinePass model IDs (e.g. "cline-pass/glm-5.1") aren't keyed in openRouterModels,
// so resolve their info via the slug-based lookup used by ClinePassProvider.
const openRouterModelsByName = useMemo(() => buildModelInfoNameMap(openRouterModels), [openRouterModels])
useEffect(() => {
setSearchTerm("")
const userGroup = userType === NEW_USER_TYPE.POWER ? NEW_USER_TYPE.POWER : NEW_USER_TYPE.FREE
const modelGroup = models[userGroup][0]
const userGroupInitModel = modelGroup.models[0]
setSelectedModelId(userGroupInitModel.id)
const groupKey = userType === NEW_USER_TYPE.CLINE_PASS ? "clinePass" : userType === NEW_USER_TYPE.POWER ? "power" : "free"
// ClinePass must stay within its curated list (never fall back to a free/OpenRouter model
// under the cline-pass provider). Free/Frontier fall back to free if their group is empty.
const modelGroup = userType === NEW_USER_TYPE.CLINE_PASS ? models[groupKey][0] : (models[groupKey][0] ?? models.free[0])
const userGroupInitModel = modelGroup?.models[0]
setSelectedModelId(userGroupInitModel?.id ?? "")
}, [userType, models])
const onUserTypeClick = useCallback((userType: NEW_USER_TYPE) => {
setUserType(userType)
const action =
userType === NEW_USER_TYPE.POWER
? "power_user_selected"
: userType === NEW_USER_TYPE.FREE
? "free_user_selected"
: "byok_user_selected"
userType === NEW_USER_TYPE.CLINE_PASS
? "cline_pass_user_selected"
: userType === NEW_USER_TYPE.POWER
? "power_user_selected"
: userType === NEW_USER_TYPE.FREE
? "free_user_selected"
: "byok_user_selected"
// User selection is available in step 0 only
StateServiceClient.captureOnboardingProgress({ step: 0, action })
}, [])
@@ -307,28 +349,49 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
const finishOnboarding = useCallback(
async (updateModelId: boolean, step: number) => {
const modelSelected = (updateModelId && selectedModelId) || undefined
// Guard: never save a non-ClinePass model id under the cline-pass provider.
const isClinePassModel = selectedModelId.startsWith("cline-pass/")
if (modelSelected) {
await handleFieldsChange({
planModeOpenRouterModelId: selectedModelId,
actModeOpenRouterModelId: selectedModelId,
planModeOpenRouterModelInfo: openRouterModels[selectedModelId],
actModeOpenRouterModelInfo: openRouterModels[selectedModelId],
planModeApiProvider: "cline",
actModeApiProvider: "cline",
})
if (userType === NEW_USER_TYPE.CLINE_PASS && isClinePassModel) {
const clinePassModelInfo = resolveClinePassModelInfo(selectedModelId, openRouterModelsByName)
await handleFieldsChange({
planModeClinePassModelId: selectedModelId,
actModeClinePassModelId: selectedModelId,
planModeClinePassModelInfo: clinePassModelInfo,
actModeClinePassModelInfo: clinePassModelInfo,
planModeApiProvider: "cline-pass",
actModeApiProvider: "cline-pass",
})
} else if (userType !== NEW_USER_TYPE.CLINE_PASS) {
await handleFieldsChange({
planModeOpenRouterModelId: selectedModelId,
actModeOpenRouterModelId: selectedModelId,
planModeOpenRouterModelInfo: openRouterModels[selectedModelId],
actModeOpenRouterModelInfo: openRouterModels[selectedModelId],
planModeApiProvider: "cline",
actModeApiProvider: "cline",
})
} else {
// ClinePass selected but the id isn't a cline-pass/ model: skip the write
// (avoids a bad provider config) and log so the no-op is observable.
console.error(`Skipped ClinePass provider setup: unexpected model id "${selectedModelId}"`)
}
}
hideAccount()
hideSettings()
const action = "onboarding_completed"
StateServiceClient.captureOnboardingProgress({ step, modelSelected, action, completed: true })
},
[hideAccount, hideSettings, handleFieldsChange, selectedModelId, openRouterModels],
[hideAccount, hideSettings, handleFieldsChange, selectedModelId, openRouterModels, openRouterModelsByName, userType],
)
const handleFooterAction = useCallback(
async (action: "signin" | "next" | "back" | "done" | "signup") => {
switch (action) {
case "signup":
// ClinePass: record the intent so App opens the subscription page once auth
// completes (App outlives this view, which unmounts on auth). Login flow unchanged.
setPendingClinePassSubscribe(userType === NEW_USER_TYPE.CLINE_PASS)
setStepNumber(stepNumber + 1)
setIsActionLoading(true)
await AccountServiceClient.accountLoginClicked({})
@@ -337,6 +400,7 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
await finishOnboarding(true, stepNumber + 1)
break
case "signin":
setPendingClinePassSubscribe(false)
setIsActionLoading(true)
await AccountServiceClient.accountLoginClicked({})
.catch(() => {})
@@ -348,6 +412,8 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
setStepNumber(stepNumber + 1)
break
case "back":
// Abandon any pending ClinePass subscription redirect when the user goes back.
setPendingClinePassSubscribe(false)
StateServiceClient.captureOnboardingProgress({ step: stepNumber - 1 })
setStepNumber(stepNumber - 1)
break
@@ -358,7 +424,7 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
break
}
},
[stepNumber, finishOnboarding, setShowWelcome],
[stepNumber, finishOnboarding, setShowWelcome, userType],
)
const stepDisplayInfo = useMemo(() => {
@@ -394,20 +460,27 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
setSearchTerm={setSearchTerm}
step={stepNumber}
userType={userType}
userTypeSelections={userTypeSelections}
/>
</div>
<footer className="flex w-full max-w-lg flex-col gap-3 my-2 px-2 overflow-hidden flex-shrink-0">
{stepDisplayInfo.buttons.map((btn) => (
<Button
className={`w-full rounded-xs ${isActionLoading ? "animate-pulse" : ""}`}
disabled={isActionLoading}
key={btn.text}
onClick={() => handleFooterAction(btn.action)}
variant={btn.variant}>
{btn.text}
</Button>
))}
{stepDisplayInfo.buttons.map((btn) => {
// Block ClinePass signup when no ClinePass model is selected (e.g. empty list).
const disabled =
isActionLoading ||
(btn.action === "signup" && userType === NEW_USER_TYPE.CLINE_PASS && !selectedModelId)
return (
<Button
className={`w-full rounded-xs ${isActionLoading ? "animate-pulse" : ""}`}
disabled={disabled}
key={btn.text}
onClick={() => handleFooterAction(btn.action)}
variant={btn.variant}>
{btn.text}
</Button>
)
})}
{stepNumber !== 2 && (
<div className="items-center justify-center flex text-sm text-foreground gap-2 mb-3 text-pretty">
@@ -0,0 +1,86 @@
import type { OnboardingModel, OnboardingModelGroup } from "@shared/proto/cline/state"
import { describe, expect, it } from "vitest"
import { getClineUIOnboardingGroups, getRecommendedModelsData } from "../data-models"
function model(id: string, group: string): OnboardingModel {
return {
id,
name: id,
group,
badge: "",
score: 0,
latency: 0,
info: undefined,
} as OnboardingModel
}
function groupOf(models: OnboardingModel[]): OnboardingModelGroup {
return { models } as OnboardingModelGroup
}
describe("getClineUIOnboardingGroups", () => {
it("buckets ClinePass models into the clinePass group", () => {
const result = getClineUIOnboardingGroups(
groupOf([
model("cline-pass/glm-5.1", "clinepass"),
model("free-model", "free"),
model("anthropic/claude", "frontier"),
model("z-ai/glm", "open source"),
]),
)
expect(result.clinePass).toHaveLength(1)
expect(result.clinePass[0].group).toBe("clinepass")
expect(result.clinePass[0].models.map((m) => m.id)).toEqual(["cline-pass/glm-5.1"])
expect(result.free[0].models.map((m) => m.id)).toEqual(["free-model"])
expect(result.power.flatMap((g) => g.models.map((m) => m.id))).toEqual(["anthropic/claude", "z-ai/glm"])
})
it("returns an empty clinePass group when no ClinePass models are present", () => {
const result = getClineUIOnboardingGroups(groupOf([model("free-model", "free")]))
expect(result.clinePass).toEqual([])
})
})
describe("getRecommendedModelsData", () => {
it("ignores ClinePass-only responses when the ClinePass feature flag is disabled", () => {
const result = getRecommendedModelsData(
{
recommended: [],
free: [],
clinePass: [{ id: "cline-pass/glm-5.1", name: "GLM 5.1", description: "", tags: [] }],
},
false,
)
expect(result).toBeUndefined()
})
it("includes ClinePass models when the ClinePass feature flag is enabled", () => {
const result = getRecommendedModelsData(
{
recommended: [],
free: [],
clinePass: [{ id: "cline-pass/glm-5.1", name: "GLM 5.1", description: "", tags: [] }],
},
true,
)
expect(result?.clinePass.map((model) => model.id)).toEqual(["cline-pass/glm-5.1"])
})
it("keeps classic recommended/free responses when the ClinePass feature flag is disabled", () => {
const result = getRecommendedModelsData(
{
recommended: [{ id: "anthropic/claude", name: "Claude", description: "", tags: [] }],
free: [{ id: "free-model", name: "Free", description: "", tags: [] }],
clinePass: [{ id: "cline-pass/glm-5.1", name: "GLM 5.1", description: "", tags: [] }],
},
false,
)
expect(result?.recommended.map((model) => model.id)).toEqual(["anthropic/claude"])
expect(result?.free.map((model) => model.id)).toEqual(["free-model"])
expect(result?.clinePass).toEqual([])
})
})
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest"
import { getUserTypeSelections, NEW_USER_TYPE } from "../data-steps"
describe("getUserTypeSelections", () => {
it("omits the ClinePass option when the flag is disabled", () => {
const selections = getUserTypeSelections(false)
expect(selections.map((s) => s.type)).toEqual([NEW_USER_TYPE.FREE, NEW_USER_TYPE.POWER, NEW_USER_TYPE.BYOK])
expect(selections.some((s) => s.type === NEW_USER_TYPE.CLINE_PASS)).toBe(false)
})
it("inserts ClinePass right after the free option when the flag is enabled", () => {
const selections = getUserTypeSelections(true)
// Free stays first (and remains the default selection); ClinePass is the
// recommended-but-optional second choice.
expect(selections[0]?.type).toBe(NEW_USER_TYPE.FREE)
expect(selections[1]?.type).toBe(NEW_USER_TYPE.CLINE_PASS)
expect(selections.map((s) => s.type)).toEqual([
NEW_USER_TYPE.FREE,
NEW_USER_TYPE.CLINE_PASS,
NEW_USER_TYPE.POWER,
NEW_USER_TYPE.BYOK,
])
})
})
@@ -0,0 +1,28 @@
import { StringRequest } from "@shared/proto/cline/common"
import { UiServiceClient } from "@/services/grpc-client"
// ClinePass subscription signup page in the dashboard (requires auth).
const CLINE_PASS_SUBSCRIBE_PATH = "/onboarding/individual-plan"
const DEFAULT_APP_BASE_URL = "https://app.cline.bot"
// Module-level so the pending intent survives OnboardingView unmounting: handleAuthCallback
// completes the welcome view (unmounting onboarding) before it pushes the auth-status update
// that sets clineUser, so this must outlive the component to fire the redirect.
let pendingClinePassSubscribe = false
export function setPendingClinePassSubscribe(pending: boolean): void {
pendingClinePassSubscribe = pending
}
// Opens the ClinePass subscription page once a pending signup is authenticated (guarded so it fires once).
export function openClinePassSubscriptionIfPending(appBaseUrl: string | undefined): void {
if (!pendingClinePassSubscribe) {
return
}
pendingClinePassSubscribe = false
const baseUrl = appBaseUrl || DEFAULT_APP_BASE_URL
UiServiceClient.openUrl(StringRequest.create({ value: `${baseUrl}${CLINE_PASS_SUBSCRIBE_PATH}` })).catch((err) =>
console.error("Failed to open ClinePass subscription page:", err),
)
}
@@ -1,7 +1,35 @@
import type { OpenRouterModelInfo } from "@shared/proto/cline/models"
import type { ClineRecommendedModel, OpenRouterModelInfo } from "@shared/proto/cline/models"
import type { OnboardingModel, OnboardingModelGroup } from "@shared/proto/cline/state"
export interface RecommendedModelsData {
recommended: ClineRecommendedModel[]
free: ClineRecommendedModel[]
clinePass: ClineRecommendedModel[]
}
type RecommendedModelsResponseLike = {
recommended?: ClineRecommendedModel[]
free?: ClineRecommendedModel[]
clinePass?: ClineRecommendedModel[]
}
export function getRecommendedModelsData(
response: RecommendedModelsResponseLike,
isClinePassEnabled: boolean,
): RecommendedModelsData | undefined {
const recommended = response.recommended ?? []
const free = response.free ?? []
const clinePass = isClinePassEnabled ? (response.clinePass ?? []) : []
if (recommended.length === 0 && free.length === 0 && clinePass.length === 0) {
return undefined
}
return { recommended, free, clinePass }
}
export interface OnboardingModelsByGroup {
clinePass: ModelGroup[]
free: ModelGroup[]
power: ModelGroup[]
}
@@ -14,11 +42,13 @@ interface ModelGroup {
export function getClineUIOnboardingGroups(groupedModels: OnboardingModelGroup): OnboardingModelsByGroup {
const { models } = groupedModels
const clinePassModels = models.filter((m) => m.group === "clinepass")
const freeModels = models.filter((m) => m.group === "free")
const frontierModels = models.filter((m) => m.group === "frontier")
const openSourceModels = models.filter((m) => m.group === "open source")
return {
clinePass: clinePassModels.length > 0 ? [{ group: "clinepass", models: clinePassModels }] : [],
free: freeModels.length > 0 ? [{ group: "free", models: freeModels }] : [],
power: [
...(frontierModels.length > 0 ? [{ group: "frontier", models: frontierModels }] : []),
@@ -1,4 +1,5 @@
export enum NEW_USER_TYPE {
CLINE_PASS = "cline-pass",
FREE = "free",
POWER = "power",
BYOK = "byok",
@@ -19,6 +20,13 @@ export const STEP_CONFIG = {
{ text: "Login to Cline", action: "signin", variant: "secondary" },
],
},
[NEW_USER_TYPE.CLINE_PASS]: {
title: "Select a ClinePass model",
buttons: [
{ text: "Create my Account", action: "signup", variant: "default" },
{ text: "Back", action: "back", variant: "secondary" },
],
},
[NEW_USER_TYPE.FREE]: {
title: "Select a free model",
buttons: [
@@ -47,8 +55,29 @@ export const STEP_CONFIG = {
},
} as const
export const USER_TYPE_SELECTIONS: UserTypeSelection[] = [
const CLINE_PASS_USER_TYPE_SELECTION: UserTypeSelection = {
title: "ClinePass (Recommended)",
description: "One subscription, curated models, no API keys",
type: NEW_USER_TYPE.CLINE_PASS,
}
const BASE_USER_TYPE_SELECTIONS: UserTypeSelection[] = [
{ title: "Absolutely Free", description: "Get started at no cost", type: NEW_USER_TYPE.FREE },
{ title: "Frontier Model", description: "Claude, GPT Codex, Gemini, etc.", type: NEW_USER_TYPE.POWER },
{ title: "Bring my own API key", description: "Use Cline with your provider of choice", type: NEW_USER_TYPE.BYOK },
]
/**
* Returns the onboarding user-type options. The free option leads the list and is
* the default selection; ClinePass is inserted as a recommended-but-optional
* choice (labeled "Recommended") right after it, only when the `ext-cline-pass`
* feature flag is enabled. When the flag is off, the classic Free / Frontier /
* BYOK options are shown unchanged.
*/
export function getUserTypeSelections(isClinePassEnabled: boolean): UserTypeSelection[] {
if (!isClinePassEnabled) {
return BASE_USER_TYPE_SELECTIONS
}
const [free, ...rest] = BASE_USER_TYPE_SELECTIONS
return [free, CLINE_PASS_USER_TYPE_SELECTION, ...rest]
}
@@ -1,11 +1,14 @@
import type { ModelInfo } from "@shared/api"
import { buildModelInfoNameMap, type ModelInfo, resolveClinePassModelInfo } from "@shared/api"
import { CLINE_ONBOARDING_MODELS } from "@shared/cline/onboarding"
import { EmptyRequest } from "@shared/proto/cline/common"
import type { ClineRecommendedModel } from "@shared/proto/cline/models"
import type { OnboardingModel, OnboardingModelGroup } from "@shared/proto/cline/state"
import { useEffect, useMemo, useState } from "react"
import { CLINE_PASS_FEATURE_FLAG } from "@/constants/featureFlags"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useHasFeatureFlag } from "@/hooks/useFeatureFlag"
import { ModelsServiceClient } from "@/services/grpc-client"
import { getRecommendedModelsData, type RecommendedModelsData } from "./data-models"
export type OnboardingModelsStatus = "loading" | "success" | "empty"
@@ -44,15 +47,11 @@ function toOnboardingModel(
}
}
interface RecommendedModelsData {
recommended: ClineRecommendedModel[]
free: ClineRecommendedModel[]
}
type FetchState = { status: "loading" } | { status: "success"; data: RecommendedModelsData } | { status: "empty" }
export function useOnboardingModels(): UseOnboardingModelsResult {
const { openRouterModels, clineModels, refreshClineModels } = useExtensionState()
const isClinePassEnabled = useHasFeatureFlag(CLINE_PASS_FEATURE_FLAG)
const [fetchState, setFetchState] = useState<FetchState>({ status: "loading" })
useEffect(() => {
@@ -62,12 +61,11 @@ export function useOnboardingModels(): UseOnboardingModelsResult {
try {
const response = await ModelsServiceClient.refreshClineRecommendedModelsRpc(EmptyRequest.create({}))
if (!cancelled) {
const recommended = response.recommended ?? []
const free = response.free ?? []
if (recommended.length === 0 && free.length === 0) {
const data = getRecommendedModelsData(response, isClinePassEnabled)
if (!data) {
setFetchState({ status: "empty" })
} else {
setFetchState({ status: "success", data: { recommended, free } })
setFetchState({ status: "success", data })
}
}
} catch {
@@ -82,7 +80,7 @@ export function useOnboardingModels(): UseOnboardingModelsResult {
return () => {
cancelled = true
}
}, [])
}, [isClinePassEnabled])
useEffect(() => {
refreshClineModels()
@@ -93,6 +91,11 @@ export function useOnboardingModels(): UseOnboardingModelsResult {
return { ...openRouterModels, ...(clineModels ?? {}) }
}, [openRouterModels, clineModels])
// ClinePass model IDs omit the upstream lab (e.g. "cline-pass/glm-5.1"), so look up
// capabilities via the model slug against the OpenRouter catalog, falling back to
// conservative ClinePass defaults. Mirrors ClinePassProvider's resolution.
const openRouterModelsByName = useMemo(() => buildModelInfoNameMap(openRouterModels), [openRouterModels])
return useMemo<UseOnboardingModelsResult>(() => {
if (fetchState.status !== "success") {
return { status: fetchState.status, models: { models: CLINE_ONBOARDING_MODELS } }
@@ -101,7 +104,11 @@ export function useOnboardingModels(): UseOnboardingModelsResult {
const { data } = fetchState
const freeModels = data.free.map((rec) => toOnboardingModel(rec, "free", "Free", modelCatalog))
const frontierModels = data.recommended.map((rec) => toOnboardingModel(rec, "frontier", "", modelCatalog))
const clinePassCatalog = Object.fromEntries(
data.clinePass.map((rec) => [rec.id, resolveClinePassModelInfo(rec.id, openRouterModelsByName)]),
)
const clinePassModels = data.clinePass.map((rec) => toOnboardingModel(rec, "clinepass", "", clinePassCatalog))
return { status: "success", models: { models: [...freeModels, ...frontierModels] } }
}, [fetchState, modelCatalog])
return { status: "success", models: { models: [...clinePassModels, ...freeModels, ...frontierModels] } }
}, [fetchState, modelCatalog, openRouterModelsByName])
}
@@ -9,6 +9,7 @@ import styled from "styled-components"
import { normalizeApiConfiguration } from "@/components/settings/utils/providerUtils"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { PLATFORM_CONFIG, PlatformType } from "@/config/platform.config"
import { CLINE_PASS_FEATURE_FLAG } from "@/constants/featureFlags"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useHasFeatureFlag } from "@/hooks/useFeatureFlag"
import { ModelsServiceClient } from "@/services/grpc-client"
@@ -58,8 +59,6 @@ import { XaiProvider } from "./providers/XaiProvider"
import { ZAiProvider } from "./providers/ZAiProvider"
import { useApiConfigurationHandlers } from "./utils/useApiConfigurationHandlers"
const CLINE_PASS_FEATURE_FLAG = "ext-cline-pass"
interface ApiOptionsProps {
showModelOptions: boolean
apiErrorMessage?: string
@@ -25,7 +25,7 @@ export const ClinePassProvider: typeof ClineProvider = (props) => {
(response.clinePass ?? [])
.filter((model) => model.id)
.map((model) => {
// Cline Pass model IDs omit the upstream lab, so look up capabilities using
// ClinePass model IDs omit the upstream lab, so look up capabilities using
// the model slug (for example, glm-5.1 instead of cline-pass/glm-5.1).
// If the model is not in OpenRouter yet, use conservative generic defaults
// instead of copying GLM-5.1-specific context/max-token values.
@@ -42,7 +42,7 @@ export const ClinePassProvider: typeof ClineProvider = (props) => {
)
setClinePassRecommendedModels(Object.keys(models).length > 0 ? models : undefined)
} catch (error) {
console.error("Failed to refresh Cline Pass models:", error)
console.error("Failed to refresh ClinePass models:", error)
}
}, [openRouterModelsByName])
@@ -0,0 +1,3 @@
// Webview copies of feature-flag strings. Must match the extension's FeatureFlag
// enum, which can't be imported here (it pulls in Node-only deps).
export const CLINE_PASS_FEATURE_FLAG = "ext-cline-pass"
+229 -173
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -1,5 +1,15 @@
# Cline SDK Changelog
## 0.0.50
- Truncate every tool result by default (including MCP and custom tool output), with tightened `MessageBuilder` limits and tunable `CLINE_MESSAGE_BUILDER_*` env overrides, to keep provider requests within budget
- Cap assistant text in provider messages and count `tool_use` input toward the request budget; protect binary carrier blocks (not just images) from truncation
- Resolve tool names from `tool_result` when the paired `tool_use` is gone
- Add ClinePass provider support (built-in provider, error handling, format compatibility)
- Apply auto-approve toggles immediately in the agent runtime
- Harden parallel tool-call guidance in the system prompt and tool definitions
- Refresh the generated model catalog
## 0.0.49
- Reverted ClinePass recommended-models support, removing the `clinePass` field from the recommended models data
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/agents",
"version": "0.0.49",
"version": "0.0.50",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline",
@@ -600,6 +600,73 @@ describe("AgentRuntime", () => {
});
});
it("applies beforeTool approval policy overrides before executing tools", async () => {
const executeTool = vi.fn(async () => ({ echoed: "hi" }));
const requestToolApproval = vi.fn(async () => ({
approved: false,
reason: "live policy denied",
}));
const model = new ScriptedModel([
() => [
{
type: "tool-call-delta",
toolCallId: "call_live_policy",
toolName: "echo",
inputText: '{"text":"hi"}',
},
{ type: "finish", reason: "tool-calls" },
],
(request) => {
const toolMessage = request.messages.at(-1) as AgentMessage;
expect(toolMessage.role).toBe("tool");
expect(toolMessage.content[0]).toMatchObject({
type: "tool-result",
isError: true,
output: { error: "live policy denied" },
});
return [
{ type: "text-delta", text: "live policy handled" },
{ type: "finish", reason: "stop" },
];
},
]);
const runtime = new AgentRuntime({
sessionId: "session_test",
agentId: "agent_test",
conversationId: "conversation_test",
model,
tools: [
{
name: "echo",
description: "Echo input text",
inputSchema: { type: "object" },
execute: executeTool,
},
],
toolPolicies: { "*": { autoApprove: true } },
hooks: {
beforeTool: () => ({ policy: { autoApprove: false } }),
},
requestToolApproval,
});
const result = await runtime.run("Start");
expect(result.status).toBe("completed");
expect(result.outputText).toBe("live policy handled");
expect(executeTool).not.toHaveBeenCalled();
expect(requestToolApproval).toHaveBeenCalledWith({
sessionId: "session_test",
agentId: "agent_test",
conversationId: "conversation_test",
iteration: 1,
toolCallId: "call_live_policy",
toolName: "echo",
input: { text: "hi" },
policy: { autoApprove: false },
});
});
it("stores tool calls but skips execution when metadata disables external execution", async () => {
const executeTool = vi.fn(async () => ({ echoed: "hi" }));
const model = new ScriptedModel([
+11 -4
View File
@@ -1133,6 +1133,7 @@ export class AgentRuntime {
skipReason = `Tool execution is disabled for provider ${providerId}`;
}
let policyOverride: ToolPolicy | undefined;
if (tool && !skipReason) {
for (const hook of this.hooks.beforeTool) {
const result = (await hook({
@@ -1144,6 +1145,12 @@ export class AgentRuntime {
if (result?.input !== undefined) {
input = result.input;
}
if (result?.policy) {
policyOverride = {
...policyOverride,
...result.policy,
};
}
this.applyStopControl(result);
if (result?.skip) {
skipReason =
@@ -1154,10 +1161,10 @@ export class AgentRuntime {
}
if (tool && !skipReason) {
const policy = resolveToolPolicy(
toolCall.toolName,
this.config.toolPolicies,
);
const policy = {
...resolveToolPolicy(toolCall.toolName, this.config.toolPolicies),
...policyOverride,
};
if (policy.enabled === false) {
skipReason = `Tool "${toolCall.toolName}" is disabled by policy`;
} else if (policy.autoApprove === false) {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/core",
"description": "Cline Core SDK for Node Runtime",
"version": "0.0.49",
"version": "0.0.50",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline",
@@ -1047,10 +1047,10 @@ describe("default run_commands tool", () => {
});
it("emits timeout telemetry without leaking raw command data", async () => {
const execute = vi.fn(
async (): Promise<string> =>
await new Promise((resolve) => setTimeout(() => resolve("ok"), 20)),
);
// Never resolves, so the configured timeout deterministically wins the
// race regardless of host load (a tight real-timer margin flaked under
// heavy parallel CI runs).
const execute = vi.fn((): Promise<string> => new Promise<string>(() => {}));
const tool = createWindowsShellTool(execute, { bashTimeoutMs: 5 });
const telemetry = createTelemetryStub();
@@ -1151,10 +1151,10 @@ describe("default run_commands tool", () => {
it("emits timeout telemetry on the default bash tool path", async () => {
const telemetry = createTelemetryStub();
const execute = vi.fn(
async (): Promise<string> =>
await new Promise((resolve) => setTimeout(() => resolve("ok"), 20)),
);
// Never resolves, so the configured timeout deterministically wins the
// race regardless of host load (a tight real-timer margin flaked under
// heavy parallel CI runs).
const execute = vi.fn((): Promise<string> => new Promise<string>(() => {}));
const tool = createBashTool(execute, { bashTimeoutMs: 5 });
const result = await tool.execute(
@@ -328,7 +328,7 @@ export function createBashTool(
description:
"Run shell commands from the root of the workspace. " +
"Use for listing files, checking git status, running builds, executing tests, etc. " +
"Commands should be properly shell-escaped and targeted to avoid error or timeout. Include multiple commands only when they are independent complete shell commands and safe to run concurrently; multiline scripts and heredocs must be a single command string. " +
"Commands should be properly shell-escaped and targeted to avoid error or timeout. Include multiple commands in the same call when they are independent complete shell commands and safe to run concurrently; multiline scripts and heredocs must be a single command string. When independent reads, searches, or edits are also needed, call those tools in the same response. " +
`Output beyond ~${Math.round(MAX_COMMAND_OUTPUT_CHARS / 1000)}k characters is middle-truncated (start and end preserved); pipe through grep/head/tail when you need specific sections of large output. ` +
"For long-running commands, run them in background and redirect output to a tmp file that you can read from later.",
inputSchema: zodToJsonSchema(RunCommandsInputSchema),
@@ -418,10 +418,10 @@ export function createWindowsShellTool(
return createTool<StructuredCommandInput, ToolOperationResult[]>({
name: "run_commands",
description:
"Run shell commands from the root of the workspacein Windows environment. " +
"Run shell commands from the root of the workspace in Windows environment. " +
"Use for listing files, checking git status, running builds, executing tests, etc. " +
`Output beyond ~${Math.round(MAX_COMMAND_OUTPUT_CHARS / 1000)}k characters is middle-truncated (start and end preserved); filter output when you need specific sections. ` +
"Prefer structured { command, args } entries for portability; plain string commands should be properly shell-escaped. Include multiple commands when they are independent and safe to run concurrently.",
"Prefer structured { command, args } entries for portability; plain string commands should be properly shell-escaped. Include multiple commands in the same call when they are independent and safe to run concurrently. When independent reads, searches, or edits are also needed, call those tools in the same response.",
inputSchema: zodToJsonSchema(StructuredCommandsInputSchema),
timeoutMs: timeoutMs * 2,
retryable: false, // Shell commands often have side effects
@@ -635,7 +635,7 @@ export function createEditorTool(
"An editor for controlled filesystem edits on the text file at the provided path. " +
"Provide `insert_line` to insert `new_text` at a specific line number. " +
"Otherwise, the tool replaces `old_text` with `new_text`, or creates the file with `new_text` if file does not exist. " +
"Use this tools for making small, precise edits to existing files or creating new files over shell commands.",
"Use this tool for making small, precise edits to existing files or creating new files over shell commands. If several edits to different files or non-overlapping regions are already known, emit multiple editor tool calls in the same response instead of serializing them across turns.",
inputSchema: zodToJsonSchema(EditFileInputSchema),
timeoutMs,
@@ -669,7 +669,16 @@ export class LocalRuntimeHost implements RuntimeHost {
modelId: active.config.modelId,
},
});
await this.failSession(active);
try {
await this.failSession(active);
} catch (cleanupError) {
// Never let cleanup failures mask the error that actually
// killed the turn; that one is what callers must see.
active.config.logger?.error?.("Session failure cleanup threw", {
sessionId: active.sessionId,
error: cleanupError,
});
}
throw error;
}
}
@@ -53,7 +53,10 @@ import {
resolveKnownModelsFromConfig,
} from "../../services/llms/handler-factory";
import { CLINE_INTERNAL_TELEMETRY_METADATA_KEY } from "../../services/telemetry/tool-context";
import { MessageBuilder } from "../../session/services/message-builder";
import {
getMessageBuilderOptionsFromEnv,
MessageBuilder,
} from "../../session/services/message-builder";
import { ConversationStore } from "../../session/stores/conversation-store";
import {
agentMessagesToMessages,
@@ -374,7 +377,7 @@ export class SessionRuntime {
deps.createAgentRuntimeImpl ?? createAgentRuntime;
this.conversation = new ConversationStore(config.initialMessages);
this.messageBuilder = new MessageBuilder();
this.messageBuilder = new MessageBuilder(getMessageBuilderOptionsFromEnv());
this.contributionRegistry = createContributionRegistry<
AgentExtension,
AgentTool,
File diff suppressed because it is too large Load Diff
@@ -25,18 +25,22 @@ import {
validateAndReserveImageMedia,
} from "@cline/shared";
const DEFAULT_MAX_TOOL_RESULT_CHARS = 50_000;
const DEFAULT_MAX_TOTAL_TEXT_BYTES = 6_000_000;
const MIN_TOTAL_BUDGET_TOOL_RESULT_BYTES = 8_000;
const TARGET_TOOL_NAMES = new Set([
"read",
"read_files",
"search",
"search_codebase",
"bash",
"run_commands",
"fetch_web_content",
]);
export const DEFAULT_MAX_TOOL_RESULT_CHARS = 8_000;
export const DEFAULT_MAX_FILE_CONTENT_CHARS = 50_000;
// The aggregate budget intentionally stays far above what the per-result cap
// usually produces: budget truncation rewrites bytes mid-transcript, which
// invalidates provider prefix caches from the first rewritten block onward,
// so it must remain a rare overflow valve rather than the steady state.
export const DEFAULT_MAX_TOTAL_TEXT_BYTES = 6_000_000;
export const DEFAULT_MAX_ASSISTANT_TEXT_CHARS = 200_000;
export const DEFAULT_MAX_ASSISTANT_TOOL_MARKUP_CHARS = 12_000;
const MIN_TOTAL_BUDGET_TOOL_RESULT_BYTES = 2_000;
const MIN_TOTAL_BUDGET_ASSISTANT_TEXT_BYTES = 40_000;
const REPEATED_TOOL_CALL_MARKUP_THRESHOLD = 8;
export const MESSAGE_BUILDER_LIMIT_ENV = {
maxToolResultChars: "CLINE_MESSAGE_BUILDER_MAX_TOOL_RESULT_CHARS",
maxTotalTextBytes: "CLINE_MESSAGE_BUILDER_MAX_TOTAL_TEXT_BYTES",
} as const;
const READ_TOOL_NAMES = new Set(["read", "read_files"]);
const OUTDATED_FILE_CONTENT = "[outdated - see the latest file content]";
const MISSING_TOOL_RESULT_TEXT =
@@ -45,6 +49,12 @@ const TRUNCATE_MARKER_DEFAULT = (n: number) =>
`\n\n...[truncated ${n} chars]...\n\n`;
const TRUNCATE_MARKER_BUDGET = (n: number) =>
`\n\n...[truncated ${n} chars to fit provider request budget]...\n\n`;
const TRUNCATE_ASSISTANT_TEXT_MARKER = (n: number) =>
`\n\n...[assistant text truncated: omitted ${n} chars]...\n\n`;
const TRUNCATE_ASSISTANT_TEXT_BUDGET_MARKER = (n: number) =>
`\n\n...[assistant text truncated: omitted ${n} chars to fit provider request budget]...\n\n`;
const TRUNCATE_ASSISTANT_TOOL_MARKUP_MARKER = (n: number) =>
`\n\n...[assistant text truncated: omitted ${n} chars due to repeated tool-call markup]...\n\n`;
interface ReadLocator {
path: string;
@@ -54,10 +64,36 @@ interface ReadLocator {
interface TruncationCandidate {
byteLength: number;
minBytes: number;
makeMarker: (removed: number) => string;
get(): string;
set(value: string): void;
}
export interface MessageBuilderOptions {
maxToolResultChars?: number;
maxFileContentChars?: number;
maxTotalTextBytes?: number;
mediaBudget?: MediaBudgetOptions;
maxAssistantTextChars?: number;
maxAssistantToolMarkupChars?: number;
}
export function getMessageBuilderOptionsFromEnv(
env: Record<string, string | undefined> = process.env,
): MessageBuilderOptions {
// Zero and negative values are rejected (falling back to the defaults), so
// an env override can tune the limits but never silently disable them.
return {
maxToolResultChars: parsePositiveIntegerEnv(
env[MESSAGE_BUILDER_LIMIT_ENV.maxToolResultChars],
),
maxTotalTextBytes: parsePositiveIntegerEnv(
env[MESSAGE_BUILDER_LIMIT_ENV.maxTotalTextBytes],
),
};
}
/**
* Builds an API-safe message copy without mutating original conversation history.
*/
@@ -75,13 +111,36 @@ export class MessageBuilder {
string
>();
private readResultLocatorCache = new WeakMap<object, ReadLocator[]>();
private readonly maxToolResultChars: number;
private readonly maxFileContentChars: number;
private readonly maxTotalTextBytes: number;
private readonly mediaBudget: MediaBudgetOptions;
private readonly maxAssistantTextChars: number;
private readonly maxAssistantToolMarkupChars: number;
constructor(
private readonly maxToolResultChars = DEFAULT_MAX_TOOL_RESULT_CHARS,
private readonly targetToolNames = TARGET_TOOL_NAMES,
private readonly maxTotalTextBytes = DEFAULT_MAX_TOTAL_TEXT_BYTES,
private readonly mediaBudget: MediaBudgetOptions = {},
) {}
constructor(options: MessageBuilderOptions = {}) {
this.maxToolResultChars = normalizePositiveLimit(
options.maxToolResultChars,
DEFAULT_MAX_TOOL_RESULT_CHARS,
);
this.maxFileContentChars = normalizePositiveLimit(
options.maxFileContentChars,
DEFAULT_MAX_FILE_CONTENT_CHARS,
);
this.maxTotalTextBytes = normalizePositiveLimit(
options.maxTotalTextBytes,
DEFAULT_MAX_TOTAL_TEXT_BYTES,
);
this.mediaBudget = options.mediaBudget ?? {};
this.maxAssistantTextChars = normalizePositiveLimit(
options.maxAssistantTextChars,
DEFAULT_MAX_ASSISTANT_TEXT_CHARS,
);
this.maxAssistantToolMarkupChars = normalizePositiveLimit(
options.maxAssistantToolMarkupChars,
DEFAULT_MAX_ASSISTANT_TOOL_MARKUP_CHARS,
);
}
buildForApi(messages: Message[]): Message[] {
this.reindex(messages);
@@ -95,6 +154,15 @@ export class MessageBuilder {
return { ...message, content: normalized };
}
}
if (
message.role === "assistant" &&
typeof message.content === "string"
) {
const truncated = this.truncateAssistantText(message.content);
if (truncated !== message.content) {
return { ...message, content: truncated };
}
}
return message;
}
@@ -130,8 +198,24 @@ export class MessageBuilder {
return block;
}
if (
role === "assistant" &&
block.type === "text" &&
typeof block.text === "string"
) {
const truncated = this.truncateAssistantText(block.text);
return truncated === block.text ? block : { ...block, text: truncated };
}
if (block.type === "file") {
const truncated = this.truncateMiddle(block.content);
// Top-level file blocks are user attachments, not tool output; they
// get their own (looser) cap so the aggressive tool-result limit
// does not mutilate content the user explicitly supplied.
const truncated = truncateMiddleByChars(
block.content,
this.maxFileContentChars,
TRUNCATE_MARKER_DEFAULT,
);
return truncated === block.content
? block
: { ...block, content: truncated };
@@ -141,7 +225,7 @@ export class MessageBuilder {
return block;
}
const toolName = this.toolNameByIdCache.get(block.tool_use_id);
const toolName = this.resolveToolName(block);
let nextContent = block.content;
if (this.isReadTool(toolName) && block.is_error !== true) {
@@ -156,9 +240,10 @@ export class MessageBuilder {
}
}
if (this.shouldTruncateTool(toolName)) {
nextContent = this.truncateToolResultContent(nextContent);
}
// Truncation is default-on for every tool result: MCP and custom SDK
// tools produce payloads just as large as the built-in ones, and any
// allowlist gate silently exempts them.
nextContent = this.truncateToolResultContent(nextContent);
return nextContent === block.content
? block
@@ -197,7 +282,7 @@ export class MessageBuilder {
}
}
} else if (block.type === "tool_result") {
const toolName = this.toolNameByIdCache.get(block.tool_use_id);
const toolName = this.resolveToolName(block);
if (!this.isReadTool(toolName) || block.is_error === true) {
continue;
}
@@ -761,8 +846,19 @@ export class MessageBuilder {
return !!toolName && READ_TOOL_NAMES.has(toolName);
}
private shouldTruncateTool(toolName: string | undefined): boolean {
return !!toolName && this.targetToolNames.has(toolName);
/**
* Tool results can outlive their paired tool_use block (compacted or
* imported histories), so fall back to the name carried on the result
* itself when the id lookup misses.
*/
private resolveToolName(block: ToolResultContent): string | undefined {
const cached = this.toolNameByIdCache.get(block.tool_use_id);
if (cached !== undefined) {
return cached;
}
return typeof block.name === "string" && block.name.length > 0
? block.name.toLowerCase()
: undefined;
}
private truncateToolResultContent(
@@ -809,7 +905,7 @@ export class MessageBuilder {
return changed ? next : value;
}
if (value !== null && typeof value === "object") {
if (isImageContentLike(value)) {
if (isBinaryContentLike(value)) {
return value;
}
let changed = false;
@@ -834,11 +930,36 @@ export class MessageBuilder {
);
}
private truncateToTotalTextBudget(messages: Message[]): Message[] {
if (this.maxTotalTextBytes <= 0) {
return messages;
private truncateAssistantText(text: string): string {
if (this.hasRepeatedToolCallMarkup(text)) {
return truncateMiddleByChars(
text,
this.maxAssistantToolMarkupChars,
TRUNCATE_ASSISTANT_TOOL_MARKUP_MARKER,
);
}
return truncateMiddleByChars(
text,
this.maxAssistantTextChars,
TRUNCATE_ASSISTANT_TEXT_MARKER,
);
}
private hasRepeatedToolCallMarkup(text: string): boolean {
if (text.length <= this.maxAssistantToolMarkupChars) {
return false;
}
let count = 0;
for (const _match of text.matchAll(TOOL_CALL_MARKUP_PATTERN)) {
count += 1;
if (count >= REPEATED_TOOL_CALL_MARKUP_THRESHOLD) {
return true;
}
}
return false;
}
private truncateToTotalTextBudget(messages: Message[]): Message[] {
let totalBytes = this.countMessageTextBytes(messages);
if (totalBytes <= this.maxTotalTextBytes) {
return messages;
@@ -846,7 +967,7 @@ export class MessageBuilder {
const next = messages.map((message) => {
if (!Array.isArray(message.content)) {
return message;
return { ...message };
}
return {
...message,
@@ -862,18 +983,15 @@ export class MessageBuilder {
break;
}
const currentBytes = candidate.byteLength;
if (currentBytes <= MIN_TOTAL_BUDGET_TOOL_RESULT_BYTES) {
if (currentBytes <= candidate.minBytes) {
continue;
}
const overflow = totalBytes - this.maxTotalTextBytes;
const targetBytes = Math.max(
MIN_TOTAL_BUDGET_TOOL_RESULT_BYTES,
currentBytes - overflow,
);
const targetBytes = Math.max(candidate.minBytes, currentBytes - overflow);
const truncated = truncateMiddleToBytes(
candidate.get(),
targetBytes,
TRUNCATE_MARKER_BUDGET,
candidate.makeMarker,
);
candidate.set(truncated);
totalBytes -= currentBytes - utf8ByteLength(truncated);
@@ -896,6 +1014,12 @@ export class MessageBuilder {
total += utf8ByteLength(block.thinking);
} else if (block.type === "file") {
total += utf8ByteLength(block.content);
} else if (block.type === "tool_use") {
// Model-generated tool arguments ship on the wire too. Counting
// them keeps the budget honest; if tool results alone cannot
// absorb the overflow, oversized argument strings are truncated
// as a last resort (see collectTruncationCandidates).
total += countNestedStringBytes(block.input);
} else if (block.type === "tool_result") {
if (typeof block.content === "string") {
total += utf8ByteLength(block.content);
@@ -919,22 +1043,49 @@ export class MessageBuilder {
private collectTruncationCandidates(
messages: Message[],
): TruncationCandidate[] {
const candidates: TruncationCandidate[] = [];
const resultCandidates: TruncationCandidate[] = [];
const inputCandidates: TruncationCandidate[] = [];
for (const message of messages) {
if (message.role === "assistant" && typeof message.content === "string") {
resultCandidates.push({
byteLength: utf8ByteLength(message.content),
minBytes: MIN_TOTAL_BUDGET_ASSISTANT_TEXT_BYTES,
makeMarker: TRUNCATE_ASSISTANT_TEXT_BUDGET_MARKER,
get: () => message.content as string,
set: (value) => {
message.content = value;
},
});
continue;
}
if (!Array.isArray(message.content)) {
continue;
}
for (const block of message.content) {
if (block.type === "tool_use") {
collectNestedStringCandidates(block.input, inputCandidates);
continue;
}
if (message.role === "assistant" && block.type === "text") {
resultCandidates.push({
byteLength: utf8ByteLength(block.text),
minBytes: MIN_TOTAL_BUDGET_ASSISTANT_TEXT_BYTES,
makeMarker: TRUNCATE_ASSISTANT_TEXT_BUDGET_MARKER,
get: () => block.text,
set: (value) => {
block.text = value;
},
});
continue;
}
if (block.type !== "tool_result") {
continue;
}
const toolName = this.toolNameByIdCache.get(block.tool_use_id);
if (!this.shouldTruncateTool(toolName)) {
continue;
}
if (typeof block.content === "string") {
candidates.push({
resultCandidates.push({
byteLength: utf8ByteLength(block.content),
minBytes: MIN_TOTAL_BUDGET_TOOL_RESULT_BYTES,
makeMarker: TRUNCATE_MARKER_BUDGET,
get: () => block.content as string,
set: (value) => {
block.content = value;
@@ -944,28 +1095,38 @@ export class MessageBuilder {
}
for (const entry of block.content) {
if (entry.type === "text") {
candidates.push({
resultCandidates.push({
byteLength: utf8ByteLength(entry.text),
minBytes: MIN_TOTAL_BUDGET_TOOL_RESULT_BYTES,
makeMarker: TRUNCATE_MARKER_BUDGET,
get: () => entry.text,
set: (value) => {
entry.text = value;
},
});
} else if (entry.type === "file") {
candidates.push({
resultCandidates.push({
byteLength: utf8ByteLength(entry.content),
minBytes: MIN_TOTAL_BUDGET_TOOL_RESULT_BYTES,
makeMarker: TRUNCATE_MARKER_BUDGET,
get: () => entry.content,
set: (value) => {
entry.content = value;
},
});
} else if (isStructuredToolResultEntry(entry)) {
collectNestedStringCandidates(entry, candidates);
collectNestedStringCandidates(entry, resultCandidates);
}
}
}
}
return candidates.sort((l, r) => r.byteLength - l.byteLength);
// Tool results and assistant text truncate first; model-generated
// tool_use arguments are a last resort because some providers
// revalidate or replay them. All three being candidates keeps the
// budget reclaimable no matter which side carries the overflow.
resultCandidates.sort((l, r) => r.byteLength - l.byteLength);
inputCandidates.sort((l, r) => r.byteLength - l.byteLength);
return [...resultCandidates, ...inputCandidates];
}
private applyMediaBudget(messages: Message[]): Message[] {
@@ -1116,10 +1277,37 @@ export class MessageBuilder {
}
}
const DSML_BAR = String.raw`[\|\uFF5C]`;
// Compiled once at module load; String.prototype.matchAll clones the regex
// per call, so sharing the global-flagged instance is safe.
const TOOL_CALL_MARKUP_PATTERN = new RegExp(
String.raw`<\s*(?:${DSML_BAR}\s*)?DSML\s*(?:${DSML_BAR}\s*)?(?:tool_calls|invoke)\b[^>]*>|<\s*/?\s*(?:tool_calls?|tool_call|function_calls?|function_call|invoke)\b[^>]*>`,
"gi",
);
function utf8ByteLength(text: string): number {
return Buffer.byteLength(text, "utf8");
}
function parsePositiveIntegerEnv(
value: string | undefined,
): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
function normalizePositiveLimit(
value: number | undefined,
fallback: number,
): number {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.floor(value)
: fallback;
}
function truncateMiddleByChars(
text: string,
maxChars: number,
@@ -1170,6 +1358,15 @@ function truncateMiddleToBytes(
}
function cloneContentBlockForMutation(block: ContentBlock): ContentBlock {
if (block.type === "tool_use") {
// Inputs are budget-truncation candidates of last resort, so they need
// the same deep-clone treatment as structured results: a shallow copy
// would leak truncation mutations back into conversation history.
return {
...block,
input: deepCloneJsonLike(block.input) as typeof block.input,
};
}
if (block.type !== "tool_result" || typeof block.content === "string") {
return { ...block };
}
@@ -1217,6 +1414,10 @@ function isImageContentWithData(value: unknown): value is ImageContent {
);
}
function isBinaryContentLike(value: unknown): boolean {
return isImageContentWithData(value);
}
function countNestedStringBytes(value: unknown): number {
if (typeof value === "string") {
return utf8ByteLength(value);
@@ -1229,7 +1430,7 @@ function countNestedStringBytes(value: unknown): number {
return total;
}
if (value !== null && typeof value === "object") {
if (isImageContentWithData(value)) {
if (isBinaryContentLike(value)) {
return 0;
}
let total = 0;
@@ -1250,6 +1451,8 @@ function collectNestedStringCandidates(
if (typeof item === "string") {
candidates.push({
byteLength: utf8ByteLength(item),
minBytes: MIN_TOTAL_BUDGET_TOOL_RESULT_BYTES,
makeMarker: TRUNCATE_MARKER_BUDGET,
get: () => container[index] as string,
set: (value) => {
container[index] = value;
@@ -1262,7 +1465,7 @@ function collectNestedStringCandidates(
return;
}
if (container !== null && typeof container === "object") {
if (isImageContentWithData(container)) {
if (isBinaryContentLike(container)) {
return;
}
const record = container as Record<string, unknown>;
@@ -1271,6 +1474,8 @@ function collectNestedStringCandidates(
if (typeof item === "string") {
candidates.push({
byteLength: utf8ByteLength(item),
minBytes: MIN_TOTAL_BUDGET_TOOL_RESULT_BYTES,
makeMarker: TRUNCATE_MARKER_BUDGET,
get: () => record[key] as string,
set: (value) => {
record[key] = value;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/llms",
"version": "0.0.49",
"version": "0.0.50",
"description": "Config-driven SDK for selecting, extending, and instantiating LLM providers and models",
"repository": {
"type": "git",
@@ -14,7 +14,7 @@ export const GENERATED_PROVIDER_MODELS: {
version: number;
providers: Record<string, Record<string, ModelInfo>>;
} = {
version: 1781728990666,
version: 1781903376387,
providers: {
aihubmix: {
"glm-5v-turbo": {
@@ -1521,7 +1521,7 @@ export const GENERATED_PROVIDER_MODELS: {
baseten: {
"zai-org/GLM-5.2": {
id: "zai-org/GLM-5.2",
name: "GLM-5.2",
name: "GLM 5.2",
contextWindow: 131072,
maxInputTokens: 131072,
maxTokens: 131072,
@@ -1650,7 +1650,7 @@ export const GENERATED_PROVIDER_MODELS: {
cacheRead: 0.26,
cacheWrite: 0,
},
releaseDate: "2026-03-27",
releaseDate: "2026-04-07",
family: "glm",
},
"nvidia/Nemotron-120B-A12B": {
@@ -1667,7 +1667,7 @@ export const GENERATED_PROVIDER_MODELS: {
"prompt-cache",
],
pricing: {
input: 0.06,
input: 0.3,
output: 0.75,
cacheRead: 0.06,
cacheWrite: 0,
@@ -1734,7 +1734,7 @@ export const GENERATED_PROVIDER_MODELS: {
"prompt-cache",
],
pricing: {
input: 0.12,
input: 0.6,
output: 2.2,
cacheRead: 0.12,
cacheWrite: 0,
@@ -3680,9 +3680,103 @@ export const GENERATED_PROVIDER_MODELS: {
},
},
"cline-pass": {
"cline-pass/mimo-v2.5-pro": {
name: "MiMo-V2.5-Pro",
id: "cline-pass/mimo-v2.5-pro",
contextWindow: 1048576,
maxInputTokens: 1048576,
maxTokens: 131072,
capabilities: [
"tools",
"reasoning",
"structured_output",
"temperature",
"prompt-cache",
],
pricing: {
input: 0.435,
output: 0.87,
cacheRead: 0.0036,
cacheWrite: 0,
},
releaseDate: "2026-04-22",
family: "mimo",
description: "",
},
"cline-pass/glm-5.2": {
name: "GLM-5.2",
id: "cline-pass/glm-5.2",
contextWindow: 262144,
maxInputTokens: 262144,
maxTokens: 262144,
capabilities: [
"tools",
"reasoning",
"structured_output",
"temperature",
"prompt-cache",
],
pricing: {
input: 1.4,
output: 4.4,
cacheRead: 0.26,
cacheWrite: 0,
},
releaseDate: "2026-06-13",
family: "glm",
description: "",
},
"cline-pass/mimo-v2.5": {
name: "MiMo-V2.5",
id: "cline-pass/mimo-v2.5",
contextWindow: 1048576,
maxInputTokens: 1048576,
maxTokens: 131072,
capabilities: [
"images",
"tools",
"reasoning",
"structured_output",
"temperature",
"prompt-cache",
],
pricing: {
input: 0.14,
output: 0.28,
cacheRead: 0.0028,
cacheWrite: 0,
},
releaseDate: "2026-04-22",
family: "mimo",
description: "",
},
"cline-pass/kimi-k2.7-code": {
name: "Kimi K2.7 Code",
id: "cline-pass/kimi-k2.7-code",
contextWindow: 262144,
maxInputTokens: 262144,
maxTokens: 16384,
capabilities: [
"images",
"tools",
"reasoning",
"structured_output",
"temperature",
"prompt-cache",
],
pricing: {
input: 0.74,
output: 3.5,
cacheRead: 0.15,
cacheWrite: 0,
},
releaseDate: "2026-06-12",
family: "kimi-k2",
description: "",
},
"cline-pass/glm-5.1": {
id: "cline-pass/glm-5.1",
name: "GLM-5.1",
id: "cline-pass/glm-5.1",
contextWindow: 202752,
maxInputTokens: 202752,
maxTokens: 131072,
@@ -3699,13 +3793,13 @@ export const GENERATED_PROVIDER_MODELS: {
cacheRead: 0.182,
cacheWrite: 0,
},
releaseDate: "2026-03-27",
releaseDate: "2026-04-07",
family: "glm",
description: "",
},
"cline-pass/deepseek-v4-flash": {
id: "cline-pass/deepseek-v4-flash",
name: "DeepSeek V4 Flash",
id: "cline-pass/deepseek-v4-flash",
contextWindow: 1000000,
maxInputTokens: 1000000,
maxTokens: 65536,
@@ -3727,8 +3821,8 @@ export const GENERATED_PROVIDER_MODELS: {
description: "",
},
"cline-pass/deepseek-v4-pro": {
id: "cline-pass/deepseek-v4-pro",
name: "DeepSeek V4 Pro",
id: "cline-pass/deepseek-v4-pro",
contextWindow: 1048576,
maxInputTokens: 1048576,
maxTokens: 384000,
@@ -3750,8 +3844,8 @@ export const GENERATED_PROVIDER_MODELS: {
description: "",
},
"cline-pass/kimi-k2.6": {
id: "cline-pass/kimi-k2.6",
name: "Kimi K2.6",
id: "cline-pass/kimi-k2.6",
contextWindow: 262142,
maxInputTokens: 262142,
maxTokens: 262142,
@@ -9890,6 +9984,27 @@ export const GENERATED_PROVIDER_MODELS: {
},
},
ollama: {
"glm-5.2": {
id: "glm-5.2",
name: "GLM-5.2",
contextWindow: 976000,
maxInputTokens: 976000,
maxTokens: 131072,
capabilities: [
"tools",
"reasoning",
"structured_output",
"temperature",
],
pricing: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
releaseDate: "2026-06-13",
family: "glm",
},
"kimi-k2.7-code": {
id: "kimi-k2.7-code",
name: "kimi-k2.7-code",
@@ -11242,6 +11357,45 @@ export const GENERATED_PROVIDER_MODELS: {
},
},
openrouter: {
"google/gemini-3-pro-image": {
id: "google/gemini-3-pro-image",
name: "Nano Banana Pro (Gemini 3 Pro Image)",
contextWindow: 65536,
maxInputTokens: 65536,
maxTokens: 32768,
capabilities: [
"images",
"tools",
"reasoning",
"structured_output",
"temperature",
"prompt-cache",
],
pricing: {
input: 2,
output: 12,
cacheRead: 0.2,
cacheWrite: 0.375,
},
releaseDate: "2026-06-18",
family: "gemini",
},
"cohere/north-mini-code:free": {
id: "cohere/north-mini-code:free",
name: "North Mini Code (free)",
contextWindow: 256000,
maxInputTokens: 256000,
maxTokens: 64000,
capabilities: ["tools", "reasoning", "temperature"],
pricing: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
releaseDate: "2026-06-17",
family: "north",
},
"z-ai/glm-5.2": {
id: "z-ai/glm-5.2",
name: "GLM-5.2",
@@ -11696,7 +11850,12 @@ export const GENERATED_PROVIDER_MODELS: {
contextWindow: 262144,
maxInputTokens: 262144,
maxTokens: 32768,
capabilities: ["tools", "reasoning", "temperature"],
capabilities: [
"tools",
"reasoning",
"structured_output",
"temperature",
],
pricing: {
input: 0,
output: 0,
@@ -11711,7 +11870,12 @@ export const GENERATED_PROVIDER_MODELS: {
contextWindow: 262144,
maxInputTokens: 262144,
maxTokens: 32768,
capabilities: ["tools", "reasoning", "temperature"],
capabilities: [
"tools",
"reasoning",
"structured_output",
"temperature",
],
pricing: {
input: 0,
output: 0,
@@ -12329,6 +12493,28 @@ export const GENERATED_PROVIDER_MODELS: {
releaseDate: "2026-04-07",
family: "claude-opus",
},
"z-ai/glm-5.1": {
id: "z-ai/glm-5.1",
name: "GLM-5.1",
contextWindow: 202752,
maxInputTokens: 202752,
maxTokens: 131072,
capabilities: [
"tools",
"reasoning",
"structured_output",
"temperature",
"prompt-cache",
],
pricing: {
input: 0.98,
output: 3.08,
cacheRead: 0.182,
cacheWrite: 0,
},
releaseDate: "2026-04-07",
family: "glm",
},
"google/gemma-4-26b-a4b-it": {
id: "google/gemma-4-26b-a4b-it",
name: "Gemma 4 26B A4B IT",
@@ -12354,8 +12540,8 @@ export const GENERATED_PROVIDER_MODELS: {
"google/gemma-4-26b-a4b-it:free": {
id: "google/gemma-4-26b-a4b-it:free",
name: "Gemma 4 26B A4B (free)",
contextWindow: 262144,
maxInputTokens: 262144,
contextWindow: 131072,
maxInputTokens: 131072,
maxTokens: 32768,
capabilities: ["images", "tools", "reasoning", "temperature"],
pricing: {
@@ -12395,7 +12581,7 @@ export const GENERATED_PROVIDER_MODELS: {
name: "Gemma 4 31B (free)",
contextWindow: 262144,
maxInputTokens: 262144,
maxTokens: 32768,
maxTokens: 8192,
capabilities: ["images", "tools", "reasoning", "temperature"],
pricing: {
input: 0,
@@ -12496,28 +12682,6 @@ export const GENERATED_PROVIDER_MODELS: {
releaseDate: "2026-03-27",
family: "kat-coder",
},
"z-ai/glm-5.1": {
id: "z-ai/glm-5.1",
name: "GLM-5.1",
contextWindow: 202752,
maxInputTokens: 202752,
maxTokens: 131072,
capabilities: [
"tools",
"reasoning",
"structured_output",
"temperature",
"prompt-cache",
],
pricing: {
input: 0.98,
output: 3.08,
cacheRead: 0.182,
cacheWrite: 0,
},
releaseDate: "2026-03-27",
family: "glm",
},
"rekaai/reka-edge": {
id: "rekaai/reka-edge",
name: "Reka Edge",
@@ -13108,7 +13272,7 @@ export const GENERATED_PROVIDER_MODELS: {
cacheRead: 0.12,
cacheWrite: 0,
},
releaseDate: "2026-02-11",
releaseDate: "2026-02-12",
family: "glm",
},
"qwen/qwen3-max-thinking": {
@@ -13258,6 +13422,27 @@ export const GENERATED_PROVIDER_MODELS: {
releaseDate: "2026-01-27",
family: "solar-pro",
},
"liquid/lfm-2.5-1.2b-thinking:free": {
id: "liquid/lfm-2.5-1.2b-thinking:free",
name: "LFM2.5-1.2B-Thinking (free)",
contextWindow: 32768,
maxInputTokens: 32768,
maxTokens: 32768,
capabilities: [
"tools",
"reasoning",
"structured_output",
"temperature",
],
pricing: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
releaseDate: "2026-01-20",
family: "liquid",
},
"openai/gpt-audio": {
id: "openai/gpt-audio",
name: "GPT Audio",
@@ -13427,7 +13612,7 @@ export const GENERATED_PROVIDER_MODELS: {
name: "Gemini 3 Flash Preview",
contextWindow: 1048576,
maxInputTokens: 1048576,
maxTokens: 65536,
maxTokens: 65535,
capabilities: [
"images",
"files",
@@ -13446,22 +13631,6 @@ export const GENERATED_PROVIDER_MODELS: {
releaseDate: "2025-12-17",
family: "gemini-flash",
},
"xiaomi/mimo-v2-flash": {
id: "xiaomi/mimo-v2-flash",
name: "MiMo-V2-Flash",
contextWindow: 262144,
maxInputTokens: 262144,
maxTokens: 65536,
capabilities: ["tools", "reasoning", "temperature", "prompt-cache"],
pricing: {
input: 0.1,
output: 0.3,
cacheRead: 0.01,
cacheWrite: 0,
},
releaseDate: "2025-12-16",
family: "mimo",
},
"nvidia/nemotron-3-nano-30b-a3b": {
id: "nvidia/nemotron-3-nano-30b-a3b",
name: "Nemotron 3 Nano 30B A3B",
@@ -14002,7 +14171,13 @@ export const GENERATED_PROVIDER_MODELS: {
contextWindow: 131072,
maxInputTokens: 131072,
maxTokens: 65536,
capabilities: ["tools", "reasoning", "temperature", "prompt-cache"],
capabilities: [
"tools",
"reasoning",
"structured_output",
"temperature",
"prompt-cache",
],
pricing: {
input: 0.075,
output: 0.3,
@@ -14056,7 +14231,7 @@ export const GENERATED_PROVIDER_MODELS: {
contextWindow: 131072,
maxInputTokens: 131072,
maxTokens: 32768,
capabilities: ["images", "tools", "temperature"],
capabilities: ["images", "tools", "structured_output", "temperature"],
pricing: {
input: 0.104,
output: 0.416,
@@ -14285,7 +14460,12 @@ export const GENERATED_PROVIDER_MODELS: {
contextWindow: 262144,
maxInputTokens: 262144,
maxTokens: 32768,
capabilities: ["tools", "temperature", "prompt-cache"],
capabilities: [
"tools",
"structured_output",
"temperature",
"prompt-cache",
],
pricing: {
input: 0.78,
output: 3.9,
@@ -14323,7 +14503,13 @@ export const GENERATED_PROVIDER_MODELS: {
contextWindow: 131072,
maxInputTokens: 131072,
maxTokens: 32768,
capabilities: ["images", "tools", "reasoning", "temperature"],
capabilities: [
"images",
"tools",
"reasoning",
"structured_output",
"temperature",
],
pricing: {
input: 0.26,
output: 2.6,
@@ -14766,7 +14952,7 @@ export const GENERATED_PROVIDER_MODELS: {
name: "gpt-oss-20b (free)",
contextWindow: 131072,
maxInputTokens: 131072,
maxTokens: 8192,
maxTokens: 32768,
capabilities: [
"tools",
"reasoning",
@@ -15900,6 +16086,22 @@ export const GENERATED_PROVIDER_MODELS: {
releaseDate: "2024-11-01",
family: "mistral-large",
},
"qwen/qwen-2.5-7b-instruct": {
id: "qwen/qwen-2.5-7b-instruct",
name: "Qwen2.5 7B Instruct",
contextWindow: 32768,
maxInputTokens: 32768,
maxTokens: 32768,
capabilities: ["tools", "structured_output", "temperature"],
pricing: {
input: 0.04,
output: 0.1,
cacheRead: 0,
cacheWrite: 0,
},
releaseDate: "2024-10-16",
family: "qwen",
},
"thedrummer/rocinante-12b": {
id: "thedrummer/rocinante-12b",
name: "Rocinante 12B",
@@ -16286,7 +16488,12 @@ export const GENERATED_PROVIDER_MODELS: {
contextWindow: 1000000,
maxInputTokens: 1000000,
maxTokens: 32768,
capabilities: ["tools", "temperature", "prompt-cache"],
capabilities: [
"tools",
"structured_output",
"temperature",
"prompt-cache",
],
pricing: {
input: 0.26,
output: 0.78,
@@ -22074,7 +22281,7 @@ export const GENERATED_PROVIDER_MODELS: {
cacheRead: 0.26,
cacheWrite: 0,
},
releaseDate: "2026-03-27",
releaseDate: "2026-04-07",
family: "glm",
},
"nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-FP8": {
@@ -22591,6 +22798,28 @@ export const GENERATED_PROVIDER_MODELS: {
},
},
zai: {
"glm-5.1": {
id: "glm-5.1",
name: "GLM-5.1",
contextWindow: 200000,
maxInputTokens: 200000,
maxTokens: 131072,
capabilities: [
"tools",
"reasoning",
"structured_output",
"temperature",
"prompt-cache",
],
pricing: {
input: 1.4,
output: 4.4,
cacheRead: 0.26,
cacheWrite: 0,
},
releaseDate: "2026-04-07",
family: "glm",
},
"glm-5v-turbo": {
id: "glm-5v-turbo",
name: "GLM-5V-Turbo",
@@ -22614,28 +22843,6 @@ export const GENERATED_PROVIDER_MODELS: {
releaseDate: "2026-04-01",
family: "glm",
},
"glm-5.1": {
id: "glm-5.1",
name: "GLM-5.1",
contextWindow: 200000,
maxInputTokens: 200000,
maxTokens: 131072,
capabilities: [
"tools",
"reasoning",
"structured_output",
"temperature",
"prompt-cache",
],
pricing: {
input: 1.4,
output: 4.4,
cacheRead: 0.26,
cacheWrite: 0,
},
releaseDate: "2026-03-27",
family: "glm",
},
"glm-5-turbo": {
id: "glm-5-turbo",
name: "GLM-5-Turbo",
@@ -22671,7 +22878,7 @@ export const GENERATED_PROVIDER_MODELS: {
cacheRead: 0.2,
cacheWrite: 0,
},
releaseDate: "2026-02-11",
releaseDate: "2026-02-12",
family: "glm",
},
"glm-4.7-flash": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/sdk",
"description": "Cline SDK - user-facing alias for @cline/core",
"version": "0.0.49",
"version": "0.0.50",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/shared",
"version": "0.0.49",
"version": "0.0.50",
"description": "Shared utilities, types, and schemas for Cline packages",
"repository": {
"type": "git",
+1
View File
@@ -300,6 +300,7 @@ export interface AgentBeforeToolResult {
stop?: boolean;
reason?: string;
input?: unknown;
policy?: ToolPolicy;
}
export interface AgentAfterToolContext {
+4 -2
View File
@@ -19,7 +19,8 @@ Remember:
- Be explicit about any assumptions or limitations in your solution.
- Always show your planning process before executing any task. This will help ensure that you have a clear understanding of the requirements and that your approach aligns with the user's needs.
- Always use absolute paths when referring to files.
- You can call multiple tools in a single response. When tool calls are independent and do not require each other's results, call them together in the same response. Do not split independent reads, searches, or checks across separate turns.
- You can call multiple tools in a single response. Before using tools, identify every independent read, search, command, or edit needed for the next step and emit all of those tool calls now, either as multiple tool calls or as one batched input for tools that accept arrays. Do not wait for one independent result before requesting another. Do not split independent reads, searches, checks, or edits across separate turns.
- Good parallelism examples: read all known relevant files in one read_files call; run independent inspection commands in one run_commands call; emit independent read_files, search_codebase, and run_commands calls together in one response; emit multiple editor calls together when editing different files or non-overlapping regions.
- Always verify the files you have edited or created at the end of the task to ensure they are completed and working as expected.
Begin by analyzing the user's input and gathering any necessary additional context. Then, present your plan at the start of your response along with tool calls before proceeding with the task. It's OK for this section to be quite long.
@@ -44,7 +45,8 @@ RULES:
- Provide complete and functional code without omissions or placeholders.
- Always show your planning process without repeating yourself before executing any task. This will help ensure that you have a clear understanding of the requirements and that your approach aligns with the user's request.
- Always use absolute paths when referring to files.
- You can call multiple tools in a single response. When tool calls are independent and do not require each other's results, call them together in the same response. Do not split independent reads, searches, or checks across separate turns.
- You can call multiple tools in a single response. Before using tools, identify every independent read, search, command, or edit needed for the next step and emit all of those tool calls now, either as multiple tool calls or as one batched input for tools that accept arrays. Do not wait for one independent result before requesting another. Do not split independent reads, searches, checks, or edits across separate turns.
- Good parallelism examples: read all known relevant files in one read_files call; run independent inspection commands in one run_commands call; emit independent read_files, search_codebase, and run_commands calls together in one response; emit multiple editor calls together when editing different files or non-overlapping regions.
- Always verify the files you have edited or created at the end of the task to ensure they are completed and working as expected.
Environment you are running in:
@@ -24,8 +24,6 @@ type EnvSnapshot = {
CLINE_DATA_DIR: string | undefined;
CLINE_DB_DATA_DIR: string | undefined;
CLINE_GLOBAL_SETTINGS_PATH: string | undefined;
CLINE_ENVIRONMENT: string | undefined;
CLINE_ENVIRONMENT_OVERRIDE: string | undefined;
CLINE_MCP_SETTINGS_PATH: string | undefined;
CLINE_PROVIDER_SETTINGS_PATH: string | undefined;
CLINE_SESSION_DATA_DIR: string | undefined;
@@ -38,8 +36,6 @@ function captureEnv(): EnvSnapshot {
CLINE_DATA_DIR: process.env.CLINE_DATA_DIR,
CLINE_DB_DATA_DIR: process.env.CLINE_DB_DATA_DIR,
CLINE_GLOBAL_SETTINGS_PATH: process.env.CLINE_GLOBAL_SETTINGS_PATH,
CLINE_ENVIRONMENT: process.env.CLINE_ENVIRONMENT,
CLINE_ENVIRONMENT_OVERRIDE: process.env.CLINE_ENVIRONMENT_OVERRIDE,
CLINE_MCP_SETTINGS_PATH: process.env.CLINE_MCP_SETTINGS_PATH,
CLINE_PROVIDER_SETTINGS_PATH: process.env.CLINE_PROVIDER_SETTINGS_PATH,
CLINE_SESSION_DATA_DIR: process.env.CLINE_SESSION_DATA_DIR,
@@ -52,8 +48,6 @@ function restoreEnv(snapshot: EnvSnapshot): void {
process.env.CLINE_DIR = snapshot.CLINE_DIR;
process.env.CLINE_DB_DATA_DIR = snapshot.CLINE_DB_DATA_DIR;
process.env.CLINE_GLOBAL_SETTINGS_PATH = snapshot.CLINE_GLOBAL_SETTINGS_PATH;
process.env.CLINE_ENVIRONMENT = snapshot.CLINE_ENVIRONMENT;
process.env.CLINE_ENVIRONMENT_OVERRIDE = snapshot.CLINE_ENVIRONMENT_OVERRIDE;
process.env.CLINE_MCP_SETTINGS_PATH = snapshot.CLINE_MCP_SETTINGS_PATH;
process.env.CLINE_PROVIDER_SETTINGS_PATH =
snapshot.CLINE_PROVIDER_SETTINGS_PATH;
@@ -102,8 +96,6 @@ describe("storage path resolution", () => {
it("falls back to CLINE_DATA_DIR/settings/providers.json for provider settings", () => {
snapshot = captureEnv();
delete process.env.CLINE_PROVIDER_SETTINGS_PATH;
delete process.env.CLINE_ENVIRONMENT;
delete process.env.CLINE_ENVIRONMENT_OVERRIDE;
process.env.CLINE_DATA_DIR = "/tmp/cline-data";
expect(resolveProviderSettingsPath()).toBe(
@@ -111,39 +103,6 @@ describe("storage path resolution", () => {
);
});
it("resolves staging provider settings as a full replacement file", () => {
snapshot = captureEnv();
delete process.env.CLINE_PROVIDER_SETTINGS_PATH;
delete process.env.CLINE_ENVIRONMENT_OVERRIDE;
process.env.CLINE_ENVIRONMENT = "staging";
process.env.CLINE_DATA_DIR = "/tmp/cline-data";
expect(resolveProviderSettingsPath()).toBe(
join("/tmp/cline-data", "settings", "providers.staging.json"),
);
});
it("resolves local provider settings as a full replacement file", () => {
snapshot = captureEnv();
delete process.env.CLINE_PROVIDER_SETTINGS_PATH;
delete process.env.CLINE_ENVIRONMENT_OVERRIDE;
process.env.CLINE_ENVIRONMENT = "local";
process.env.CLINE_DATA_DIR = "/tmp/cline-data";
expect(resolveProviderSettingsPath()).toBe(
join("/tmp/cline-data", "settings", "providers.local.json"),
);
});
it("keeps explicit provider settings path precedence over environment", () => {
snapshot = captureEnv();
process.env.CLINE_PROVIDER_SETTINGS_PATH = "/tmp/custom-providers.json";
process.env.CLINE_ENVIRONMENT = "staging";
process.env.CLINE_DATA_DIR = "/tmp/cline-data";
expect(resolveProviderSettingsPath()).toBe("/tmp/custom-providers.json");
});
it("falls back to CLINE_DATA_DIR/settings/global-settings.json for global settings", () => {
snapshot = captureEnv();
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
+1 -13
View File
@@ -10,7 +10,6 @@ import {
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import type { PluginManifest } from "..";
import { resolveClineEnvironment } from "../runtime/cline-environment";
const DEPRECATED_CONFIG_DIR = ".clinerules";
const CLINE_CONFIG_DIR = ".cline";
@@ -260,23 +259,12 @@ export function resolveCronEventsDir(
);
}
function getProviderSettingsFileName(): string {
const environment = resolveClineEnvironment();
if (environment === "staging" || environment === "local") {
return `providers.${environment}.json`;
}
return "providers.json";
}
export function resolveProviderSettingsPath(): string {
const explicitPath = process.env.CLINE_PROVIDER_SETTINGS_PATH?.trim();
if (explicitPath) {
return explicitPath;
}
return join(resolveClineDataDir(), "settings", getProviderSettingsFileName());
return join(resolveClineDataDir(), "settings", "providers.json");
}
export function resolveGlobalSettingsPath(): string {