mirror of
https://github.com/cline/cline.git
synced 2026-09-07 22:16:30 +08:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c23f80a94 | ||
|
|
4be362892f | ||
|
|
cdff084652 | ||
|
|
299a4a9520 | ||
|
|
c497698beb | ||
|
|
292133989b | ||
|
|
f5aa035a6a | ||
|
|
bbcae25542 | ||
|
|
9b914912ac | ||
|
|
8c369eeed9 | ||
|
|
85223a07cf | ||
|
|
eb2687677c | ||
|
|
6fe5acb2a4 |
@@ -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,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": {
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+3
-3
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
@@ -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,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([
|
||||
|
||||
@@ -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,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,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,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,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",
|
||||
|
||||
@@ -300,6 +300,7 @@ export interface AgentBeforeToolResult {
|
||||
stop?: boolean;
|
||||
reason?: string;
|
||||
input?: unknown;
|
||||
policy?: ToolPolicy;
|
||||
}
|
||||
|
||||
export interface AgentAfterToolContext {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user